yaver-feedback-react-native 0.9.7 → 0.9.9

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.
@@ -0,0 +1,175 @@
1
+ import Foundation
2
+ import React
3
+ import UIKit
4
+
5
+ /**
6
+ * Passive, capability-gated three-finger hold for Dogfood quick controls.
7
+ *
8
+ * The recognizer is attached to the app window with cancelsTouchesInView=false
9
+ * and simultaneous recognition enabled, so observing the gesture does not
10
+ * install a transparent React overlay or take ownership of ordinary app taps.
11
+ * VoiceOver and Switch Control disable it because accessibility owns complex
12
+ * multi-touch gestures; JS then renders the minimized draggable Y fallback.
13
+ */
14
+ @objc(YaverDogfoodGesture)
15
+ class YaverDogfoodGesture: RCTEventEmitter, UIGestureRecognizerDelegate {
16
+ private static let triggerEvent = "yaverDogfoodControlGesture"
17
+ private static let capabilityEvent = "yaverDogfoodControlCapability"
18
+
19
+ private weak var attachedWindow: UIWindow?
20
+ private var recognizer: UILongPressGestureRecognizer?
21
+ private var requestedEnabled = false
22
+ private var durationMs: Double = 900
23
+ private var hasJSListeners = false
24
+ private var observers: [NSObjectProtocol] = []
25
+
26
+ override static func requiresMainQueueSetup() -> Bool { true }
27
+
28
+ override func supportedEvents() -> [String]! {
29
+ [Self.triggerEvent, Self.capabilityEvent]
30
+ }
31
+
32
+ override func startObserving() {
33
+ hasJSListeners = true
34
+ }
35
+
36
+ override func stopObserving() {
37
+ hasJSListeners = false
38
+ }
39
+
40
+ override init() {
41
+ super.init()
42
+ let center = NotificationCenter.default
43
+ observers.append(center.addObserver(
44
+ forName: UIApplication.didBecomeActiveNotification,
45
+ object: nil,
46
+ queue: .main
47
+ ) { [weak self] _ in self?.reconcile() })
48
+ observers.append(center.addObserver(
49
+ forName: UIAccessibility.voiceOverStatusDidChangeNotification,
50
+ object: nil,
51
+ queue: .main
52
+ ) { [weak self] _ in self?.reconcileAndEmit() })
53
+ observers.append(center.addObserver(
54
+ forName: UIAccessibility.switchControlStatusDidChangeNotification,
55
+ object: nil,
56
+ queue: .main
57
+ ) { [weak self] _ in self?.reconcileAndEmit() })
58
+ }
59
+
60
+ deinit {
61
+ observers.forEach(NotificationCenter.default.removeObserver)
62
+ detach()
63
+ }
64
+
65
+ @objc func getCapability(_ resolve: @escaping RCTPromiseResolveBlock,
66
+ rejecter reject: @escaping RCTPromiseRejectBlock) {
67
+ DispatchQueue.main.async { resolve(self.status()) }
68
+ }
69
+
70
+ @objc func setEnabled(_ enabled: Bool,
71
+ durationMs: Double,
72
+ resolver resolve: @escaping RCTPromiseResolveBlock,
73
+ rejecter reject: @escaping RCTPromiseRejectBlock) {
74
+ DispatchQueue.main.async {
75
+ self.requestedEnabled = enabled
76
+ self.durationMs = min(max(durationMs, 650), 2000)
77
+ self.reconcile()
78
+ resolve(self.status())
79
+ }
80
+ }
81
+
82
+ private func accessibilityConflict() -> Bool {
83
+ UIAccessibility.isVoiceOverRunning || UIAccessibility.isSwitchControlRunning
84
+ }
85
+
86
+ private func simulatorInputConflict() -> Bool {
87
+ #if targetEnvironment(simulator)
88
+ // Simulator's Option-key multi-touch synthesizes two pointers only.
89
+ // Treat it as unsupported so the SDK exposes the tappable Y fallback.
90
+ return true
91
+ #else
92
+ return false
93
+ #endif
94
+ }
95
+
96
+ private func keyWindow() -> UIWindow? {
97
+ if #available(iOS 13.0, *) {
98
+ return UIApplication.shared.connectedScenes
99
+ .compactMap { $0 as? UIWindowScene }
100
+ .flatMap { $0.windows }
101
+ .first(where: { $0.isKeyWindow })
102
+ }
103
+ return UIApplication.shared.keyWindow
104
+ }
105
+
106
+ private func status() -> [String: Any] {
107
+ let accessibility = accessibilityConflict()
108
+ let simulator = simulatorInputConflict()
109
+ let windowAvailable = keyWindow() != nil
110
+ return [
111
+ "supported": !accessibility && !simulator,
112
+ "enabled": requestedEnabled && !accessibility && !simulator && recognizer != nil,
113
+ "reason": accessibility
114
+ ? "accessibility-touch-exploration"
115
+ : (simulator ? "simulator-three-finger-input-unavailable" : (windowAvailable ? "supported" : "window-unavailable")),
116
+ "platform": "ios",
117
+ ]
118
+ }
119
+
120
+ private func reconcileAndEmit() {
121
+ reconcile()
122
+ if hasJSListeners {
123
+ sendEvent(withName: Self.capabilityEvent, body: status())
124
+ }
125
+ }
126
+
127
+ private func reconcile() {
128
+ guard requestedEnabled, !accessibilityConflict(), !simulatorInputConflict(), let window = keyWindow() else {
129
+ detach()
130
+ return
131
+ }
132
+ if attachedWindow === window, let recognizer = recognizer {
133
+ recognizer.minimumPressDuration = durationMs / 1000
134
+ recognizer.isEnabled = true
135
+ return
136
+ }
137
+ detach()
138
+ let hold = UILongPressGestureRecognizer(target: self, action: #selector(handleHold(_:)))
139
+ hold.minimumPressDuration = durationMs / 1000
140
+ hold.numberOfTouchesRequired = 3
141
+ hold.cancelsTouchesInView = false
142
+ hold.delaysTouchesBegan = false
143
+ hold.delaysTouchesEnded = false
144
+ hold.delegate = self
145
+ window.addGestureRecognizer(hold)
146
+ attachedWindow = window
147
+ recognizer = hold
148
+ }
149
+
150
+ private func detach() {
151
+ if let recognizer = recognizer {
152
+ attachedWindow?.removeGestureRecognizer(recognizer)
153
+ }
154
+ recognizer = nil
155
+ attachedWindow = nil
156
+ }
157
+
158
+ @objc private func handleHold(_ sender: UILongPressGestureRecognizer) {
159
+ guard sender.state == .began, requestedEnabled, !accessibilityConflict() else { return }
160
+ UIImpactFeedbackGenerator(style: .medium).impactOccurred()
161
+ if hasJSListeners {
162
+ sendEvent(withName: Self.triggerEvent, body: ["source": "three-finger-hold"])
163
+ }
164
+ }
165
+
166
+ func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer,
167
+ shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
168
+ true
169
+ }
170
+
171
+ func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer,
172
+ shouldReceive touch: UITouch) -> Bool {
173
+ !accessibilityConflict()
174
+ }
175
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "yaver-feedback-react-native",
3
- "version": "0.9.7",
3
+ "version": "0.9.9",
4
4
  "description": "Visual feedback SDK for Yaver — bug reports, screen recording, voice vibe coding, and local-first developer workflows",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -1,5 +1,5 @@
