@xpufx/paseo-top 0.4.4 → 0.4.5

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 (129) hide show
  1. package/OVERVIEW.md +17 -0
  2. package/client/host-ui.tsx +184 -0
  3. package/client/multi-host-view.tsx +57 -43
  4. package/client/multi-host.ts +36 -13
  5. package/client/pill-labels.ts +94 -1
  6. package/client/pill.tsx +690 -614
  7. package/client/resources-query.ts +1 -1
  8. package/client/settings-events.ts +1 -1
  9. package/client/settings-ui.tsx +86 -9
  10. package/client/surface.tsx +308 -276
  11. package/client/telemetry-copy.ts +8 -2
  12. package/client/telemetry.tsx +372 -321
  13. package/client/turn-panel.tsx +126 -104
  14. package/client/vendor/paseo-plugin-helper/{host.ts → core/host.ts} +9 -3
  15. package/client/vendor/paseo-plugin-helper/core/index.ts +76 -0
  16. package/client/vendor/paseo-plugin-helper/core/plugin-load-count.ts +89 -0
  17. package/client/vendor/paseo-plugin-helper/{query-refresh.ts → core/query-refresh.ts} +2 -2
  18. package/client/vendor/paseo-plugin-helper/{query.ts → core/query.ts} +2 -2
  19. package/client/vendor/paseo-plugin-helper/{settings.ts → core/settings.ts} +1 -1
  20. package/client/vendor/paseo-plugin-helper/{shared-settings.ts → core/shared-settings.ts} +2 -2
  21. package/client/vendor/paseo-plugin-helper/{utils → lifecycle}/clipboard.ts +2 -2
  22. package/client/vendor/paseo-plugin-helper/lifecycle/flair.ts +26 -0
  23. package/client/vendor/paseo-plugin-helper/{theme/color-utils.ts → lifecycle/host-color.ts} +95 -38
  24. package/client/vendor/paseo-plugin-helper/lifecycle/host-theme.tsx +109 -0
  25. package/client/vendor/paseo-plugin-helper/lifecycle/index.ts +23 -0
  26. package/client/vendor/paseo-plugin-helper/lifecycle/multi-host.ts +66 -0
  27. package/client/vendor/paseo-plugin-helper/{panel.tsx → lifecycle/panel.tsx} +7 -7
  28. package/client/vendor/paseo-plugin-helper/{pill.tsx → lifecycle/pill.tsx} +51 -18
  29. package/client/vendor/paseo-plugin-helper/lifecycle/providers.tsx +46 -0
  30. package/client/vendor/paseo-plugin-helper/lifecycle/scroll-owner.ts +26 -0
  31. package/client/vendor/paseo-plugin-helper/{surface.tsx → lifecycle/surface.tsx} +6 -6
  32. package/client/vendor/paseo-plugin-helper/ui/color.ts +1 -0
  33. package/client/vendor/paseo-plugin-helper/ui/content.tsx +1326 -0
  34. package/client/vendor/paseo-plugin-helper/ui/controls.tsx +1018 -0
  35. package/client/vendor/paseo-plugin-helper/{components/DataTable.tsx → ui/data.tsx} +50 -58
  36. package/client/vendor/paseo-plugin-helper/ui/index.ts +44 -0
  37. package/client/vendor/paseo-plugin-helper/ui/layout.tsx +263 -0
  38. package/client/vendor/paseo-plugin-helper/ui/modal.tsx +111 -0
  39. package/client/vendor/paseo-plugin-helper/ui/plugin-reload-banner.tsx +119 -0
  40. package/client/vendor/paseo-plugin-helper/{settings-screen.tsx → ui/settings-screen.tsx} +3 -3
  41. package/client/vendor/paseo-plugin-helper/ui/settings.ts +36 -0
  42. package/client/vendor/paseo-plugin-helper/ui/text.tsx +821 -0
  43. package/client/vendor/paseo-plugin-helper/ui/theme.tsx +1 -0
  44. package/client/vendor/permission-audit/client.tsx +706 -0
  45. package/client/vendor/permission-audit/index.tsx +1 -0
  46. package/index.client.tsx +58 -37
  47. package/index.server.ts +256 -234
  48. package/package.json +7 -4
  49. package/paseo-plugin.json +2 -0
  50. package/screenshots/1-paseo-top-modal-system.jpg +0 -0
  51. package/screenshots/2-paseo-top-modal-workspace.jpg +0 -0
  52. package/screenshots/3-paseo-top-modal-settings.jpg +0 -0
  53. package/screenshots/4-paseo-top-modal-about.jpg +0 -0
  54. package/screenshots/paseo-top-dashboard.jpg +0 -0
  55. package/screenshots/paseo-top-general-view.jpg +0 -0
  56. package/server/resources.ts +98 -0
  57. package/server/vendor/paseo-plugin-helper/custom-pills.ts +70 -0
  58. package/server/vendor/paseo-plugin-helper/index.ts +2 -0
  59. package/server/vendor/paseo-plugin-helper/logger.ts +5 -16
  60. package/server/vendor/paseo-plugin-helper/rpc-guard.ts +2 -2
  61. package/server/vendor/paseo-plugin-helper/storage-guard.ts +294 -0
  62. package/server/vendor/paseo-plugin-helper/storage.ts +36 -2
  63. package/server/vendor/paseo-plugin-helper/telemetry.ts +486 -0
  64. package/server/vendor/paseo-plugin-helper/tickets.ts +18 -67
  65. package/server/vendor/permission-audit/index.ts +1 -0
  66. package/server/vendor/permission-audit/server.ts +839 -0
  67. package/shared/helper-version.ts +1 -1
  68. package/shared/multi-host.ts +153 -159
  69. package/shared/resources.ts +145 -0
  70. package/shared/vendor/paseo-plugin-helper/async.ts +14 -7
  71. package/shared/vendor/paseo-plugin-helper/features.ts +244 -0
  72. package/shared/vendor/paseo-plugin-helper/forge.ts +27 -0
  73. package/shared/vendor/paseo-plugin-helper/index.ts +1 -0
  74. package/shared/vendor/paseo-plugin-helper/tickets.ts +122 -28
  75. package/shared/vendor/permission-audit/filter.ts +61 -0
  76. package/shared/vendor/permission-audit/index.ts +2 -0
  77. package/shared/vendor/permission-audit/shared.ts +119 -0
  78. package/shared/version.ts +1 -1
  79. package/client/vendor/paseo-plugin-helper/components/AboutSection.tsx +0 -493
  80. package/client/vendor/paseo-plugin-helper/components/AttentionBeacon.tsx +0 -250
  81. package/client/vendor/paseo-plugin-helper/components/Badge.tsx +0 -168
  82. package/client/vendor/paseo-plugin-helper/components/Button.tsx +0 -204
  83. package/client/vendor/paseo-plugin-helper/components/Card.tsx +0 -257
  84. package/client/vendor/paseo-plugin-helper/components/CodeBlock.tsx +0 -196
  85. package/client/vendor/paseo-plugin-helper/components/Collapsible.tsx +0 -277
  86. package/client/vendor/paseo-plugin-helper/components/CommandBox.tsx +0 -172
  87. package/client/vendor/paseo-plugin-helper/components/CopyButton.tsx +0 -180
  88. package/client/vendor/paseo-plugin-helper/components/EmptyState.tsx +0 -97
  89. package/client/vendor/paseo-plugin-helper/components/HighlightedText.tsx +0 -70
  90. package/client/vendor/paseo-plugin-helper/components/InlineButton.tsx +0 -88
  91. package/client/vendor/paseo-plugin-helper/components/InteractiveRow.tsx +0 -142
  92. package/client/vendor/paseo-plugin-helper/components/KeyValue.tsx +0 -446
  93. package/client/vendor/paseo-plugin-helper/components/MetricGauge.tsx +0 -247
  94. package/client/vendor/paseo-plugin-helper/components/ProgressBar.tsx +0 -117
  95. package/client/vendor/paseo-plugin-helper/components/Responsive.tsx +0 -53
  96. package/client/vendor/paseo-plugin-helper/components/SearchInput.tsx +0 -132
  97. package/client/vendor/paseo-plugin-helper/components/SectionHeader.tsx +0 -87
  98. package/client/vendor/paseo-plugin-helper/components/Select.tsx +0 -283
  99. package/client/vendor/paseo-plugin-helper/components/StatusDot.tsx +0 -80
  100. package/client/vendor/paseo-plugin-helper/components/Tabs.tsx +0 -319
  101. package/client/vendor/paseo-plugin-helper/components/TextInput.tsx +0 -150
  102. package/client/vendor/paseo-plugin-helper/components/Toggle.tsx +0 -163
  103. package/client/vendor/paseo-plugin-helper/components/TruncatedText.tsx +0 -157
  104. package/client/vendor/paseo-plugin-helper/components/index.ts +0 -26
  105. package/client/vendor/paseo-plugin-helper/custom-pills.tsx +0 -225
  106. package/client/vendor/paseo-plugin-helper/forge-icon.tsx +0 -79
  107. package/client/vendor/paseo-plugin-helper/icon.tsx +0 -39
  108. package/client/vendor/paseo-plugin-helper/index.ts +0 -30
  109. package/client/vendor/paseo-plugin-helper/layout/ActionBar.tsx +0 -49
  110. package/client/vendor/paseo-plugin-helper/layout/FormRow.tsx +0 -103
  111. package/client/vendor/paseo-plugin-helper/layout/Grid.tsx +0 -65
  112. package/client/vendor/paseo-plugin-helper/layout/ModalBody.tsx +0 -368
  113. package/client/vendor/paseo-plugin-helper/layout/ModalContent.tsx +0 -51
  114. package/client/vendor/paseo-plugin-helper/layout/Row.tsx +0 -39
  115. package/client/vendor/paseo-plugin-helper/layout/Stack.tsx +0 -39
  116. package/client/vendor/paseo-plugin-helper/layout/index.ts +0 -7
  117. package/client/vendor/paseo-plugin-helper/styles/index.ts +0 -1
  118. package/client/vendor/paseo-plugin-helper/styles/recipes.ts +0 -550
  119. package/client/vendor/paseo-plugin-helper/theme/flair.ts +0 -76
  120. package/client/vendor/paseo-plugin-helper/theme/host-variables.ts +0 -121
  121. package/client/vendor/paseo-plugin-helper/theme/index.ts +0 -7
  122. package/client/vendor/paseo-plugin-helper/theme/provider.tsx +0 -178
  123. package/client/vendor/paseo-plugin-helper/theme/responsive.ts +0 -213
  124. package/client/vendor/paseo-plugin-helper/theme/tokens.ts +0 -200
  125. package/client/vendor/paseo-plugin-helper/theme/useResponsive.ts +0 -57
  126. package/client/vendor/paseo-plugin-helper/tickets.tsx +0 -766
  127. /package/client/vendor/paseo-plugin-helper/{command-center.ts → core/command-center.ts} +0 -0
  128. /package/client/vendor/paseo-plugin-helper/{snapshot.ts → core/snapshot.ts} +0 -0
  129. /package/client/vendor/paseo-plugin-helper/{utils → lifecycle}/haptics.ts +0 -0
