@xpufx/paseo-top 0.4.3 → 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 (130) 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 +124 -7
  6. package/client/pill.tsx +724 -669
  7. package/client/resources-query.ts +1 -1
  8. package/client/settings-events.ts +33 -0
  9. package/client/settings-ui.tsx +86 -9
  10. package/client/surface.tsx +310 -274
  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} +21 -61
  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} +118 -338
  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/lifecycle/surface.tsx +140 -0
  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} +55 -47
  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 -227
  48. package/package.json +14 -2
  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 +4 -0
  59. package/server/vendor/paseo-plugin-helper/logger.ts +5 -16
  60. package/server/vendor/paseo-plugin-helper/mcp-injection.ts +20 -2
  61. package/server/vendor/paseo-plugin-helper/plugins.ts +184 -19
  62. package/server/vendor/paseo-plugin-helper/rpc-guard.ts +2 -2
  63. package/server/vendor/paseo-plugin-helper/storage-guard.ts +294 -0
  64. package/server/vendor/paseo-plugin-helper/storage.ts +36 -2
  65. package/server/vendor/paseo-plugin-helper/telemetry.ts +486 -0
  66. package/server/vendor/paseo-plugin-helper/tickets.ts +1232 -0
  67. package/server/vendor/permission-audit/index.ts +1 -0
  68. package/server/vendor/permission-audit/server.ts +839 -0
  69. package/shared/helper-version.ts +20 -0
  70. package/shared/multi-host.ts +156 -155
  71. package/shared/resources.ts +146 -1
  72. package/shared/vendor/paseo-plugin-helper/async.ts +14 -7
  73. package/shared/vendor/paseo-plugin-helper/features.ts +244 -0
  74. package/shared/vendor/paseo-plugin-helper/forge.ts +27 -0
  75. package/shared/vendor/paseo-plugin-helper/index.ts +2 -0
  76. package/shared/vendor/paseo-plugin-helper/tickets.ts +1104 -0
  77. package/shared/vendor/permission-audit/filter.ts +61 -0
  78. package/shared/vendor/permission-audit/index.ts +2 -0
  79. package/shared/vendor/permission-audit/shared.ts +119 -0
  80. package/shared/version.ts +1 -1
  81. package/client/vendor/paseo-plugin-helper/components/AboutSection.tsx +0 -493
  82. package/client/vendor/paseo-plugin-helper/components/AttentionBeacon.tsx +0 -250
  83. package/client/vendor/paseo-plugin-helper/components/Badge.tsx +0 -156
  84. package/client/vendor/paseo-plugin-helper/components/Button.tsx +0 -184
  85. package/client/vendor/paseo-plugin-helper/components/Card.tsx +0 -225
  86. package/client/vendor/paseo-plugin-helper/components/CodeBlock.tsx +0 -196
  87. package/client/vendor/paseo-plugin-helper/components/Collapsible.tsx +0 -277
  88. package/client/vendor/paseo-plugin-helper/components/CommandBox.tsx +0 -172
  89. package/client/vendor/paseo-plugin-helper/components/CopyButton.tsx +0 -180
  90. package/client/vendor/paseo-plugin-helper/components/EmptyState.tsx +0 -97
  91. package/client/vendor/paseo-plugin-helper/components/HighlightedText.tsx +0 -70
  92. package/client/vendor/paseo-plugin-helper/components/InlineButton.tsx +0 -75
  93. package/client/vendor/paseo-plugin-helper/components/KeyValue.tsx +0 -446
  94. package/client/vendor/paseo-plugin-helper/components/MetricGauge.tsx +0 -247
  95. package/client/vendor/paseo-plugin-helper/components/ProgressBar.tsx +0 -117
  96. package/client/vendor/paseo-plugin-helper/components/Responsive.tsx +0 -53
  97. package/client/vendor/paseo-plugin-helper/components/SearchInput.tsx +0 -118
  98. package/client/vendor/paseo-plugin-helper/components/SectionHeader.tsx +0 -80
  99. package/client/vendor/paseo-plugin-helper/components/Select.tsx +0 -215
  100. package/client/vendor/paseo-plugin-helper/components/StatusDot.tsx +0 -80
  101. package/client/vendor/paseo-plugin-helper/components/Tabs.tsx +0 -319
  102. package/client/vendor/paseo-plugin-helper/components/TextInput.tsx +0 -150
  103. package/client/vendor/paseo-plugin-helper/components/Toggle.tsx +0 -163
  104. package/client/vendor/paseo-plugin-helper/components/TruncatedText.tsx +0 -157
  105. package/client/vendor/paseo-plugin-helper/components/index.ts +0 -25
  106. package/client/vendor/paseo-plugin-helper/custom-pills.tsx +0 -224
  107. package/client/vendor/paseo-plugin-helper/forge-icon.tsx +0 -79
  108. package/client/vendor/paseo-plugin-helper/icon.tsx +0 -39
  109. package/client/vendor/paseo-plugin-helper/index.ts +0 -29
  110. package/client/vendor/paseo-plugin-helper/layout/ActionBar.tsx +0 -49
  111. package/client/vendor/paseo-plugin-helper/layout/FormRow.tsx +0 -103
  112. package/client/vendor/paseo-plugin-helper/layout/Grid.tsx +0 -65
  113. package/client/vendor/paseo-plugin-helper/layout/ModalBody.tsx +0 -378
  114. package/client/vendor/paseo-plugin-helper/layout/ModalContent.tsx +0 -51
  115. package/client/vendor/paseo-plugin-helper/layout/Row.tsx +0 -39
  116. package/client/vendor/paseo-plugin-helper/layout/Stack.tsx +0 -39
  117. package/client/vendor/paseo-plugin-helper/layout/index.ts +0 -7
  118. package/client/vendor/paseo-plugin-helper/styles/index.ts +0 -1
  119. package/client/vendor/paseo-plugin-helper/styles/recipes.ts +0 -550
  120. package/client/vendor/paseo-plugin-helper/surface.tsx +0 -80
  121. package/client/vendor/paseo-plugin-helper/theme/flair.ts +0 -76
  122. package/client/vendor/paseo-plugin-helper/theme/host-variables.ts +0 -121
  123. package/client/vendor/paseo-plugin-helper/theme/index.ts +0 -7
  124. package/client/vendor/paseo-plugin-helper/theme/provider.tsx +0 -178
  125. package/client/vendor/paseo-plugin-helper/theme/responsive.ts +0 -213
  126. package/client/vendor/paseo-plugin-helper/theme/tokens.ts +0 -200
  127. package/client/vendor/paseo-plugin-helper/theme/useResponsive.ts +0 -57
  128. /package/client/vendor/paseo-plugin-helper/{command-center.ts → core/command-center.ts} +0 -0
  129. /package/client/vendor/paseo-plugin-helper/{snapshot.ts → core/snapshot.ts} +0 -0
  130. /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
+ });