1
1
  import React, { useCallback, useEffect, useRef, useState } from 'react';
2
- import { DeviceEventEmitter, Modal } from 'react-native';
2
+ import { Alert, DeviceEventEmitter, Modal } from 'react-native';
3
3
  import { YaverLoginScreen } from './LoginScreen';
4
4
  import { YaverMachinePickerScreen } from './MachinePickerScreen';
5
5
  import { YaverFeedback } from './YaverFeedback';
@@ -30,6 +30,23 @@ export const AuthOverlay: React.FC = () => {
30
30
  setPickerVisible(false);
31
31
  }, []);
32
32
 
33
+ const continueDogfood = useCallback(async () => {
34
+ try {
35
+ const state = await YaverFeedback.continueDogfoodOnboarding();
36
+ if (state.phase === 'denied' || state.phase === 'error') {
37
+ Alert.alert(
38
+ 'Dogfood unavailable',
39
+ state.error || 'This Yaver account or device is not enabled for this app.',
40
+ );
41
+ }
42
+ } catch (cause) {
43
+ Alert.alert(
44
+ 'Dogfood unavailable',
45
+ cause instanceof Error ? cause.message : 'Yaver could not continue Dogfood setup.',
46
+ );
47
+ }
48
+ }, []);
49
+
33
50
  useEffect(() => {
34
51
  let mounted = true;
35
52
  void getToken().then((cached) => {
@@ -58,7 +75,7 @@ export const AuthOverlay: React.FC = () => {
58
75
  await YaverFeedback.setAuthToken(newToken);
59
76
  if (YaverFeedback.getDogfoodOnboarding()) {
60
77
  closeAll();
61
- await YaverFeedback.continueDogfoodOnboarding();
78
+ await continueDogfood();
62
79
  } else {
63
80
  openPicker();
64
81
  }
@@ -68,7 +85,7 @@ export const AuthOverlay: React.FC = () => {
68
85
  await YaverFeedback.setPreferredDevice(device.deviceId);
69
86
  closeAll();
70
87
  if (YaverFeedback.getDogfoodOnboarding()) {
71
- await YaverFeedback.continueDogfoodOnboarding();
88
+ await continueDogfood();
72
89
  } else {
73
90
  DeviceEventEmitter.emit('yaverFeedback:startReport');
74
91
  }
@@ -0,0 +1,461 @@
1
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
+ import {
3
+ Animated,
4
+ DeviceEventEmitter,
5
+ Keyboard,
6
+ Modal,
7
+ PanResponder,
8
+ Pressable,
9
+ StyleSheet,
10
+ Text,
11
+ View,
12
+ useWindowDimensions,
13
+ } from 'react-native';
14
+ import { YaverFeedback, type DogfoodControlTriggerState } from './YaverFeedback';
15
+ import {
16
+ getDogfoodControlPosition,
17
+ setDogfoodControlPosition,
18
+ type DogfoodControlEdge,
19
+ type DogfoodControlPresentation,
20
+ } from './preferences';
21
+
22
+ const FALLBACK_SIZE = 36;
23
+ const DOCK_VISIBLE = 21;
24
+ const SAFE_TOP = 64;
25
+ const SAFE_BOTTOM = 92;
26
+ const EMPTY_STATE: DogfoodControlTriggerState = {
27
+ configured: false,
28
+ authorized: false,
29
+ gestureSupported: false,
30
+ gestureEnabled: false,
31
+ fallbackVisible: false,
32
+ presentation: 'auto',
33
+ onboardingSeen: false,
34
+ reason: 'not-configured',
35
+ };
36
+
37
+ function preferenceScope(state: DogfoodControlTriggerState): string | undefined {
38
+ return state.appId && state.installationId ? `${state.appId}:${state.installationId}` : undefined;
39
+ }
40
+
41
+ /**
42
+ * The standalone SDK's only persistent chrome. A newly authorized tester sees
43
+ * this edge-docked Y until first-run onboarding is completed in Convex. After
44
+ * that, capable devices default to a passive three-finger hold; unsupported
45
+ * devices keep the Y. Both entry points open exactly the same compact card.
46
+ */
47
+ export const DogfoodQuickControls: React.FC = () => {
48
+ const { width, height } = useWindowDimensions();
49
+ const orientation = width > height ? 'landscape' : 'portrait';
50
+ const defaultPosition = useMemo(() => ({
51
+ x: Math.max(width - DOCK_VISIBLE, 0),
52
+ y: Math.max(Math.round(height * 0.45), SAFE_TOP),
53
+ }), [height, width]);
54
+ const pan = useRef(new Animated.ValueXY(defaultPosition)).current;
55
+ const opacity = useRef(new Animated.Value(0.72)).current;
56
+ const lastPosition = useRef(defaultPosition);
57
+ const dragStart = useRef(defaultPosition);
58
+ const dragged = useRef(false);
59
+ const fadeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
60
+ const [dockEdge, setDockEdge] = useState<DogfoodControlEdge>('right');
61
+ const [keyboardVisible, setKeyboardVisible] = useState(false);
62
+ const [state, setState] = useState<DogfoodControlTriggerState>(EMPTY_STATE);
63
+ const [open, setOpen] = useState(false);
64
+ const [showControlSettings, setShowControlSettings] = useState(false);
65
+ const [busy, setBusy] = useState<'reload' | 'chat' | 'preference' | null>(null);
66
+ const [message, setMessage] = useState<string | null>(null);
67
+
68
+ const scheduleFade = useCallback(() => {
69
+ if (fadeTimer.current) clearTimeout(fadeTimer.current);
70
+ fadeTimer.current = setTimeout(() => {
71
+ Animated.timing(opacity, { toValue: 0.42, duration: 260, useNativeDriver: true }).start();
72
+ }, 1800);
73
+ }, [opacity]);
74
+
75
+ const wakeControl = useCallback(() => {
76
+ if (fadeTimer.current) clearTimeout(fadeTimer.current);
77
+ Animated.timing(opacity, { toValue: 1, duration: 100, useNativeDriver: true }).start();
78
+ }, [opacity]);
79
+
80
+ const refresh = useCallback(async () => {
81
+ try {
82
+ setState(await YaverFeedback.syncDogfoodControlGesture());
83
+ } catch {
84
+ setState(EMPTY_STATE);
85
+ }
86
+ }, []);
87
+
88
+ useEffect(() => {
89
+ void refresh();
90
+ const trigger = DeviceEventEmitter.addListener('yaverDogfoodControlGesture', () => {
91
+ setMessage(null);
92
+ setShowControlSettings(false);
93
+ setOpen(true);
94
+ });
95
+ const capability = DeviceEventEmitter.addListener('yaverDogfoodControlCapability', () => {
96
+ void refresh();
97
+ });
98
+ // Enrollment approval and Exit Dogfood both change SDK mode while this
99
+ // component remains mounted. Refresh React state immediately; native
100
+ // setEnabled() alone cannot make the fallback Y appear or disappear.
101
+ const mode = DeviceEventEmitter.addListener('yaverFeedback:dogfoodChanged', () => {
102
+ void refresh();
103
+ });
104
+ return () => {
105
+ trigger.remove();
106
+ capability.remove();
107
+ mode.remove();
108
+ };
109
+ }, [refresh]);
110
+
111
+ useEffect(() => {
112
+ const show = Keyboard.addListener('keyboardDidShow', () => setKeyboardVisible(true));
113
+ const hide = Keyboard.addListener('keyboardDidHide', () => setKeyboardVisible(false));
114
+ return () => { show.remove(); hide.remove(); };
115
+ }, []);
116
+
117
+ useEffect(() => {
118
+ let cancelled = false;
119
+ void (async () => {
120
+ const saved = await getDogfoodControlPosition(orientation, preferenceScope(state));
121
+ if (cancelled) return;
122
+ const edge = saved?.edge || 'right';
123
+ const minY = SAFE_TOP;
124
+ const maxY = Math.max(minY, height - FALLBACK_SIZE - SAFE_BOTTOM);
125
+ const y = saved
126
+ ? minY + (maxY - minY) * saved.yRatio
127
+ : Math.max(minY, Math.min(maxY, Math.round(height * 0.45)));
128
+ const next = { x: edge === 'left' ? -FALLBACK_SIZE + DOCK_VISIBLE : width - DOCK_VISIBLE, y };
129
+ setDockEdge(edge);
130
+ lastPosition.current = next;
131
+ pan.setValue(next);
132
+ scheduleFade();
133
+ })();
134
+ return () => { cancelled = true; };
135
+ }, [height, orientation, pan, scheduleFade, state.appId, state.installationId, width]);
136
+
137
+ useEffect(() => () => {
138
+ if (fadeTimer.current) clearTimeout(fadeTimer.current);
139
+ }, []);
140
+
141
+ const panResponder = useMemo(() => PanResponder.create({
142
+ onStartShouldSetPanResponder: () => true,
143
+ onMoveShouldSetPanResponder: (_, gesture) => Math.abs(gesture.dx) > 3 || Math.abs(gesture.dy) > 3,
144
+ onPanResponderGrant: () => {
145
+ wakeControl();
146
+ dragged.current = false;
147
+ dragStart.current = lastPosition.current;
148
+ pan.setOffset(lastPosition.current);
149
+ pan.setValue({ x: 0, y: 0 });
150
+ },
151
+ onPanResponderMove: (_, gesture) => {
152
+ if (Math.abs(gesture.dx) > 3 || Math.abs(gesture.dy) > 3) dragged.current = true;
153
+ Animated.event([null, { dx: pan.x, dy: pan.y }], { useNativeDriver: false })(_, gesture);
154
+ },
155
+ onPanResponderRelease: (_, gesture) => {
156
+ pan.flattenOffset();
157
+ const minY = SAFE_TOP;
158
+ const maxY = Math.max(minY, height - FALLBACK_SIZE - SAFE_BOTTOM);
159
+ const rawX = dragStart.current.x + gesture.dx;
160
+ const y = Math.max(minY, Math.min(maxY, dragStart.current.y + gesture.dy));
161
+ const edge: DogfoodControlEdge = rawX + FALLBACK_SIZE / 2 < width / 2 ? 'left' : 'right';
162
+ const x = edge === 'left' ? -FALLBACK_SIZE + DOCK_VISIBLE : width - DOCK_VISIBLE;
163
+ const next = { x, y };
164
+ const yRatio = maxY === minY ? 0.5 : (y - minY) / (maxY - minY);
165
+ setDockEdge(edge);
166
+ lastPosition.current = next;
167
+ Animated.spring(pan, { toValue: next, useNativeDriver: false, friction: 7 }).start(scheduleFade);
168
+ void setDogfoodControlPosition(orientation, { edge, yRatio }, preferenceScope(state));
169
+ },
170
+ onPanResponderTerminate: scheduleFade,
171
+ }), [height, orientation, pan, scheduleFade, state.appId, state.installationId, wakeControl, width]);
172
+
173
+ const choosePresentation = useCallback(async (presentation: DogfoodControlPresentation) => {
174
+ if (busy) return;
175
+ setBusy('preference');
176
+ setMessage(null);
177
+ try {
178
+ const next = await YaverFeedback.setDogfoodControlPresentation(presentation);
179
+ setState(next);
180
+ setShowControlSettings(false);
181
+ } catch (error) {
182
+ setMessage(error instanceof Error ? error.message : String(error));
183
+ } finally {
184
+ setBusy(null);
185
+ }
186
+ }, [busy]);
187
+
188
+ const fastReload = useCallback(async () => {
189
+ if (busy) return;
190
+ setBusy('reload');
191
+ setMessage(null);
192
+ try {
193
+ const ack = await YaverFeedback.requestDogfoodFastReload();
194
+ setMessage(ack || 'Fast Reload requested.');
195
+ setTimeout(() => setOpen(false), 650);
196
+ } catch (error) {
197
+ setMessage(error instanceof Error ? error.message : String(error));
198
+ } finally {
199
+ setBusy(null);
200
+ }
201
+ }, [busy]);
202
+
203
+ const openChat = useCallback(async () => {
204
+ if (busy) return;
205
+ setBusy('chat');
206
+ setMessage(null);
207
+ setOpen(false);
208
+ const result = await YaverFeedback.openDogfood();
209
+ if (result.phase === 'denied' || result.phase === 'error') {
210
+ setMessage(result.error || 'Dogfood access is not available on this installation.');
211
+ setOpen(true);
212
+ }
213
+ setBusy(null);
214
+ }, [busy]);
215
+
216
+ const openSessionSetup = useCallback(async () => {
217
+ if (busy) return;
218
+ setOpen(false);
219
+ setShowControlSettings(false);
220
+ const result = await YaverFeedback.openDogfood();
221
+ if (result.phase === 'denied' || result.phase === 'error') {
222
+ setMessage(result.error || 'Dogfood session settings are not available on this installation.');
223
+ setOpen(true);
224
+ setShowControlSettings(true);
225
+ }
226
+ }, [busy]);
227
+
228
+ if (!state.configured || !state.authorized) return null;
229
+ const onboarding = !state.onboardingSeen;
230
+
231
+ return (
232
+ <>
233
+ {state.fallbackVisible && !keyboardVisible && !open ? (
234
+ <Animated.View pointerEvents="box-none" style={[StyleSheet.absoluteFill, styles.layer]}>
235
+ <Animated.View
236
+ {...panResponder.panHandlers}
237
+ style={[
238
+ styles.fallbackPosition,
239
+ { opacity, transform: [{ translateX: pan.x }, { translateY: pan.y }] },
240
+ ]}
241
+ >
242
+ <Pressable
243
+ testID="yaver-dogfood-minimized-control"
244
+ accessibilityRole="button"
245
+ accessibilityLabel="Open Dogfood controls"
246
+ hitSlop={10}
247
+ onPressIn={wakeControl}
248
+ onPress={() => {
249
+ if (dragged.current) {
250
+ dragged.current = false;
251
+ return;
252
+ }
253
+ setMessage(null);
254
+ setShowControlSettings(false);
255
+ setOpen(true);
256
+ }}
257
+ style={({ pressed }) => [styles.fallback, pressed && styles.pressed]}
258
+ >
259
+ <Text style={[
260
+ styles.fallbackText,
261
+ dockEdge === 'right' ? styles.rightDockText : styles.leftDockText,
262
+ ]}>y</Text>
263
+ </Pressable>
264
+ </Animated.View>
265
+ </Animated.View>
266
+ ) : null}
267
+
268
+ <Modal visible={open} transparent animationType="fade" onRequestClose={() => setOpen(false)}>
269
+ <Pressable style={styles.backdrop} onPress={() => setOpen(false)}>
270
+ <Pressable style={styles.card} onPress={(event) => event.stopPropagation()}>
271
+ {onboarding ? (
272
+ <>
273
+ <Text style={styles.title}>Dogfood ready</Text>
274
+ <Text style={styles.explanation}>
275
+ {state.gestureSupported
276
+ ? 'Dogfood starts with the edge Y so you always know how to return. You can switch to a three-finger hold later from Controls.'
277
+ : 'This device cannot reliably use the three-finger hold, so the edge Y stays available.'}
278
+ </Text>
279
+ <ModeButton
280
+ title={busy === 'preference' ? 'Saving…' : 'Continue with Y'}
281
+ hint="Open Fast Reload and Chat from the edge"
282
+ disabled={busy !== null}
283
+ onPress={() => void choosePresentation('minimized-y')}
284
+ />
285
+ </>
286
+ ) : showControlSettings ? (
287
+ <>
288
+ <Text style={styles.title}>Dogfood settings</Text>
289
+ {state.gestureSupported ? (
290
+ <Text style={styles.supported}>Three-finger hold supported on this device</Text>
291
+ ) : null}
292
+ <View style={styles.stackedActions}>
293
+ {state.gestureSupported ? (
294
+ <>
295
+ <ModeButton
296
+ title="Three-finger hold"
297
+ hint="No persistent Y over the app"
298
+ selected={state.presentation === 'auto'}
299
+ disabled={busy !== null}
300
+ onPress={() => void choosePresentation('auto')}
301
+ />
302
+ <ModeButton
303
+ title="Always show Y"
304
+ hint="Keep the draggable edge control"
305
+ selected={state.presentation === 'minimized-y'}
306
+ disabled={busy !== null}
307
+ onPress={() => void choosePresentation('minimized-y')}
308
+ />
309
+ </>
310
+ ) : null}
311
+ <ModeButton
312
+ title="Session setup"
313
+ hint="Change machine, coding agent, model, or runtime lane"
314
+ disabled={busy !== null}
315
+ onPress={() => void openSessionSetup()}
316
+ />
317
+ </View>
318
+ </>
319
+ ) : (
320
+ <>
321
+ <View style={styles.titleRow}>
322
+ <Text style={styles.title}>Dogfood</Text>
323
+ <Pressable
324
+ accessibilityRole="button"
325
+ accessibilityLabel="Dogfood settings"
326
+ onPress={() => setShowControlSettings(true)}
327
+ style={styles.settingsButton}
328
+ >
329
+ <Text style={styles.settingsText}>Settings</Text>
330
+ </Pressable>
331
+ </View>
332
+ <View style={styles.actions}>
333
+ <Pressable
334
+ testID="yaver-dogfood-fast-reload"
335
+ accessibilityRole="button"
336
+ disabled={busy !== null}
337
+ onPress={() => void fastReload()}
338
+ style={({ pressed }) => [styles.action, pressed && styles.actionPressed]}
339
+ >
340
+ <Text style={styles.actionTitle}>{busy === 'reload' ? 'Reloading…' : 'Fast Reload'}</Text>
341
+ <Text style={styles.actionHint}>Refresh the selected render target</Text>
342
+ </Pressable>
343
+ <Pressable
344
+ testID="yaver-dogfood-chat"
345
+ accessibilityRole="button"
346
+ disabled={busy !== null}
347
+ onPress={() => void openChat()}
348
+ style={({ pressed }) => [styles.action, pressed && styles.actionPressed]}
349
+ >
350
+ <Text style={styles.actionTitle}>{busy === 'chat' ? 'Opening…' : 'Chat'}</Text>
351
+ <Text style={styles.actionHint}>Open the current Yaver vibing session</Text>
352
+ </Pressable>
353
+ </View>
354
+ </>
355
+ )}
356
+ {message ? <Text style={styles.message}>{message}</Text> : null}
357
+ </Pressable>
358
+ </Pressable>
359
+ </Modal>
360
+ </>
361
+ );
362
+ };
363
+
364
+ const ModeButton: React.FC<{
365
+ title: string;
366
+ hint: string;
367
+ selected?: boolean;
368
+ disabled?: boolean;
369
+ onPress: () => void;
370
+ }> = ({ title, hint, selected, disabled, onPress }) => (
371
+ <Pressable
372
+ accessibilityRole="button"
373
+ disabled={disabled}
374
+ onPress={onPress}
375
+ style={({ pressed }) => [styles.modeAction, selected && styles.modeSelected, pressed && styles.actionPressed]}
376
+ >
377
+ {typeof selected === 'boolean' ? <View style={[styles.radio, selected && styles.radioSelected]} /> : null}
378
+ <View style={styles.modeCopy}>
379
+ <Text style={styles.actionTitle}>{title}</Text>
380
+ <Text style={styles.actionHint}>{hint}</Text>
381
+ </View>
382
+ </Pressable>
383
+ );
384
+
385
+ const styles = StyleSheet.create({
386
+ layer: { zIndex: 9997 },
387
+ fallbackPosition: { position: 'absolute', left: 0, top: 0 },
388
+ fallback: {
389
+ width: FALLBACK_SIZE,
390
+ height: FALLBACK_SIZE,
391
+ borderRadius: FALLBACK_SIZE / 2,
392
+ backgroundColor: '#f97316',
393
+ borderWidth: 1.5,
394
+ borderColor: 'rgba(255,255,255,0.92)',
395
+ shadowColor: '#000',
396
+ shadowOpacity: 0.28,
397
+ shadowRadius: 5,
398
+ shadowOffset: { width: 0, height: 2 },
399
+ elevation: 6,
400
+ overflow: 'hidden',
401
+ },
402
+ fallbackText: { position: 'absolute', top: 7, color: '#111827', fontSize: 17, lineHeight: 20, fontWeight: '800' },
403
+ rightDockText: { left: 6 },
404
+ leftDockText: { right: 6 },
405
+ pressed: { opacity: 0.78 },
406
+ backdrop: {
407
+ flex: 1,
408
+ justifyContent: 'center',
409
+ alignItems: 'center',
410
+ padding: 24,
411
+ backgroundColor: 'rgba(2,6,23,0.28)',
412
+ },
413
+ card: {
414
+ width: '100%',
415
+ maxWidth: 360,
416
+ borderRadius: 20,
417
+ padding: 16,
418
+ backgroundColor: '#111827',
419
+ shadowColor: '#000',
420
+ shadowOpacity: 0.3,
421
+ shadowRadius: 16,
422
+ shadowOffset: { width: 0, height: 8 },
423
+ elevation: 12,
424
+ },
425
+ titleRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 },
426
+ title: { color: '#f9fafb', fontSize: 16, fontWeight: '700', marginBottom: 12 },
427
+ explanation: { color: '#cbd5e1', fontSize: 13, lineHeight: 19, marginBottom: 14 },
428
+ supported: { color: '#9ca3af', fontSize: 12, lineHeight: 17, marginBottom: 14 },
429
+ settingsButton: { paddingVertical: 5, paddingHorizontal: 8, marginTop: -6 },
430
+ settingsText: { color: '#fdba74', fontSize: 12, fontWeight: '700' },
431
+ actions: { flexDirection: 'row', gap: 10 },
432
+ stackedActions: { gap: 9 },
433
+ action: {
434
+ flex: 1,
435
+ minHeight: 92,
436
+ borderRadius: 14,
437
+ padding: 12,
438
+ justifyContent: 'space-between',
439
+ backgroundColor: '#1f2937',
440
+ borderWidth: StyleSheet.hairlineWidth,
441
+ borderColor: '#4b5563',
442
+ },
443
+ modeAction: {
444
+ minHeight: 62,
445
+ borderRadius: 14,
446
+ padding: 12,
447
+ flexDirection: 'row',
448
+ alignItems: 'center',
449
+ backgroundColor: '#1f2937',
450
+ borderWidth: StyleSheet.hairlineWidth,
451
+ borderColor: '#4b5563',
452
+ },
453
+ modeSelected: { borderColor: '#f97316', backgroundColor: '#292524' },
454
+ radio: { width: 15, height: 15, borderRadius: 8, borderWidth: 1.5, borderColor: '#64748b', marginRight: 11 },
455
+ radioSelected: { borderWidth: 4, borderColor: '#f97316', backgroundColor: '#111827' },
456
+ modeCopy: { flex: 1, gap: 3 },
457
+ actionPressed: { backgroundColor: '#374151' },
458
+ actionTitle: { color: '#f9fafb', fontSize: 15, fontWeight: '700' },
459
+ actionHint: { color: '#9ca3af', fontSize: 11, lineHeight: 15 },
460
+ message: { color: '#fdba74', fontSize: 12, lineHeight: 17, marginTop: 12 },
461
+ });