yaver-feedback-react-native 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +690 -0
- package/app.plugin.js +70 -0
- package/package.json +39 -0
- package/src/BlackBox.ts +317 -0
- package/src/ConnectionScreen.tsx +400 -0
- package/src/Discovery.ts +223 -0
- package/src/FeedbackModal.tsx +678 -0
- package/src/FixReport.tsx +313 -0
- package/src/FloatingButton.tsx +860 -0
- package/src/P2PClient.ts +303 -0
- package/src/ShakeDetector.ts +57 -0
- package/src/YaverFeedback.ts +345 -0
- package/src/__tests__/Discovery.test.ts +187 -0
- package/src/__tests__/P2PClient.test.ts +218 -0
- package/src/__tests__/SDKToken.test.ts +268 -0
- package/src/__tests__/YaverFeedback.test.ts +189 -0
- package/src/__tests__/types.test.ts +247 -0
- package/src/capture.ts +84 -0
- package/src/expo.ts +62 -0
- package/src/index.ts +54 -0
- package/src/types.ts +251 -0
- package/src/upload.ts +74 -0
|
@@ -0,0 +1,860 @@
|
|
|
1
|
+
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
ActivityIndicator,
|
|
4
|
+
Animated,
|
|
5
|
+
Dimensions,
|
|
6
|
+
Keyboard,
|
|
7
|
+
PanResponder,
|
|
8
|
+
ScrollView,
|
|
9
|
+
StyleSheet,
|
|
10
|
+
Text,
|
|
11
|
+
TextInput,
|
|
12
|
+
TouchableOpacity,
|
|
13
|
+
View,
|
|
14
|
+
} from 'react-native';
|
|
15
|
+
import { YaverFeedback } from './YaverFeedback';
|
|
16
|
+
import { FixReport } from './FixReport';
|
|
17
|
+
import type { TestSession } from './types';
|
|
18
|
+
import { BlackBox } from './BlackBox';
|
|
19
|
+
|
|
20
|
+
export interface FloatingButtonProps {
|
|
21
|
+
/** Called when user taps the button (opens inline console by default). */
|
|
22
|
+
onPress?: () => void;
|
|
23
|
+
/** Initial position. Default: top-left corner, below status bar. */
|
|
24
|
+
initialPosition?: { x: number; y: number };
|
|
25
|
+
/** Button size in pixels. Default: 40. */
|
|
26
|
+
size?: number;
|
|
27
|
+
/**
|
|
28
|
+
* Button background color. Default: "#6366f1" (indigo).
|
|
29
|
+
* Use a distinctive color so the debug button is never confused
|
|
30
|
+
* with your app's UI. Suggested: pink, purple, lime.
|
|
31
|
+
*/
|
|
32
|
+
color?: string;
|
|
33
|
+
/** Show connection status dot on the button. Default: true. */
|
|
34
|
+
showStatusDot?: boolean;
|
|
35
|
+
/**
|
|
36
|
+
* Style preset:
|
|
37
|
+
* - "terminal" (default) — dark terminal look with >_ icon, monospace font, pink accents
|
|
38
|
+
* - "minimal" — small circle, single-letter icon, clean panel
|
|
39
|
+
*/
|
|
40
|
+
style?: 'terminal' | 'minimal';
|
|
41
|
+
/** Custom icon text. Default: "y". */
|
|
42
|
+
icon?: string;
|
|
43
|
+
/** Agent base URL (auto-detected from YaverFeedback config if omitted). */
|
|
44
|
+
agentUrl?: string;
|
|
45
|
+
/** Auth token (auto-detected from YaverFeedback config if omitted). */
|
|
46
|
+
authToken?: string;
|
|
47
|
+
/**
|
|
48
|
+
* Health check interval in ms. The button polls the agent's /health
|
|
49
|
+
* endpoint to show connection status. Default: 5000. Set to 0 to disable.
|
|
50
|
+
*/
|
|
51
|
+
healthCheckInterval?: number;
|
|
52
|
+
/**
|
|
53
|
+
* Background color of the debug console panel.
|
|
54
|
+
* Default: "#2d2d2d" (dark gray). Override to match your app's theme.
|
|
55
|
+
*/
|
|
56
|
+
panelBackgroundColor?: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const DEFAULT_SIZE = 40;
|
|
60
|
+
const DEFAULT_COLOR = '#6366f1';
|
|
61
|
+
const DEFAULT_PANEL_BG = '#2d2d2d';
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Draggable debug console button for the Yaver Feedback SDK.
|
|
65
|
+
*
|
|
66
|
+
* Drop this into any React Native app for an instant debug console
|
|
67
|
+
* with message back-and-forth, hot reload, and build+deploy:
|
|
68
|
+
*
|
|
69
|
+
* ```tsx
|
|
70
|
+
* import { FloatingButton } from '@yaver/feedback-react-native';
|
|
71
|
+
*
|
|
72
|
+
* function App() {
|
|
73
|
+
* return (
|
|
74
|
+
* <>
|
|
75
|
+
* <YourApp />
|
|
76
|
+
* <FloatingButton />
|
|
77
|
+
* </>
|
|
78
|
+
* );
|
|
79
|
+
* }
|
|
80
|
+
* ```
|
|
81
|
+
*
|
|
82
|
+
* Features:
|
|
83
|
+
* - **Tap** → expand terminal-style console panel
|
|
84
|
+
* - **Drag** → reposition anywhere
|
|
85
|
+
* - **Type** → send tasks to the AI agent, see responses
|
|
86
|
+
* - **Hot Reload** → trigger hot reload
|
|
87
|
+
* - **Build iOS** → build + auto-submit to TestFlight
|
|
88
|
+
* - **Build Android** → build + auto-submit to Play Store
|
|
89
|
+
* - **"quit"** → disable the SDK
|
|
90
|
+
*/
|
|
91
|
+
export const FloatingButton: React.FC<FloatingButtonProps> = ({
|
|
92
|
+
onPress,
|
|
93
|
+
initialPosition,
|
|
94
|
+
size = DEFAULT_SIZE,
|
|
95
|
+
color = DEFAULT_COLOR,
|
|
96
|
+
showStatusDot = true,
|
|
97
|
+
style: stylePreset = 'terminal',
|
|
98
|
+
icon,
|
|
99
|
+
agentUrl: agentUrlProp,
|
|
100
|
+
authToken: authTokenProp,
|
|
101
|
+
healthCheckInterval = 5000,
|
|
102
|
+
panelBackgroundColor,
|
|
103
|
+
}) => {
|
|
104
|
+
const { width: screenWidth } = Dimensions.get('window');
|
|
105
|
+
const defaultX = initialPosition?.x ?? 10;
|
|
106
|
+
const defaultY = initialPosition?.y ?? 90;
|
|
107
|
+
|
|
108
|
+
const pan = useRef(new Animated.ValueXY({ x: defaultX, y: defaultY })).current;
|
|
109
|
+
const isDragging = useRef(false);
|
|
110
|
+
const [chatOpen, setChatOpen] = useState(false);
|
|
111
|
+
const [fullSize, setFullSize] = useState(false);
|
|
112
|
+
const [message, setMessage] = useState('');
|
|
113
|
+
const [sending, setSending] = useState(false);
|
|
114
|
+
const [output, setOutput] = useState<string[]>([]);
|
|
115
|
+
const [reloading, setReloading] = useState(false);
|
|
116
|
+
const [isConnected, setIsConnected] = useState(false);
|
|
117
|
+
const [testSession, setTestSession] = useState<TestSession | null>(null);
|
|
118
|
+
const [showFixReport, setShowFixReport] = useState(false);
|
|
119
|
+
const testPollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
120
|
+
const outputScrollRef = useRef<ScrollView>(null);
|
|
121
|
+
|
|
122
|
+
// Resolve agent URL and token
|
|
123
|
+
const config = YaverFeedback.getConfig();
|
|
124
|
+
const agentUrl = agentUrlProp || config?.agentUrl;
|
|
125
|
+
const authToken = authTokenProp || config?.authToken;
|
|
126
|
+
const panelBg = panelBackgroundColor || config?.panelBackgroundColor || DEFAULT_PANEL_BG;
|
|
127
|
+
|
|
128
|
+
const addOutput = useCallback((line: string) => {
|
|
129
|
+
setOutput((prev) => [...prev.slice(-20), line]);
|
|
130
|
+
}, []);
|
|
131
|
+
|
|
132
|
+
// Connection health polling
|
|
133
|
+
useEffect(() => {
|
|
134
|
+
if (!healthCheckInterval || !agentUrl) return;
|
|
135
|
+
|
|
136
|
+
const check = async () => {
|
|
137
|
+
try {
|
|
138
|
+
const client = YaverFeedback.getP2PClient();
|
|
139
|
+
if (client) {
|
|
140
|
+
setIsConnected(await client.health());
|
|
141
|
+
} else if (agentUrl) {
|
|
142
|
+
const controller = new AbortController();
|
|
143
|
+
const timeout = setTimeout(() => controller.abort(), 3000);
|
|
144
|
+
const resp = await fetch(`${agentUrl.replace(/\/$/, '')}/health`, {
|
|
145
|
+
signal: controller.signal,
|
|
146
|
+
});
|
|
147
|
+
clearTimeout(timeout);
|
|
148
|
+
setIsConnected(resp.ok);
|
|
149
|
+
}
|
|
150
|
+
} catch {
|
|
151
|
+
setIsConnected(false);
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
check();
|
|
156
|
+
const interval = setInterval(check, healthCheckInterval);
|
|
157
|
+
return () => clearInterval(interval);
|
|
158
|
+
}, [agentUrl, healthCheckInterval]);
|
|
159
|
+
|
|
160
|
+
const panResponder = useRef(
|
|
161
|
+
PanResponder.create({
|
|
162
|
+
onStartShouldSetPanResponder: () => true,
|
|
163
|
+
onMoveShouldSetPanResponder: (_, gs) =>
|
|
164
|
+
Math.abs(gs.dx) > 4 || Math.abs(gs.dy) > 4,
|
|
165
|
+
onPanResponderGrant: () => {
|
|
166
|
+
pan.extractOffset();
|
|
167
|
+
isDragging.current = false;
|
|
168
|
+
},
|
|
169
|
+
onPanResponderMove: (_, gs) => {
|
|
170
|
+
if (Math.abs(gs.dx) > 4 || Math.abs(gs.dy) > 4) isDragging.current = true;
|
|
171
|
+
Animated.event([null, { dx: pan.x, dy: pan.y }], { useNativeDriver: false })(_, gs);
|
|
172
|
+
},
|
|
173
|
+
onPanResponderRelease: () => pan.flattenOffset(),
|
|
174
|
+
}),
|
|
175
|
+
).current;
|
|
176
|
+
|
|
177
|
+
const handleTap = useCallback(() => {
|
|
178
|
+
if (isDragging.current) return;
|
|
179
|
+
if (onPress) {
|
|
180
|
+
onPress();
|
|
181
|
+
} else {
|
|
182
|
+
setChatOpen((prev) => !prev);
|
|
183
|
+
}
|
|
184
|
+
}, [onPress]);
|
|
185
|
+
|
|
186
|
+
// Send message → create task → poll for response
|
|
187
|
+
const handleSend = useCallback(async () => {
|
|
188
|
+
if (!message.trim() || !agentUrl || !authToken) return;
|
|
189
|
+
const msg = message.trim();
|
|
190
|
+
setSending(true);
|
|
191
|
+
setMessage('');
|
|
192
|
+
Keyboard.dismiss();
|
|
193
|
+
addOutput(`> ${msg}`);
|
|
194
|
+
|
|
195
|
+
try {
|
|
196
|
+
const url = agentUrl.replace(/\/$/, '');
|
|
197
|
+
const resp = await fetch(`${url}/tasks`, {
|
|
198
|
+
method: 'POST',
|
|
199
|
+
headers: { Authorization: `Bearer ${authToken}`, 'Content-Type': 'application/json' },
|
|
200
|
+
body: JSON.stringify({ title: msg, source: 'feedback-console' }),
|
|
201
|
+
});
|
|
202
|
+
if (!resp.ok) {
|
|
203
|
+
addOutput(`err: ${resp.status}`);
|
|
204
|
+
setSending(false);
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
const data = await resp.json();
|
|
208
|
+
const taskId = data.taskId ?? data.id ?? data.task?.id;
|
|
209
|
+
if (!taskId) {
|
|
210
|
+
addOutput('task created (no id)');
|
|
211
|
+
setSending(false);
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
addOutput(`task ${taskId} started...`);
|
|
215
|
+
BlackBox.log(`Console task: ${msg}`, 'FloatingButton');
|
|
216
|
+
|
|
217
|
+
// Poll task output for up to 60s
|
|
218
|
+
let attempts = 0;
|
|
219
|
+
const poll = setInterval(async () => {
|
|
220
|
+
attempts++;
|
|
221
|
+
try {
|
|
222
|
+
const sr = await fetch(`${url}/tasks/${taskId}`, {
|
|
223
|
+
headers: { Authorization: `Bearer ${authToken}` },
|
|
224
|
+
});
|
|
225
|
+
if (!sr.ok) { clearInterval(poll); setSending(false); return; }
|
|
226
|
+
const task = await sr.json();
|
|
227
|
+
const t = task.task ?? task;
|
|
228
|
+
|
|
229
|
+
if (t.status === 'completed' || t.status === 'failed' || t.status === 'stopped') {
|
|
230
|
+
const out = t.output ?? t.rawOutput ?? '';
|
|
231
|
+
if (out) {
|
|
232
|
+
const lines = out.split('\n').filter((l: string) => l.trim());
|
|
233
|
+
for (const l of lines.slice(-5)) addOutput(l.slice(0, 80));
|
|
234
|
+
}
|
|
235
|
+
addOutput(t.status === 'completed' ? 'done.' : `${t.status}.`);
|
|
236
|
+
clearInterval(poll);
|
|
237
|
+
setSending(false);
|
|
238
|
+
} else if (attempts >= 30) {
|
|
239
|
+
addOutput('running in background...');
|
|
240
|
+
clearInterval(poll);
|
|
241
|
+
setSending(false);
|
|
242
|
+
}
|
|
243
|
+
} catch { clearInterval(poll); setSending(false); }
|
|
244
|
+
}, 2000);
|
|
245
|
+
} catch (e) {
|
|
246
|
+
addOutput(`fail: ${String(e).slice(0, 50)}`);
|
|
247
|
+
setSending(false);
|
|
248
|
+
}
|
|
249
|
+
}, [message, agentUrl, authToken, addOutput]);
|
|
250
|
+
|
|
251
|
+
// Generic action: send task to agent, poll for output
|
|
252
|
+
const runAgentAction = useCallback(async (label: string, prompt: string) => {
|
|
253
|
+
if (!agentUrl || !authToken) return;
|
|
254
|
+
addOutput(`> ${label}`);
|
|
255
|
+
setSending(true);
|
|
256
|
+
try {
|
|
257
|
+
const url = agentUrl.replace(/\/$/, '');
|
|
258
|
+
const resp = await fetch(`${url}/tasks`, {
|
|
259
|
+
method: 'POST',
|
|
260
|
+
headers: { Authorization: `Bearer ${authToken}`, 'Content-Type': 'application/json' },
|
|
261
|
+
body: JSON.stringify({
|
|
262
|
+
title: prompt,
|
|
263
|
+
source: 'feedback-sdk',
|
|
264
|
+
description: `[Feedback SDK] User triggered "${label}" from the debug console.`,
|
|
265
|
+
}),
|
|
266
|
+
});
|
|
267
|
+
if (!resp.ok) { addOutput(`err: ${resp.status}`); setSending(false); return; }
|
|
268
|
+
const data = await resp.json();
|
|
269
|
+
const taskId = data.taskId ?? data.id ?? data.task?.id;
|
|
270
|
+
if (!taskId) { addOutput('started (no id)'); setSending(false); return; }
|
|
271
|
+
addOutput(`${label}: task ${taskId}...`);
|
|
272
|
+
BlackBox.log(`Action: ${label}`, 'FloatingButton');
|
|
273
|
+
|
|
274
|
+
// Poll output
|
|
275
|
+
let attempts = 0;
|
|
276
|
+
const poll = setInterval(async () => {
|
|
277
|
+
attempts++;
|
|
278
|
+
try {
|
|
279
|
+
const sr = await fetch(`${url}/tasks/${taskId}`, {
|
|
280
|
+
headers: { Authorization: `Bearer ${authToken}` },
|
|
281
|
+
});
|
|
282
|
+
if (!sr.ok) { clearInterval(poll); setSending(false); return; }
|
|
283
|
+
const task = await sr.json();
|
|
284
|
+
const t = task.task ?? task;
|
|
285
|
+
if (t.status === 'completed' || t.status === 'failed' || t.status === 'stopped') {
|
|
286
|
+
const out = t.output ?? t.rawOutput ?? '';
|
|
287
|
+
if (out) {
|
|
288
|
+
for (const l of out.split('\n').filter((l: string) => l.trim()).slice(-5)) {
|
|
289
|
+
addOutput(l.slice(0, 80));
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
addOutput(t.status === 'completed' ? 'done.' : `${t.status}.`);
|
|
293
|
+
clearInterval(poll); setSending(false);
|
|
294
|
+
} else if (attempts >= 60) {
|
|
295
|
+
addOutput('running in background...');
|
|
296
|
+
clearInterval(poll); setSending(false);
|
|
297
|
+
}
|
|
298
|
+
} catch { clearInterval(poll); setSending(false); }
|
|
299
|
+
}, 2000);
|
|
300
|
+
} catch (e) {
|
|
301
|
+
addOutput(`fail: ${String(e).slice(0, 50)}`);
|
|
302
|
+
setSending(false);
|
|
303
|
+
}
|
|
304
|
+
}, [agentUrl, authToken, addOutput]);
|
|
305
|
+
|
|
306
|
+
const handleReload = useCallback(() => {
|
|
307
|
+
// @ts-ignore — __DEV__ is defined by React Native bundler
|
|
308
|
+
const isDev = typeof __DEV__ !== 'undefined' ? __DEV__ : true;
|
|
309
|
+
if (isDev) {
|
|
310
|
+
runAgentAction('hot-reload', 'Hot reload the app. Send the reload signal to the dev server to trigger a fast refresh.');
|
|
311
|
+
} else {
|
|
312
|
+
runAgentAction(
|
|
313
|
+
'rebuild',
|
|
314
|
+
'This is a release build — hot reload is not available. ' +
|
|
315
|
+
'Rebuild the app using native tools (xcodebuild for iOS, gradle for Android — no Expo) ' +
|
|
316
|
+
'and upload to TestFlight/Play Store. Auto-increment build number. Report progress.',
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
}, [runAgentAction]);
|
|
320
|
+
|
|
321
|
+
// Build config from SDK settings
|
|
322
|
+
const buildPlatforms = config?.buildPlatforms ?? 'both';
|
|
323
|
+
const autoDeploy = config?.autoDeploy !== false; // default true
|
|
324
|
+
|
|
325
|
+
const handleBuild = useCallback(() => {
|
|
326
|
+
const platforms = buildPlatforms === 'both' ? ['ios', 'android'] : [buildPlatforms];
|
|
327
|
+
const parts: string[] = [];
|
|
328
|
+
for (const p of platforms) {
|
|
329
|
+
if (p === 'ios') {
|
|
330
|
+
parts.push(
|
|
331
|
+
autoDeploy
|
|
332
|
+
? 'Build the iOS app, archive, and upload to TestFlight. Auto-increment the build number.'
|
|
333
|
+
: 'Build the iOS app and archive it locally. Auto-increment the build number. Do NOT upload to TestFlight.',
|
|
334
|
+
);
|
|
335
|
+
} else if (p === 'android') {
|
|
336
|
+
parts.push(
|
|
337
|
+
autoDeploy
|
|
338
|
+
? 'Build the Android app (release AAB) and upload to Google Play internal testing. Auto-increment the versionCode.'
|
|
339
|
+
: 'Build the Android app (release AAB) locally. Auto-increment the versionCode. Do NOT upload to Play Store.',
|
|
340
|
+
);
|
|
341
|
+
} else if (p === 'web') {
|
|
342
|
+
parts.push('Build the web app for production.');
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
const deployLabel = autoDeploy ? ' + deploy' : '';
|
|
346
|
+
const platformLabel = buildPlatforms === 'both' ? 'iOS & Android' : buildPlatforms;
|
|
347
|
+
runAgentAction(
|
|
348
|
+
`build-${platformLabel}${deployLabel}`,
|
|
349
|
+
parts.join(' Then, ') + ' Report progress and result.',
|
|
350
|
+
);
|
|
351
|
+
}, [runAgentAction, buildPlatforms, autoDeploy]);
|
|
352
|
+
|
|
353
|
+
const handleBugReport = useCallback(async () => {
|
|
354
|
+
if (!agentUrl || !authToken) return;
|
|
355
|
+
addOutput('> bug report');
|
|
356
|
+
setSending(true);
|
|
357
|
+
try {
|
|
358
|
+
// Try to capture screenshot (without SDK overlay)
|
|
359
|
+
let screenshotUri: string | undefined;
|
|
360
|
+
try {
|
|
361
|
+
const { captureScreenshot } = require('./capture');
|
|
362
|
+
screenshotUri = await captureScreenshot();
|
|
363
|
+
} catch {
|
|
364
|
+
// Screenshot capture not available — send text-only report
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const url = agentUrl.replace(/\/$/, '');
|
|
368
|
+
if (screenshotUri) {
|
|
369
|
+
// Upload screenshot as feedback with bug flag
|
|
370
|
+
const formData = new FormData();
|
|
371
|
+
formData.append('metadata', JSON.stringify({
|
|
372
|
+
timestamp: new Date().toISOString(),
|
|
373
|
+
type: 'bug-report',
|
|
374
|
+
source: 'feedback-sdk',
|
|
375
|
+
}));
|
|
376
|
+
formData.append('screenshot_0', {
|
|
377
|
+
uri: screenshotUri,
|
|
378
|
+
type: 'image/png',
|
|
379
|
+
name: 'bug_screenshot.png',
|
|
380
|
+
} as any);
|
|
381
|
+
const resp = await fetch(`${url}/feedback`, {
|
|
382
|
+
method: 'POST',
|
|
383
|
+
headers: { Authorization: `Bearer ${authToken}` },
|
|
384
|
+
body: formData,
|
|
385
|
+
});
|
|
386
|
+
if (resp.ok) {
|
|
387
|
+
addOutput('screenshot captured & sent');
|
|
388
|
+
} else {
|
|
389
|
+
addOutput(`screenshot upload err: ${resp.status}`);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// Also create a task so the agent investigates
|
|
394
|
+
const resp = await fetch(`${url}/tasks`, {
|
|
395
|
+
method: 'POST',
|
|
396
|
+
headers: { Authorization: `Bearer ${authToken}`, 'Content-Type': 'application/json' },
|
|
397
|
+
body: JSON.stringify({
|
|
398
|
+
title: 'Bug report from device — investigate the attached screenshot and fix any visible issues.',
|
|
399
|
+
source: 'feedback-sdk',
|
|
400
|
+
description: '[Feedback SDK] User tapped the bug report button. A screenshot of the current screen was captured and sent. Investigate the UI state and fix any issues.',
|
|
401
|
+
hasScreenshot: !!screenshotUri,
|
|
402
|
+
}),
|
|
403
|
+
});
|
|
404
|
+
if (resp.ok) {
|
|
405
|
+
const data = await resp.json();
|
|
406
|
+
const taskId = data.taskId ?? data.id ?? data.task?.id;
|
|
407
|
+
addOutput(taskId ? `bug task ${taskId} created` : 'bug report sent');
|
|
408
|
+
BlackBox.log('Bug report submitted', 'FloatingButton');
|
|
409
|
+
} else {
|
|
410
|
+
addOutput(`err: ${resp.status}`);
|
|
411
|
+
}
|
|
412
|
+
} catch (e) {
|
|
413
|
+
addOutput(`fail: ${String(e).slice(0, 50)}`);
|
|
414
|
+
} finally {
|
|
415
|
+
setSending(false);
|
|
416
|
+
}
|
|
417
|
+
}, [agentUrl, authToken, addOutput]);
|
|
418
|
+
|
|
419
|
+
// Start autonomous test session
|
|
420
|
+
const handleTestApp = useCallback(async () => {
|
|
421
|
+
if (!agentUrl || !authToken) return;
|
|
422
|
+
const isRunning = testSession?.active;
|
|
423
|
+
|
|
424
|
+
if (isRunning) {
|
|
425
|
+
// Stop test session
|
|
426
|
+
addOutput('> stopping test...');
|
|
427
|
+
try {
|
|
428
|
+
await fetch(`${agentUrl.replace(/\/$/, '')}/test-app/stop`, {
|
|
429
|
+
method: 'POST',
|
|
430
|
+
headers: { Authorization: `Bearer ${authToken}` },
|
|
431
|
+
});
|
|
432
|
+
addOutput('test session stopped.');
|
|
433
|
+
if (testPollRef.current) clearInterval(testPollRef.current);
|
|
434
|
+
testPollRef.current = null;
|
|
435
|
+
// Fetch final report
|
|
436
|
+
try {
|
|
437
|
+
const resp = await fetch(`${agentUrl.replace(/\/$/, '')}/test-app/status`, {
|
|
438
|
+
headers: { Authorization: `Bearer ${authToken}` },
|
|
439
|
+
});
|
|
440
|
+
if (resp.ok) {
|
|
441
|
+
const session: TestSession = await resp.json();
|
|
442
|
+
setTestSession(session);
|
|
443
|
+
if (session.fixes.length > 0) {
|
|
444
|
+
addOutput(`${session.fixes.length} fixes applied (not committed).`);
|
|
445
|
+
addOutput('tap "fixes" to view report.');
|
|
446
|
+
setShowFixReport(true);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
} catch {}
|
|
450
|
+
} catch (e) {
|
|
451
|
+
addOutput(`fail: ${String(e).slice(0, 50)}`);
|
|
452
|
+
}
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// Start test session
|
|
457
|
+
addOutput('> starting autonomous test...');
|
|
458
|
+
setSending(true);
|
|
459
|
+
try {
|
|
460
|
+
const url = agentUrl.replace(/\/$/, '');
|
|
461
|
+
const resp = await fetch(`${url}/test-app/start`, {
|
|
462
|
+
method: 'POST',
|
|
463
|
+
headers: { Authorization: `Bearer ${authToken}`, 'Content-Type': 'application/json' },
|
|
464
|
+
body: JSON.stringify({ source: 'feedback-sdk' }),
|
|
465
|
+
});
|
|
466
|
+
if (!resp.ok) {
|
|
467
|
+
addOutput(`err: ${resp.status}`);
|
|
468
|
+
setSending(false);
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
const data = await resp.json();
|
|
472
|
+
addOutput(`test session ${data.sessionId ?? 'started'}...`);
|
|
473
|
+
addOutput('agent reading codebase for context...');
|
|
474
|
+
BlackBox.log('Test session started', 'FloatingButton');
|
|
475
|
+
|
|
476
|
+
// Poll test status every 3s
|
|
477
|
+
testPollRef.current = setInterval(async () => {
|
|
478
|
+
try {
|
|
479
|
+
const sr = await fetch(`${url}/test-app/status`, {
|
|
480
|
+
headers: { Authorization: `Bearer ${authToken}` },
|
|
481
|
+
});
|
|
482
|
+
if (sr.ok) {
|
|
483
|
+
const session: TestSession = await sr.json();
|
|
484
|
+
setTestSession(session);
|
|
485
|
+
if (!session.active && testPollRef.current) {
|
|
486
|
+
clearInterval(testPollRef.current);
|
|
487
|
+
testPollRef.current = null;
|
|
488
|
+
addOutput(`test complete: ${session.errorsFound} errors, ${session.fixes.length} fixes.`);
|
|
489
|
+
if (session.fixes.length > 0) {
|
|
490
|
+
addOutput('tap "fixes" to view report.');
|
|
491
|
+
}
|
|
492
|
+
setSending(false);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
} catch {}
|
|
496
|
+
}, 3000);
|
|
497
|
+
} catch (e) {
|
|
498
|
+
addOutput(`fail: ${String(e).slice(0, 50)}`);
|
|
499
|
+
setSending(false);
|
|
500
|
+
}
|
|
501
|
+
}, [agentUrl, authToken, addOutput, testSession]);
|
|
502
|
+
|
|
503
|
+
// Cleanup test poll on unmount
|
|
504
|
+
useEffect(() => {
|
|
505
|
+
return () => {
|
|
506
|
+
if (testPollRef.current) clearInterval(testPollRef.current);
|
|
507
|
+
};
|
|
508
|
+
}, []);
|
|
509
|
+
|
|
510
|
+
const handleDisable = useCallback(() => {
|
|
511
|
+
YaverFeedback.setEnabled(false);
|
|
512
|
+
setChatOpen(false);
|
|
513
|
+
}, []);
|
|
514
|
+
|
|
515
|
+
// Auto-scroll output
|
|
516
|
+
useEffect(() => {
|
|
517
|
+
if (outputScrollRef.current) {
|
|
518
|
+
setTimeout(() => outputScrollRef.current?.scrollToEnd({ animated: true }), 50);
|
|
519
|
+
}
|
|
520
|
+
}, [output]);
|
|
521
|
+
|
|
522
|
+
const isTerminal = stylePreset === 'terminal';
|
|
523
|
+
const buttonIcon = icon ?? 'y';
|
|
524
|
+
const btnBg = isConnected ? color : `${color}88`;
|
|
525
|
+
|
|
526
|
+
const panelWidth = fullSize ? screenWidth - 24 : 280;
|
|
527
|
+
|
|
528
|
+
return (
|
|
529
|
+
<Animated.View
|
|
530
|
+
style={[s.root, { transform: [{ translateX: pan.x }, { translateY: pan.y }] }]}
|
|
531
|
+
{...panResponder.panHandlers}
|
|
532
|
+
>
|
|
533
|
+
{/* Console panel */}
|
|
534
|
+
{chatOpen && (
|
|
535
|
+
<View style={[
|
|
536
|
+
s.panel,
|
|
537
|
+
isTerminal ? s.panelTerminal : s.panelMinimal,
|
|
538
|
+
{ borderColor: `${color}44`, width: panelWidth, backgroundColor: panelBg },
|
|
539
|
+
fullSize && { position: 'absolute', left: 0, top: size + 8 },
|
|
540
|
+
]}>
|
|
541
|
+
{/* Header */}
|
|
542
|
+
<View style={s.headerRow}>
|
|
543
|
+
<Text style={[s.headerTitle, isTerminal && s.mono, { color }]}>
|
|
544
|
+
{isTerminal ? 'YAVER DEBUG' : 'Yaver'}
|
|
545
|
+
</Text>
|
|
546
|
+
<View style={[s.dotSmall, isConnected ? s.green : s.red]} />
|
|
547
|
+
<Text style={[s.headerStatus, isTerminal && s.mono]}>
|
|
548
|
+
{isConnected ? 'live' : 'off'}
|
|
549
|
+
</Text>
|
|
550
|
+
<TouchableOpacity onPress={() => setFullSize(!fullSize)} style={s.xBtn}>
|
|
551
|
+
<Text style={s.xBtnText}>{fullSize ? '\u25A1' : '\u2197'}</Text>
|
|
552
|
+
</TouchableOpacity>
|
|
553
|
+
<TouchableOpacity onPress={() => { setChatOpen(false); setFullSize(false); }} style={s.xBtn}>
|
|
554
|
+
<Text style={s.xBtnText}>{'\u2715'}</Text>
|
|
555
|
+
</TouchableOpacity>
|
|
556
|
+
</View>
|
|
557
|
+
|
|
558
|
+
{/* Output area */}
|
|
559
|
+
<ScrollView
|
|
560
|
+
ref={outputScrollRef}
|
|
561
|
+
style={[s.outputArea, fullSize && s.outputAreaFull]}
|
|
562
|
+
contentContainerStyle={s.outputContent}
|
|
563
|
+
>
|
|
564
|
+
{output.length > 0 ? output.map((line, i) => (
|
|
565
|
+
<Text
|
|
566
|
+
key={i}
|
|
567
|
+
style={[
|
|
568
|
+
s.outputLine,
|
|
569
|
+
isTerminal && s.mono,
|
|
570
|
+
fullSize && s.outputLineFull,
|
|
571
|
+
line.startsWith('>')
|
|
572
|
+
? s.outputLineUser
|
|
573
|
+
: line === 'done.' || line.startsWith('task ')
|
|
574
|
+
? s.outputLineStatus
|
|
575
|
+
: s.outputLineAgent,
|
|
576
|
+
]}
|
|
577
|
+
>
|
|
578
|
+
{line}
|
|
579
|
+
</Text>
|
|
580
|
+
)) : (
|
|
581
|
+
<Text style={[s.outputLine, isTerminal && s.mono, { color: '#666' }]}>
|
|
582
|
+
{isConnected ? 'connected. type a message or use actions below.' : 'not connected to agent.'}
|
|
583
|
+
</Text>
|
|
584
|
+
)}
|
|
585
|
+
{sending && <ActivityIndicator color={color} size="small" style={{ marginTop: 4 }} />}
|
|
586
|
+
</ScrollView>
|
|
587
|
+
|
|
588
|
+
{/* Input */}
|
|
589
|
+
<View style={s.inputRow}>
|
|
590
|
+
{isTerminal && <Text style={[s.prompt, { color }]}>></Text>}
|
|
591
|
+
<TextInput
|
|
592
|
+
style={[s.input, isTerminal && s.mono, fullSize && s.inputFull]}
|
|
593
|
+
placeholder={isTerminal ? 'tell the agent...' : 'Type a message...'}
|
|
594
|
+
placeholderTextColor="#444"
|
|
595
|
+
value={message}
|
|
596
|
+
onChangeText={setMessage}
|
|
597
|
+
onSubmitEditing={handleSend}
|
|
598
|
+
returnKeyType="send"
|
|
599
|
+
multiline={fullSize}
|
|
600
|
+
/>
|
|
601
|
+
<TouchableOpacity
|
|
602
|
+
style={[s.goBtn, { backgroundColor: color }, (sending || !message.trim()) && s.dim]}
|
|
603
|
+
onPress={handleSend}
|
|
604
|
+
disabled={sending || !message.trim() || !isConnected}
|
|
605
|
+
>
|
|
606
|
+
{sending ? (
|
|
607
|
+
<ActivityIndicator color="#fff" size="small" />
|
|
608
|
+
) : (
|
|
609
|
+
<Text style={[s.goBtnText, isTerminal && s.mono]}>
|
|
610
|
+
{isTerminal ? 'run' : 'Send'}
|
|
611
|
+
</Text>
|
|
612
|
+
)}
|
|
613
|
+
</TouchableOpacity>
|
|
614
|
+
</View>
|
|
615
|
+
|
|
616
|
+
{/* Action cards row 1 — Reload | Build | Bug */}
|
|
617
|
+
<View style={s.cardRow}>
|
|
618
|
+
<TouchableOpacity
|
|
619
|
+
style={[s.card, fullSize && s.cardFull, !isConnected && s.dim]}
|
|
620
|
+
onPress={handleReload}
|
|
621
|
+
disabled={sending || !isConnected}
|
|
622
|
+
>
|
|
623
|
+
<Text style={[s.cardIcon, { color: '#fbbf24' }]}>{'\u21BB'}</Text>
|
|
624
|
+
<Text style={[s.cardLabel, isTerminal && s.mono]}>Hot Reload</Text>
|
|
625
|
+
</TouchableOpacity>
|
|
626
|
+
<TouchableOpacity
|
|
627
|
+
style={[s.card, fullSize && s.cardFull, !isConnected && s.dim]}
|
|
628
|
+
onPress={handleBuild}
|
|
629
|
+
disabled={sending || !isConnected}
|
|
630
|
+
>
|
|
631
|
+
<Text style={[s.cardIcon, { color: '#60a5fa' }]}>{'\u2692'}</Text>
|
|
632
|
+
<Text style={[s.cardLabel, isTerminal && s.mono]}>
|
|
633
|
+
{buildPlatforms === 'both' ? 'Build' : `Build ${buildPlatforms}`}
|
|
634
|
+
</Text>
|
|
635
|
+
{autoDeploy && (
|
|
636
|
+
<Text style={[s.cardSub, isTerminal && s.mono]}>
|
|
637
|
+
{buildPlatforms === 'ios' ? '+ TestFlight'
|
|
638
|
+
: buildPlatforms === 'android' ? '+ Play Store'
|
|
639
|
+
: buildPlatforms === 'both' ? '+ Deploy'
|
|
640
|
+
: ''}
|
|
641
|
+
</Text>
|
|
642
|
+
)}
|
|
643
|
+
</TouchableOpacity>
|
|
644
|
+
<TouchableOpacity
|
|
645
|
+
style={[s.card, fullSize && s.cardFull, !isConnected && s.dim]}
|
|
646
|
+
onPress={handleBugReport}
|
|
647
|
+
disabled={sending || !isConnected}
|
|
648
|
+
>
|
|
649
|
+
<Text style={[s.cardIcon, { color: '#f87171' }]}>{'\u{1F41B}'}</Text>
|
|
650
|
+
<Text style={[s.cardLabel, isTerminal && s.mono]}>Report Bug</Text>
|
|
651
|
+
</TouchableOpacity>
|
|
652
|
+
</View>
|
|
653
|
+
|
|
654
|
+
{/* Action cards row 2 — Test | Fixes */}
|
|
655
|
+
<View style={[s.cardRow, { marginTop: -2 }]}>
|
|
656
|
+
<TouchableOpacity
|
|
657
|
+
style={[s.card, fullSize && s.cardFull, !isConnected && s.dim,
|
|
658
|
+
testSession?.active && { borderColor: '#a78bfa44', backgroundColor: '#a78bfa08' }]}
|
|
659
|
+
onPress={handleTestApp}
|
|
660
|
+
disabled={!isConnected}
|
|
661
|
+
>
|
|
662
|
+
<Text style={[s.cardIcon, { color: '#a78bfa' }]}>
|
|
663
|
+
{testSession?.active ? '\u23F8' : '\u25B6'}
|
|
664
|
+
</Text>
|
|
665
|
+
<Text style={[s.cardLabel, isTerminal && s.mono]}>
|
|
666
|
+
{testSession?.active ? 'Stop Test' : 'Test App'}
|
|
667
|
+
</Text>
|
|
668
|
+
{testSession?.active && (
|
|
669
|
+
<Text style={[s.cardSub, isTerminal && s.mono]}>
|
|
670
|
+
{testSession.screensTested}/{testSession.screensDiscovered}
|
|
671
|
+
</Text>
|
|
672
|
+
)}
|
|
673
|
+
</TouchableOpacity>
|
|
674
|
+
{testSession && testSession.fixes.length > 0 && (
|
|
675
|
+
<TouchableOpacity
|
|
676
|
+
style={[s.card, fullSize && s.cardFull]}
|
|
677
|
+
onPress={() => setShowFixReport(true)}
|
|
678
|
+
>
|
|
679
|
+
<Text style={[s.cardIcon, { color: '#22c55e' }]}>{'\u2713'}</Text>
|
|
680
|
+
<Text style={[s.cardLabel, isTerminal && s.mono]}>
|
|
681
|
+
{testSession.fixes.length} Fixes
|
|
682
|
+
</Text>
|
|
683
|
+
</TouchableOpacity>
|
|
684
|
+
)}
|
|
685
|
+
</View>
|
|
686
|
+
|
|
687
|
+
{/* Bottom row */}
|
|
688
|
+
<View style={s.actionsRow}>
|
|
689
|
+
<TouchableOpacity style={s.actionBtn} onPress={() => setOutput([])}>
|
|
690
|
+
<Text style={[s.actionText, isTerminal && s.mono]}>clear</Text>
|
|
691
|
+
</TouchableOpacity>
|
|
692
|
+
<TouchableOpacity
|
|
693
|
+
style={s.actionBtn}
|
|
694
|
+
onPress={() => {
|
|
695
|
+
setChatOpen(false);
|
|
696
|
+
YaverFeedback.startReport();
|
|
697
|
+
}}
|
|
698
|
+
>
|
|
699
|
+
<Text style={[s.actionText, isTerminal && s.mono]}>report</Text>
|
|
700
|
+
</TouchableOpacity>
|
|
701
|
+
<TouchableOpacity style={s.actionBtn} onPress={handleDisable}>
|
|
702
|
+
<Text style={[s.actionText, isTerminal && s.mono, { color: '#f87171' }]}>quit</Text>
|
|
703
|
+
</TouchableOpacity>
|
|
704
|
+
</View>
|
|
705
|
+
</View>
|
|
706
|
+
)}
|
|
707
|
+
|
|
708
|
+
{/* Fix Report Modal */}
|
|
709
|
+
<FixReport
|
|
710
|
+
session={testSession}
|
|
711
|
+
visible={showFixReport}
|
|
712
|
+
onClose={() => setShowFixReport(false)}
|
|
713
|
+
color={color}
|
|
714
|
+
/>
|
|
715
|
+
|
|
716
|
+
{/* The button */}
|
|
717
|
+
<TouchableOpacity
|
|
718
|
+
style={[
|
|
719
|
+
s.button,
|
|
720
|
+
isTerminal ? s.buttonTerminal : s.buttonMinimal,
|
|
721
|
+
{ backgroundColor: btnBg, width: size, height: size },
|
|
722
|
+
!isTerminal && { borderRadius: size / 2 },
|
|
723
|
+
]}
|
|
724
|
+
activeOpacity={0.7}
|
|
725
|
+
onPress={handleTap}
|
|
726
|
+
>
|
|
727
|
+
<Text style={[s.buttonIcon, isTerminal && s.mono, { fontSize: 22 }]}>
|
|
728
|
+
{chatOpen ? '\u2715' : buttonIcon}
|
|
729
|
+
</Text>
|
|
730
|
+
{showStatusDot && (
|
|
731
|
+
<View style={[s.statusDot, isConnected ? s.green : s.red]} />
|
|
732
|
+
)}
|
|
733
|
+
</TouchableOpacity>
|
|
734
|
+
</Animated.View>
|
|
735
|
+
);
|
|
736
|
+
};
|
|
737
|
+
|
|
738
|
+
const s = StyleSheet.create({
|
|
739
|
+
root: { position: 'absolute', zIndex: 99999, alignItems: 'flex-start' },
|
|
740
|
+
mono: { fontFamily: 'Courier' },
|
|
741
|
+
// Button variants
|
|
742
|
+
button: {
|
|
743
|
+
alignItems: 'center',
|
|
744
|
+
justifyContent: 'center',
|
|
745
|
+
shadowColor: '#000',
|
|
746
|
+
shadowOffset: { width: 0, height: 3 },
|
|
747
|
+
shadowOpacity: 0.5,
|
|
748
|
+
shadowRadius: 5,
|
|
749
|
+
elevation: 10,
|
|
750
|
+
},
|
|
751
|
+
buttonTerminal: { borderRadius: 10 },
|
|
752
|
+
buttonMinimal: { /* borderRadius set inline */ },
|
|
753
|
+
buttonIcon: { color: '#fff', fontWeight: '800', fontStyle: 'italic' as const },
|
|
754
|
+
statusDot: {
|
|
755
|
+
position: 'absolute',
|
|
756
|
+
top: -2,
|
|
757
|
+
right: -2,
|
|
758
|
+
width: 9,
|
|
759
|
+
height: 9,
|
|
760
|
+
borderRadius: 5,
|
|
761
|
+
borderWidth: 1.5,
|
|
762
|
+
borderColor: '#000',
|
|
763
|
+
},
|
|
764
|
+
green: { backgroundColor: '#22c55e' },
|
|
765
|
+
red: { backgroundColor: '#ef4444' },
|
|
766
|
+
// Panel
|
|
767
|
+
panel: {
|
|
768
|
+
padding: 10,
|
|
769
|
+
marginBottom: 6,
|
|
770
|
+
borderWidth: 1,
|
|
771
|
+
shadowColor: '#000',
|
|
772
|
+
shadowOffset: { width: 0, height: 4 },
|
|
773
|
+
shadowOpacity: 0.5,
|
|
774
|
+
shadowRadius: 8,
|
|
775
|
+
elevation: 12,
|
|
776
|
+
},
|
|
777
|
+
panelTerminal: {
|
|
778
|
+
borderRadius: 12,
|
|
779
|
+
},
|
|
780
|
+
panelMinimal: {
|
|
781
|
+
borderRadius: 16,
|
|
782
|
+
},
|
|
783
|
+
// Header
|
|
784
|
+
headerRow: { flexDirection: 'row', alignItems: 'center', marginBottom: 6, gap: 5 },
|
|
785
|
+
headerTitle: { flex: 1, fontSize: 11, fontWeight: '700', textTransform: 'uppercase', letterSpacing: 1 },
|
|
786
|
+
dotSmall: { width: 6, height: 6, borderRadius: 3 },
|
|
787
|
+
headerStatus: { fontSize: 10, color: '#666' },
|
|
788
|
+
xBtn: { paddingHorizontal: 6, paddingVertical: 2 },
|
|
789
|
+
xBtnText: { color: '#666', fontSize: 12 },
|
|
790
|
+
// Output
|
|
791
|
+
outputArea: {
|
|
792
|
+
backgroundColor: '#1a1a1a',
|
|
793
|
+
borderRadius: 8,
|
|
794
|
+
padding: 8,
|
|
795
|
+
marginBottom: 6,
|
|
796
|
+
maxHeight: 140,
|
|
797
|
+
},
|
|
798
|
+
outputAreaFull: { maxHeight: 300, minHeight: 160 },
|
|
799
|
+
outputContent: { paddingBottom: 4 },
|
|
800
|
+
outputLine: {
|
|
801
|
+
fontSize: 11,
|
|
802
|
+
lineHeight: 16,
|
|
803
|
+
},
|
|
804
|
+
outputLineUser: {
|
|
805
|
+
color: '#9ca3af',
|
|
806
|
+
fontStyle: 'italic' as const,
|
|
807
|
+
},
|
|
808
|
+
outputLineAgent: {
|
|
809
|
+
color: '#e5e5e5',
|
|
810
|
+
},
|
|
811
|
+
outputLineStatus: {
|
|
812
|
+
color: '#22c55e',
|
|
813
|
+
},
|
|
814
|
+
outputLineFull: { fontSize: 13, lineHeight: 20 },
|
|
815
|
+
// Input
|
|
816
|
+
inputRow: { flexDirection: 'row', alignItems: 'center', gap: 4, marginBottom: 6 },
|
|
817
|
+
prompt: { fontSize: 14, fontWeight: '700' },
|
|
818
|
+
input: {
|
|
819
|
+
flex: 1,
|
|
820
|
+
backgroundColor: '#1a1a1a',
|
|
821
|
+
borderRadius: 6,
|
|
822
|
+
paddingHorizontal: 8,
|
|
823
|
+
paddingVertical: 6,
|
|
824
|
+
color: '#e5e5e5',
|
|
825
|
+
fontSize: 12,
|
|
826
|
+
borderWidth: 1,
|
|
827
|
+
borderColor: '#3a3a3a',
|
|
828
|
+
},
|
|
829
|
+
inputFull: { fontSize: 15, paddingVertical: 10 },
|
|
830
|
+
goBtn: { borderRadius: 6, paddingHorizontal: 10, paddingVertical: 6 },
|
|
831
|
+
goBtnText: { color: '#fff', fontSize: 11, fontWeight: '700' },
|
|
832
|
+
dim: { opacity: 0.3 },
|
|
833
|
+
// Action cards
|
|
834
|
+
cardRow: { flexDirection: 'row', gap: 6, marginBottom: 6 },
|
|
835
|
+
card: {
|
|
836
|
+
flex: 1,
|
|
837
|
+
backgroundColor: '#1a1a1a',
|
|
838
|
+
borderRadius: 8,
|
|
839
|
+
paddingVertical: 10,
|
|
840
|
+
alignItems: 'center',
|
|
841
|
+
borderWidth: 1,
|
|
842
|
+
borderColor: '#3a3a3a',
|
|
843
|
+
},
|
|
844
|
+
cardFull: { paddingVertical: 14 },
|
|
845
|
+
cardIcon: { fontSize: 18, marginBottom: 2 },
|
|
846
|
+
cardLabel: { fontSize: 10, color: '#999', fontWeight: '600' },
|
|
847
|
+
cardSub: { fontSize: 8, color: '#555', marginTop: 1 },
|
|
848
|
+
// Bottom actions
|
|
849
|
+
actionsRow: { flexDirection: 'row', gap: 4 },
|
|
850
|
+
actionBtn: {
|
|
851
|
+
flex: 1,
|
|
852
|
+
paddingVertical: 5,
|
|
853
|
+
borderRadius: 6,
|
|
854
|
+
alignItems: 'center',
|
|
855
|
+
backgroundColor: '#1a1a1a',
|
|
856
|
+
borderWidth: 1,
|
|
857
|
+
borderColor: '#3a3a3a',
|
|
858
|
+
},
|
|
859
|
+
actionText: { fontSize: 10, color: '#999', fontWeight: '600' },
|
|
860
|
+
});
|