@@ -0,0 +1,1018 @@
1
+ import React, { useRef, useState, type ComponentType, type ReactNode, type Ref } from "react";
2
+ import {
3
+ ActivityIndicator,
4
+ Modal,
5
+ Pressable,
6
+ ScrollView as RNScrollView,
7
+ StyleSheet,
8
+ Text,
9
+ TextInput as RNTextInput,
10
+ TouchableWithoutFeedback,
11
+ View,
12
+ type KeyboardTypeOptions,
13
+ type StyleProp,
14
+ type TextInputProps as RNTextInputProps,
15
+ type TextInput as RNTextInputInstance,
16
+ type TextStyle,
17
+ type ViewStyle,
18
+ } from "react-native";
19
+ import { getClientHost, getOptionalClientHost } from "../core/host";
20
+ import { copyToClipboard } from "../lifecycle/clipboard";
21
+ import { useHostTheme } from "./theme";
22
+ import { alpha } from "./color";
23
+ import { spacing } from "./layout";
24
+
25
+ /**
26
+ * Paseo Plugin Helper — UI control adapters (`paseo-plugin-helper/ui`).
27
+ *
28
+ * Thin, host-delegating control adapters
29
+ * (Button, Toggle, Select, TextInput, …). Same contract as the rest of ui/:
30
+ * host theme colors only, no scroll ownership, no design-system machinery.
31
+ * Text inputs prefer the host-injected `TextInput` (modal keyboard
32
+ * positioning) and fall back to plain React Native.
33
+ */
34
+
35
+ // --- Button ----------------------------------------------------------------
36
+
37
+ export type HostButtonVariant = "primary" | "secondary" | "danger" | "ghost";
38
+ export type HostButtonSize = "sm" | "md" | "lg";
39
+
40
+ export interface HostButtonProps {
41
+ label?: string;
42
+ children?: ReactNode;
43
+ variant?: HostButtonVariant;
44
+ size?: HostButtonSize;
45
+ icon?: string | ReactNode;
46
+ iconPosition?: "left" | "right";
47
+ onPress?: () => void | Promise<void>;
48
+ disabled?: boolean;
49
+ loading?: boolean;
50
+ style?: StyleProp<ViewStyle>;
51
+ textStyle?: StyleProp<TextStyle>;
52
+ accessibilityLabel?: string;
53
+ accessibilityRole?: "button" | "link";
54
+ }
55
+
56
+ /** Pressable button with host-theme variant colors. */
57
+ export function HostButton({
58
+ label,
59
+ children,
60
+ variant = "secondary",
61
+ size = "md",
62
+ icon,
63
+ iconPosition = "left",
64
+ onPress,
65
+ disabled = false,
66
+ loading = false,
67
+ style,
68
+ textStyle,
69
+ accessibilityLabel,
70
+ accessibilityRole = "button",
71
+ }: HostButtonProps) {
72
+ const { Icon } = getClientHost();
73
+ const { colors, alpha: alphaColor } = useHostTheme();
74
+
75
+ const py = size === "sm" ? 6 : size === "lg" ? 12 : 10;
76
+ const px = size === "sm" ? 10 : size === "lg" ? 18 : 14;
77
+ const fontSize = size === "sm" ? 12 : size === "lg" ? 15 : 13;
78
+ const iconSize = size === "sm" ? 12 : size === "lg" ? 16 : 14;
79
+
80
+ let bg = "transparent";
81
+ let border = "transparent";
82
+ let textColor = colors.foreground;
83
+
84
+ switch (variant) {
85
+ case "primary":
86
+ bg = colors.accent;
87
+ textColor = colors.accentForeground;
88
+ break;
89
+ case "danger":
90
+ bg = alphaColor(colors.statusDanger, 0.15);
91
+ border = alphaColor(colors.statusDanger, 0.4);
92
+ textColor = colors.statusDanger;
93
+ break;
94
+ case "ghost":
95
+ bg = "transparent";
96
+ textColor = colors.foregroundMuted;
97
+ break;
98
+ case "secondary":
99
+ default:
100
+ bg = colors.surface1;
101
+ border = colors.border;
102
+ textColor = colors.foreground;
103
+ break;
104
+ }
105
+
106
+ const renderIcon = () => {
107
+ if (!icon) return null;
108
+ if (typeof icon === "string") {
109
+ return <Icon name={icon} size={iconSize} color={textColor} />;
110
+ }
111
+ return icon;
112
+ };
113
+
114
+ return (
115
+ <Pressable
116
+ onPress={onPress}
117
+ disabled={disabled || loading}
118
+ accessibilityRole={accessibilityRole}
119
+ accessibilityLabel={accessibilityLabel || label}
120
+ hitSlop={4}
121
+ style={({ pressed }) => [
122
+ styles.buttonBase,
123
+ {
124
+ backgroundColor: pressed && !disabled ? alphaColor(bg, 0.8) : bg,
125
+ borderColor: border,
126
+ borderWidth: border !== "transparent" ? 1 : 0,
127
+ borderRadius: 10,
128
+ paddingVertical: py,
129
+ paddingHorizontal: px,
130
+ opacity: disabled ? 0.45 : 1,
131
+ },
132
+ style,
133
+ ]}
134
+ >
135
+ {loading ? (
136
+ <ActivityIndicator size="small" color={textColor} />
137
+ ) : (
138
+ <View style={styles.buttonContent}>
139
+ {iconPosition === "left" ? renderIcon() : null}
140
+ {children ? (
141
+ <Text style={[styles.buttonText, { color: textColor, fontSize }, textStyle]}>
142
+ {children}
143
+ </Text>
144
+ ) : (
145
+ <Text style={[styles.buttonText, { color: textColor, fontSize }, textStyle]}>
146
+ {label}
147
+ </Text>
148
+ )}
149
+ {iconPosition === "right" ? renderIcon() : null}
150
+ </View>
151
+ )}
152
+ </Pressable>
153
+ );
154
+ }
155
+
156
+ // --- Inline button ---------------------------------------------------------
157
+
158
+ export interface HostInlineButtonProps {
159
+ label: string;
160
+ onPress?: () => void | Promise<void>;
161
+ icon?: string | ReactNode;
162
+ disabled?: boolean;
163
+ accessibilityLabel?: string;
164
+ accessibilityRole?: "button" | "link";
165
+ style?: StyleProp<ViewStyle>;
166
+ textStyle?: StyleProp<TextStyle>;
167
+ }
168
+
169
+ /** Compact text/link action for inline cards and timeline content. */
170
+ export function HostInlineButton({
171
+ label,
172
+ onPress,
173
+ icon,
174
+ disabled = false,
175
+ accessibilityLabel,
176
+ accessibilityRole = "button",
177
+ style,
178
+ textStyle,
179
+ }: HostInlineButtonProps) {
180
+ const { Icon } = getClientHost();
181
+ const { colors } = useHostTheme();
182
+ return (
183
+ <Pressable
184
+ onPress={onPress}
185
+ disabled={disabled}
186
+ accessibilityRole={accessibilityRole}
187
+ accessibilityLabel={accessibilityLabel || label}
188
+ hitSlop={8}
189
+ style={({ pressed }) => [
190
+ styles.inlineButton,
191
+ { opacity: disabled ? 0.45 : pressed ? 0.7 : 1 },
192
+ style,
193
+ ]}
194
+ >
195
+ {icon ? (
196
+ typeof icon === "string" ? (
197
+ <Icon name={icon} size={13} color={colors.accent} />
198
+ ) : (
199
+ icon
200
+ )
201
+ ) : null}
202
+ <Text style={[styles.inlineButtonText, { color: colors.accent }, textStyle]}>{label}</Text>
203
+ </Pressable>
204
+ );
205
+ }
206
+
207
+ // --- Toggle ----------------------------------------------------------------
208
+
209
+ export interface HostToggleProps {
210
+ value: boolean;
211
+ onValueChange: (next: boolean) => void;
212
+ label?: string;
213
+ description?: string;
214
+ disabled?: boolean;
215
+ style?: StyleProp<ViewStyle>;
216
+ labelStyle?: StyleProp<TextStyle>;
217
+ }
218
+
219
+ /** Switch control with optional label/description. */
220
+ export function HostToggle({
221
+ value,
222
+ onValueChange,
223
+ label,
224
+ description,
225
+ disabled = false,
226
+ style,
227
+ labelStyle,
228
+ }: HostToggleProps) {
229
+ const { colors, alpha: alphaColor } = useHostTheme();
230
+
231
+ const handlePress = () => {
232
+ if (!disabled) {
233
+ onValueChange(!value);
234
+ }
235
+ };
236
+
237
+ const trackWidth = 38;
238
+ const trackHeight = 22;
239
+ const thumbSize = 16;
240
+ const thumbPadding = 3;
241
+
242
+ const trackColor = value ? colors.accent : alphaColor(colors.foregroundMuted, 0.35);
243
+ const thumbPosition = value ? trackWidth - thumbSize - thumbPadding : thumbPadding;
244
+
245
+ const hasText = Boolean(label || description);
246
+
247
+ return (
248
+ <Pressable
249
+ onPress={handlePress}
250
+ disabled={disabled}
251
+ accessibilityRole="switch"
252
+ accessibilityState={{ checked: value, disabled }}
253
+ hitSlop={8}
254
+ style={({ pressed }) => [
255
+ styles.toggleContainer,
256
+ !hasText && styles.toggleBare,
257
+ {
258
+ minHeight: 40,
259
+ opacity: disabled ? 0.5 : pressed ? 0.8 : 1,
260
+ },
261
+ style,
262
+ ]}
263
+ >
264
+ {hasText && (
265
+ <View style={styles.toggleTextContainer}>
266
+ {label ? (
267
+ <Text style={[styles.toggleLabel, { color: colors.foreground }, labelStyle]}>
268
+ {label}
269
+ </Text>
270
+ ) : null}
271
+ {description ? (
272
+ <Text style={[styles.toggleDescription, { color: colors.foregroundMuted }]}>
273
+ {description}
274
+ </Text>
275
+ ) : null}
276
+ </View>
277
+ )}
278
+ <View
279
+ style={[
280
+ styles.toggleTrack,
281
+ {
282
+ width: trackWidth,
283
+ height: trackHeight,
284
+ borderRadius: trackHeight / 2,
285
+ backgroundColor: trackColor,
286
+ },
287
+ ]}
288
+ >
289
+ <View
290
+ style={[
291
+ styles.toggleThumb,
292
+ {
293
+ width: thumbSize,
294
+ height: thumbSize,
295
+ borderRadius: thumbSize / 2,
296
+ backgroundColor: colors.accentForeground,
297
+ transform: [{ translateX: thumbPosition }],
298
+ },
299
+ ]}
300
+ />
301
+ </View>
302
+ </Pressable>
303
+ );
304
+ }
305
+
306
+ // --- Select ----------------------------------------------------------------
307
+
308
+ export interface HostSelectOption {
309
+ label: string;
310
+ value: string;
311
+ }
312
+
313
+ export interface HostSelectProps {
314
+ value: string;
315
+ options: HostSelectOption[];
316
+ onValueChange: (value: string) => void;
317
+ label?: string;
318
+ size?: "sm" | "md";
319
+ placeholder?: string;
320
+ disabled?: boolean;
321
+ searchable?: boolean;
322
+ searchPlaceholder?: string;
323
+ style?: StyleProp<ViewStyle>;
324
+ }
325
+
326
+ const OPTION_LIST_MAX_HEIGHT = 216;
327
+
328
+ interface TriggerCoords {
329
+ x: number;
330
+ y: number;
331
+ width: number;
332
+ height: number;
333
+ }
334
+
335
+ const FALLBACK_COORDS: TriggerCoords = { x: 0, y: 0, width: 0, height: 0 };
336
+
337
+ /**
338
+ * Compact single-choice picker. The closed trigger stays one line tall;
339
+ * opening mounts the menu in a root transparent `<Modal>` overlay portal, so a
340
+ * long list never expands the trigger's parent container and always paints
341
+ * above later siblings. The option list scrolls with a plain React Native
342
+ * ScrollView — never the host sheet-gesture scroller (#219).
343
+ */
344
+ export function HostSelect({
345
+ value,
346
+ options,
347
+ onValueChange,
348
+ label,
349
+ size = "md",
350
+ placeholder = "Select…",
351
+ disabled = false,
352
+ searchable = false,
353
+ searchPlaceholder,
354
+ style,
355
+ }: HostSelectProps) {
356
+ const { Icon } = getClientHost();
357
+ const { colors, alpha: alphaColor } = useHostTheme();
358
+ const [open, setOpen] = useState(false);
359
+ const [coords, setCoords] = useState<TriggerCoords | null>(null);
360
+ const triggerRef = useRef<View>(null);
361
+ const [searchQuery, setSearchQuery] = useState("");
362
+
363
+ const fontSize = size === "sm" ? 10 : 12;
364
+ const lineHeight = size === "sm" ? 12 : 15;
365
+ const paddingVertical = size === "sm" ? 3 : 6;
366
+ const paddingHorizontal = size === "sm" ? 8 : 10;
367
+ const iconSize = size === "sm" ? 12 : 14;
368
+
369
+ const selected = options.find((option) => option.value === value);
370
+ const display = selected?.label ?? (value ? value : placeholder);
371
+ const canOpen = !disabled && options.length > 0;
372
+ const isOpen = open && canOpen;
373
+
374
+ const triggerMinHeight = size === "sm" ? 28 : 34;
375
+
376
+ const openAt = (next: TriggerCoords) => {
377
+ setCoords(next);
378
+ setOpen(true);
379
+ };
380
+
381
+ const handleToggle = () => {
382
+ if (isOpen) {
383
+ setOpen(false);
384
+ setSearchQuery("");
385
+ return;
386
+ }
387
+ const node = triggerRef.current;
388
+ if (!node || typeof node.measureInWindow !== "function") {
389
+ openAt(FALLBACK_COORDS);
390
+ return;
391
+ }
392
+ node.measureInWindow((x, y, width, height) => {
393
+ openAt({ x, y, width, height });
394
+ });
395
+ };
396
+
397
+ const handleSelect = (optionValue: string) => {
398
+ onValueChange(optionValue);
399
+ setOpen(false);
400
+ setSearchQuery("");
401
+ };
402
+
403
+ const filteredOptions = searchable && searchQuery
404
+ ? options.filter(
405
+ (option) =>
406
+ option.label.toLowerCase().includes(searchQuery.toLowerCase()) ||
407
+ option.value.toLowerCase().includes(searchQuery.toLowerCase()),
408
+ )
409
+ : options;
410
+
411
+ const ResolvedSearchInput = (
412
+ getOptionalClientHost()?.TextInput ?? RNTextInput
413
+ ) as ComponentType<RNTextInputProps & { ref?: Ref<RNTextInputInstance> }>;
414
+
415
+ const handleSearchChange = (text: string) => {
416
+ setSearchQuery(text);
417
+ };
418
+
419
+ return (
420
+ <View style={[styles.selectContainer, style]}>
421
+ <Pressable
422
+ ref={triggerRef}
423
+ accessibilityRole="button"
424
+ accessibilityLabel={label ? `${label}: ${display}` : display}
425
+ accessibilityState={{ expanded: isOpen, disabled }}
426
+ disabled={!canOpen}
427
+ onPress={handleToggle}
428
+ style={({ pressed }) => [
429
+ styles.selectTrigger,
430
+ {
431
+ minHeight: triggerMinHeight,
432
+ borderColor: isOpen ? colors.accent : colors.border,
433
+ backgroundColor:
434
+ disabled || !canOpen
435
+ ? alphaColor(colors.surface1, 0.5)
436
+ : pressed
437
+ ? colors.surface2
438
+ : colors.surface0,
439
+ paddingVertical,
440
+ paddingHorizontal,
441
+ },
442
+ ]}
443
+ >
444
+ <Text
445
+ numberOfLines={1}
446
+ style={[
447
+ styles.selectTriggerText,
448
+ {
449
+ color: disabled || !canOpen ? colors.foregroundMuted : colors.foreground,
450
+ fontSize,
451
+ lineHeight,
452
+ fontWeight: selected ? "600" : "500",
453
+ },
454
+ ]}
455
+ >
456
+ {display}
457
+ </Text>
458
+ <Icon
459
+ name={isOpen ? "ChevronUp" : "ChevronDown"}
460
+ size={iconSize}
461
+ color={colors.foregroundMuted}
462
+ />
463
+ </Pressable>
464
+
465
+ {isOpen && coords ? (
466
+ <Modal transparent visible={isOpen} animationType="none" onRequestClose={() => setOpen(false)}>
467
+ <TouchableWithoutFeedback
468
+ accessibilityRole="button"
469
+ accessibilityLabel="Dismiss options"
470
+ onPress={() => setOpen(false)}
471
+ >
472
+ <View style={StyleSheet.absoluteFill} />
473
+ </TouchableWithoutFeedback>
474
+
475
+ <View
476
+ style={[
477
+ styles.selectOverlayList,
478
+ {
479
+ top: coords.y + coords.height + spacing.xs,
480
+ left: coords.x,
481
+ minWidth: coords.width,
482
+ borderColor: colors.border,
483
+ backgroundColor: colors.surface0,
484
+ },
485
+ ]}
486
+ >
487
+ {searchable ? (
488
+ <View style={{ padding: spacing.xs }}>
489
+ <ResolvedSearchInput
490
+ value={searchQuery}
491
+ onChangeText={handleSearchChange}
492
+ placeholder={searchPlaceholder ?? "Search..."}
493
+ placeholderTextColor={colors.foregroundMuted}
494
+ style={[
495
+ styles.searchInput,
496
+ {
497
+ color: colors.foreground,
498
+ backgroundColor: colors.surface0,
499
+ borderColor: colors.border,
500
+ borderRadius: 8,
501
+ fontSize: 13,
502
+ },
503
+ ]}
504
+ />
505
+ </View>
506
+ ) : null}
507
+ <RNScrollView
508
+ nestedScrollEnabled
509
+ keyboardShouldPersistTaps="handled"
510
+ style={{ maxHeight: OPTION_LIST_MAX_HEIGHT }}
511
+ >
512
+ {filteredOptions.length === 0 ? (
513
+ <View style={{ padding: spacing.sm }}>
514
+ <Text
515
+ style={{
516
+ color: colors.foregroundMuted,
517
+ fontSize: 13,
518
+ textAlign: "center",
519
+ }}
520
+ >
521
+ No matching options
522
+ </Text>
523
+ </View>
524
+ ) : (
525
+ filteredOptions.map((option) => {
526
+ const isSelected = option.value === value;
527
+ return (
528
+ <Pressable
529
+ key={option.value}
530
+ accessibilityRole="button"
531
+ accessibilityLabel={option.label}
532
+ accessibilityState={{ selected: isSelected }}
533
+ onPress={() => handleSelect(option.value)}
534
+ style={({ pressed }) => [
535
+ styles.selectOption,
536
+ {
537
+ paddingVertical,
538
+ paddingHorizontal,
539
+ backgroundColor: isSelected
540
+ ? colors.surface2
541
+ : pressed
542
+ ? alphaColor(colors.surface2, 0.5)
543
+ : "transparent",
544
+ },
545
+ ]}
546
+ >
547
+ <Text
548
+ numberOfLines={1}
549
+ style={[
550
+ styles.selectOptionText,
551
+ {
552
+ color: isSelected ? colors.foreground : colors.foregroundMuted,
553
+ fontSize: fontSize + 1,
554
+ lineHeight,
555
+ fontWeight: isSelected ? "600" : "400",
556
+ },
557
+ ]}
558
+ >
559
+ {option.label}
560
+ </Text>
561
+ </Pressable>
562
+ );
563
+ })
564
+ )}
565
+ </RNScrollView>
566
+ </View>
567
+ </Modal>
568
+ ) : null}
569
+ </View>
570
+ );
571
+ }
572
+
573
+ // --- Copy button -----------------------------------------------------------
574
+
575
+ export type HostCopyButtonSize = "sm" | "md";
576
+ export type HostCopyButtonVariant = "ghost" | "secondary";
577
+
578
+ export interface HostCopyButtonProps {
579
+ text?: string;
580
+ getText?: () => string | Promise<string>;
581
+ label?: string;
582
+ copiedLabel?: string;
583
+ icon?: string;
584
+ size?: HostCopyButtonSize;
585
+ variant?: HostCopyButtonVariant;
586
+ accessibilityLabel?: string;
587
+ toastMessage?: string;
588
+ feedbackDurationMs?: number;
589
+ disabled?: boolean;
590
+ style?: StyleProp<ViewStyle>;
591
+ textStyle?: StyleProp<TextStyle>;
592
+ }
593
+
594
+ /** Copy-to-clipboard button. Uses the host clipboard via `copyToClipboard`. */
595
+ export function HostCopyButton({
596
+ text,
597
+ getText,
598
+ label,
599
+ copiedLabel,
600
+ icon,
601
+ size = "sm",
602
+ variant = "ghost",
603
+ accessibilityLabel,
604
+ toastMessage,
605
+ feedbackDurationMs = 2000,
606
+ disabled = false,
607
+ style,
608
+ textStyle,
609
+ }: HostCopyButtonProps): React.ReactElement | null {
610
+ const { Icon, useToast } = getClientHost();
611
+ const { colors, alpha: alphaColor } = useHostTheme();
612
+ const toast = useToast();
613
+ const [copied, setCopied] = useState(false);
614
+ const [hovered, setHovered] = useState(false);
615
+ const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
616
+
617
+ React.useEffect(() => {
618
+ return () => {
619
+ if (timer.current) clearTimeout(timer.current);
620
+ };
621
+ }, []);
622
+
623
+ const hasSource = getText !== undefined || text !== undefined;
624
+ if (!hasSource) return null;
625
+
626
+ const idleIcon = icon ?? "Copy";
627
+ const idleLabel = label ?? "Copy";
628
+ const doneLabel = copiedLabel ?? "Copied!";
629
+ const feedbackIcon = copied ? "Check" : idleIcon;
630
+ const feedbackLabel = copied ? doneLabel : idleLabel;
631
+
632
+ const secondary = variant === "secondary";
633
+ const bg = secondary ? colors.surface1 : "transparent";
634
+ const border = secondary ? colors.border : "transparent";
635
+
636
+ const handleCopy = async () => {
637
+ if (disabled) return;
638
+ let value: string | undefined;
639
+ try {
640
+ value = getText ? await getText() : text;
641
+ } catch {
642
+ return;
643
+ }
644
+ if (value === undefined || value === null) return;
645
+ const ok = await copyToClipboard(value, { toast, toastMessage });
646
+ if (ok) {
647
+ setCopied(true);
648
+ if (timer.current) clearTimeout(timer.current);
649
+ timer.current = setTimeout(() => setCopied(false), feedbackDurationMs);
650
+ }
651
+ };
652
+
653
+ // RN-web hover hooks (same passthrough `InteractiveRow` uses). On desktop a
654
+ // copy affordance must respond to the pointer, not only to a press.
655
+ const hoverProps = {
656
+ onMouseEnter: () => {
657
+ if (!disabled) setHovered(true);
658
+ },
659
+ onMouseLeave: () => setHovered(false),
660
+ } as any;
661
+
662
+ return (
663
+ <Pressable
664
+ {...hoverProps}
665
+ onPress={handleCopy}
666
+ disabled={disabled}
667
+ accessibilityRole="button"
668
+ accessibilityLabel={accessibilityLabel || feedbackLabel}
669
+ hitSlop={4}
670
+ style={({ pressed }) => [
671
+ styles.copyButton,
672
+ { cursor: disabled ? "auto" : "pointer" },
673
+ {
674
+ paddingVertical: size === "sm" ? 3 : 5,
675
+ paddingHorizontal: size === "sm" ? 8 : 10,
676
+ backgroundColor:
677
+ !disabled && (pressed || hovered)
678
+ ? alphaColor(colors.surface2, secondary ? 1 : 0.7)
679
+ : bg,
680
+ borderColor: border,
681
+ borderWidth: border !== "transparent" ? 1 : 0,
682
+ borderRadius: size === "sm" ? 6 : 8,
683
+ opacity: disabled ? 0.45 : 1,
684
+ },
685
+ style,
686
+ ]}
687
+ >
688
+ <Icon
689
+ name={feedbackIcon}
690
+ size={size === "sm" ? 12 : 14}
691
+ color={copied ? colors.statusSuccess : colors.foregroundMuted}
692
+ />
693
+ {feedbackLabel !== "" ? (
694
+ <Text
695
+ style={[
696
+ styles.copyButtonText,
697
+ {
698
+ fontSize: size === "sm" ? 11 : 12,
699
+ color: copied ? colors.statusSuccess : colors.foregroundMuted,
700
+ },
701
+ textStyle,
702
+ ]}
703
+ >
704
+ {feedbackLabel}
705
+ </Text>
706
+ ) : null}
707
+ </Pressable>
708
+ );
709
+ }
710
+
711
+ // --- Text input ------------------------------------------------------------
712
+
713
+ export interface HostTextInputProps {
714
+ value: string;
715
+ onChangeText: (text: string) => void;
716
+ label?: string;
717
+ placeholder?: string;
718
+ helperText?: string;
719
+ errorText?: string;
720
+ secureTextEntry?: boolean;
721
+ keyboardType?: KeyboardTypeOptions;
722
+ autoCapitalize?: "none" | "sentences" | "words" | "characters";
723
+ autoCorrect?: boolean;
724
+ disabled?: boolean;
725
+ mono?: boolean;
726
+ multiline?: boolean;
727
+ numberOfLines?: number;
728
+ style?: StyleProp<ViewStyle>;
729
+ inputStyle?: StyleProp<TextStyle>;
730
+ onSubmitEditing?: () => void;
731
+ }
732
+
733
+ /**
734
+ * Text input. Prefers the host-injected `TextInput` (native focus integrated
735
+ * with modal keyboard positioning) and falls back to plain React Native.
736
+ */
737
+ export function HostTextInput({
738
+ value,
739
+ onChangeText,
740
+ label,
741
+ placeholder,
742
+ helperText,
743
+ errorText,
744
+ secureTextEntry = false,
745
+ keyboardType = "default",
746
+ autoCapitalize = "none",
747
+ autoCorrect = false,
748
+ disabled = false,
749
+ mono = false,
750
+ multiline = false,
751
+ numberOfLines = 1,
752
+ style,
753
+ inputStyle,
754
+ onSubmitEditing,
755
+ }: HostTextInputProps) {
756
+ const { colors } = useHostTheme();
757
+ const [isFocused, setIsFocused] = useState(false);
758
+ const ResolvedInput = (getOptionalClientHost()?.TextInput ??
759
+ RNTextInput) as ComponentType<RNTextInputProps & { ref?: Ref<RNTextInputInstance> }>;
760
+
761
+ const hasError = Boolean(errorText);
762
+ const borderColor = hasError ? colors.statusDanger : isFocused ? colors.accent : colors.border;
763
+ const minHeight = multiline ? 64 : 40;
764
+
765
+ return (
766
+ <View style={[styles.inputContainer, style]}>
767
+ {label ? (
768
+ <Text
769
+ style={[
770
+ styles.inputLabel,
771
+ { color: hasError ? colors.statusDanger : colors.foreground },
772
+ ]}
773
+ >
774
+ {label}
775
+ </Text>
776
+ ) : null}
777
+ <ResolvedInput
778
+ value={value}
779
+ onChangeText={onChangeText}
780
+ placeholder={placeholder}
781
+ placeholderTextColor={colors.foregroundMuted}
782
+ secureTextEntry={secureTextEntry}
783
+ keyboardType={keyboardType}
784
+ autoCapitalize={autoCapitalize}
785
+ autoCorrect={autoCorrect}
786
+ editable={!disabled}
787
+ multiline={multiline}
788
+ numberOfLines={numberOfLines}
789
+ onSubmitEditing={onSubmitEditing}
790
+ onFocus={() => setIsFocused(true)}
791
+ onBlur={() => setIsFocused(false)}
792
+ style={[
793
+ styles.input,
794
+ {
795
+ color: colors.foreground,
796
+ backgroundColor: colors.surface0,
797
+ borderColor,
798
+ minHeight,
799
+ fontFamily: mono ? "monospace" : undefined,
800
+ },
801
+ inputStyle,
802
+ ]}
803
+ />
804
+ {errorText ? (
805
+ <Text style={[styles.inputHelper, { color: colors.statusDanger }]}>{errorText}</Text>
806
+ ) : helperText ? (
807
+ <Text style={[styles.inputHelper, { color: colors.foregroundMuted }]}>{helperText}</Text>
808
+ ) : null}
809
+ </View>
810
+ );
811
+ }
812
+
813
+ // --- Search input ----------------------------------------------------------
814
+
815
+ export interface HostSearchInputProps {
816
+ value: string;
817
+ onChangeText: (text: string) => void;
818
+ placeholder?: string;
819
+ onClear?: () => void;
820
+ height?: number;
821
+ style?: StyleProp<ViewStyle>;
822
+ inputStyle?: StyleProp<TextStyle>;
823
+ testID?: string;
824
+ }
825
+
826
+ /** Search input with a leading icon and optional clear button. */
827
+ export function HostSearchInput({
828
+ value,
829
+ onChangeText,
830
+ placeholder = "Search...",
831
+ onClear,
832
+ height = 40,
833
+ style,
834
+ inputStyle,
835
+ testID,
836
+ }: HostSearchInputProps) {
837
+ const { Icon } = getClientHost();
838
+ const { colors } = useHostTheme();
839
+ const ResolvedInput = (getOptionalClientHost()?.TextInput ??
840
+ RNTextInput) as ComponentType<RNTextInputProps & { ref?: Ref<RNTextInputInstance> }>;
841
+
842
+ const handleClear = () => {
843
+ onChangeText("");
844
+ if (onClear) onClear();
845
+ };
846
+
847
+ return (
848
+ <View
849
+ style={[
850
+ styles.searchContainer,
851
+ {
852
+ backgroundColor: colors.surface1,
853
+ borderColor: colors.border,
854
+ height,
855
+ },
856
+ style,
857
+ ]}
858
+ >
859
+ <View style={styles.searchIconWrapper}>
860
+ <Icon name="Search" size={16} color={colors.foregroundMuted} />
861
+ </View>
862
+ <ResolvedInput
863
+ testID={testID}
864
+ value={value}
865
+ onChangeText={onChangeText}
866
+ placeholder={placeholder}
867
+ placeholderTextColor={colors.foregroundMuted}
868
+ style={[
869
+ styles.searchInput,
870
+ {
871
+ color: colors.foreground,
872
+ fontSize: height <= 26 ? 12 : 14,
873
+ },
874
+ inputStyle,
875
+ ]}
876
+ />
877
+ {value ? (
878
+ <Pressable
879
+ onPress={handleClear}
880
+ accessibilityRole="button"
881
+ accessibilityLabel="Clear search"
882
+ hitSlop={8}
883
+ style={styles.searchClearBtn}
884
+ >
885
+ <Icon name="X" size={14} color={colors.foregroundMuted} />
886
+ </Pressable>
887
+ ) : null}
888
+ </View>
889
+ );
890
+ }
891
+
892
+ const styles = StyleSheet.create({
893
+ buttonBase: {
894
+ flexDirection: "row",
895
+ alignItems: "center",
896
+ justifyContent: "center",
897
+ },
898
+ buttonContent: {
899
+ flexDirection: "row",
900
+ alignItems: "center",
901
+ gap: spacing.xs,
902
+ },
903
+ buttonText: {
904
+ fontWeight: "600",
905
+ },
906
+ inlineButton: {
907
+ flexDirection: "row",
908
+ alignItems: "center",
909
+ gap: 4,
910
+ },
911
+ inlineButtonText: {
912
+ fontSize: 13,
913
+ fontWeight: "600",
914
+ },
915
+ toggleContainer: {
916
+ flexDirection: "row",
917
+ alignItems: "center",
918
+ gap: spacing.sm,
919
+ },
920
+ toggleBare: {
921
+ justifyContent: "center",
922
+ },
923
+ toggleTextContainer: {
924
+ flex: 1,
925
+ gap: 1,
926
+ },
927
+ toggleLabel: {
928
+ fontSize: 14,
929
+ },
930
+ toggleDescription: {
931
+ fontSize: 11,
932
+ },
933
+ toggleTrack: {
934
+ justifyContent: "center",
935
+ },
936
+ toggleThumb: {
937
+ position: "absolute",
938
+ left: 0,
939
+ top: 0,
940
+ },
941
+ selectContainer: {
942
+ width: "100%",
943
+ },
944
+ selectTrigger: {
945
+ flexDirection: "row",
946
+ alignItems: "center",
947
+ justifyContent: "space-between",
948
+ gap: spacing.sm,
949
+ borderWidth: 1,
950
+ },
951
+ selectTriggerText: {
952
+ flexShrink: 1,
953
+ minWidth: 0,
954
+ },
955
+ selectOverlayList: {
956
+ position: "absolute",
957
+ borderWidth: 1,
958
+ overflow: "hidden",
959
+ elevation: 20,
960
+ shadowColor: "#000",
961
+ shadowOffset: { width: 0, height: 4 },
962
+ shadowOpacity: 0.25,
963
+ shadowRadius: 8,
964
+ },
965
+ selectOption: {},
966
+ selectOptionText: {
967
+ flexShrink: 1,
968
+ minWidth: 0,
969
+ },
970
+ copyButton: {
971
+ flexDirection: "row",
972
+ alignItems: "center",
973
+ gap: 4,
974
+ paddingVertical: 4,
975
+ paddingHorizontal: 6,
976
+ },
977
+ copyButtonText: {
978
+ fontSize: 12,
979
+ fontWeight: "600",
980
+ },
981
+ inputContainer: {
982
+ gap: 4,
983
+ width: "100%",
984
+ },
985
+ inputLabel: {
986
+ fontSize: 13,
987
+ fontWeight: "600",
988
+ },
989
+ input: {
990
+ borderWidth: 1,
991
+ borderRadius: 10,
992
+ paddingHorizontal: spacing.md,
993
+ paddingVertical: spacing.sm,
994
+ fontSize: 14,
995
+ },
996
+ inputHelper: {
997
+ fontSize: 11,
998
+ },
999
+ searchContainer: {
1000
+ flexDirection: "row",
1001
+ alignItems: "center",
1002
+ borderWidth: 1,
1003
+ borderRadius: 10,
1004
+ paddingHorizontal: spacing.sm,
1005
+ gap: spacing.xs,
1006
+ },
1007
+ searchIconWrapper: {
1008
+ flexShrink: 0,
1009
+ },
1010
+ searchInput: {
1011
+ flex: 1,
1012
+ paddingVertical: 0,
1013
+ },
1014
+ searchClearBtn: {
1015
+ flexShrink: 0,
1016
+ padding: 2,
1017
+ },
1018
+ });