rn-backstage 1.4.2 → 1.4.3

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.
@@ -1,425 +0,0 @@
1
- import React, { useCallback, useMemo, useState } from 'react'
2
- import { StyleSheet, Text, TouchableOpacity, View } from 'react-native'
3
- import { MonospaceFont } from '../constants'
4
- import { useBackstageTheme } from '../ThemeContext'
5
- import { NetworkState } from '../types'
6
- import type { NetworkEntry, BackstageTheme } from '../types'
7
- import { formatTimestamp } from '../utils/formatTimestamp'
8
- import { JsonTreeView } from './JsonTreeView'
9
- import { toCurl } from '../network-interceptor'
10
-
11
- // ─── Types ───────────────────────────────────────────────────────────────────
12
-
13
- interface NetworkItemProps {
14
- item: NetworkEntry
15
- onCopy?: (text: string) => void
16
- jsonMaxDepth?: number
17
- }
18
-
19
- // ─── Status Badge Config ─────────────────────────────────────────────────────
20
-
21
- function getStatusConfig(
22
- entry: NetworkEntry,
23
- t: BackstageTheme,
24
- ): { label: string; color: string; bgColor: string; rowBg: string } {
25
- if (entry.state === NetworkState.pending) {
26
- return { label: '…', color: t.warning, bgColor: t.warningDim, rowBg: 'transparent' }
27
- }
28
- if (entry.state === NetworkState.error) {
29
- return { label: 'ERR', color: t.error, bgColor: t.errorDim, rowBg: 'rgba(255, 77, 106, 0.06)' }
30
- }
31
-
32
- const status = entry.status ?? 0
33
-
34
- if (status >= 200 && status < 300) {
35
- return {
36
- label: String(status),
37
- color: t.success,
38
- bgColor: 'rgba(52, 211, 153, 0.12)',
39
- rowBg: 'transparent',
40
- }
41
- }
42
- if (status >= 300 && status < 400) {
43
- return { label: String(status), color: t.info, bgColor: t.infoDim, rowBg: 'transparent' }
44
- }
45
- if (status >= 400 && status < 500) {
46
- return {
47
- label: String(status),
48
- color: t.warning,
49
- bgColor: t.warningDim,
50
- rowBg: 'rgba(255, 178, 36, 0.06)',
51
- }
52
- }
53
- // 5xx
54
- return {
55
- label: String(status),
56
- color: t.error,
57
- bgColor: t.errorDim,
58
- rowBg: 'rgba(255, 77, 106, 0.06)',
59
- }
60
- }
61
-
62
- // ─── Method Badge Colors ─────────────────────────────────────────────────────
63
-
64
- function getMethodColor(method: string, t: BackstageTheme): string {
65
- switch (method) {
66
- case 'GET':
67
- return t.success
68
- case 'POST':
69
- return t.info
70
- case 'PUT':
71
- case 'PATCH':
72
- return t.warning
73
- case 'DELETE':
74
- return t.error
75
- default:
76
- return t.textSecondary
77
- }
78
- }
79
-
80
- // ─── URL Helpers ─────────────────────────────────────────────────────────────
81
-
82
- function extractPath(url: string): string {
83
- try {
84
- const parsed = new URL(url)
85
- const path = parsed.pathname + parsed.search
86
- return path.length > 60 ? path.substring(0, 57) + '...' : path
87
- } catch {
88
- return url.length > 60 ? url.substring(0, 57) + '...' : url
89
- }
90
- }
91
-
92
- function extractHost(url: string): string {
93
- try {
94
- return new URL(url).host
95
- } catch {
96
- return ''
97
- }
98
- }
99
-
100
- // ─── Format Duration ─────────────────────────────────────────────────────────
101
-
102
- function formatDuration(ms?: number): string {
103
- if (ms === undefined) return '—'
104
- if (ms < 1000) return `${ms}ms`
105
- return `${(ms / 1000).toFixed(2)}s`
106
- }
107
-
108
- function formatSize(bytes?: number): string {
109
- if (bytes === undefined) return ''
110
- if (bytes < 1024) return `${bytes}B`
111
- if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`
112
- return `${(bytes / (1024 * 1024)).toFixed(2)}MB`
113
- }
114
-
115
- // ─── Try Parse JSON ──────────────────────────────────────────────────────────
116
-
117
- function tryParseJSON(text?: string): unknown | null {
118
- if (!text) return null
119
- try {
120
- return JSON.parse(text)
121
- } catch {
122
- return null
123
- }
124
- }
125
-
126
- // ─── Component ───────────────────────────────────────────────────────────────
127
-
128
- export const NetworkItem: React.FC<NetworkItemProps> = React.memo(
129
- ({ item, onCopy, jsonMaxDepth }) => {
130
- const theme = useBackstageTheme()
131
- const s = useMemo(() => createStyles(theme), [theme])
132
- const [expanded, setExpanded] = useState(false)
133
- const [detailSection, setDetailSection] = useState<'general' | 'request' | 'response'>(
134
- 'general',
135
- )
136
-
137
- const config = getStatusConfig(item, theme)
138
- const methodColor = getMethodColor(item.method, theme)
139
- const isPending = item.state === NetworkState.pending
140
-
141
- const toggleExpand = useCallback(() => {
142
- setExpanded(prev => !prev)
143
- }, [])
144
-
145
- const handleCopyCurl = useCallback(() => {
146
- if (onCopy) {
147
- onCopy(toCurl(item))
148
- }
149
- }, [item, onCopy])
150
-
151
- // ─── Detail Row (inline) ─────────────────────────────────
152
- const renderDetailRow = (
153
- label: string,
154
- value: string,
155
- selectable?: boolean,
156
- isError?: boolean,
157
- ) => (
158
- <View style={s.detailRow}>
159
- <Text style={s.detailLabel}>{label}</Text>
160
- <Text
161
- style={[s.detailValue, isError && { color: theme.error }]}
162
- numberOfLines={3}
163
- selectable={selectable}
164
- >
165
- {value}
166
- </Text>
167
- </View>
168
- )
169
-
170
- return (
171
- <TouchableOpacity
172
- style={[s.container, { backgroundColor: config.rowBg }]}
173
- onPress={toggleExpand}
174
- onLongPress={handleCopyCurl}
175
- activeOpacity={0.7}
176
- >
177
- {/* ── Collapsed Row ─────────────────────────────────────── */}
178
- <View style={s.headerRow}>
179
- <View style={[s.methodBadge, { borderColor: methodColor }]}>
180
- <Text style={[s.methodText, { color: methodColor }]}>{item.method}</Text>
181
- </View>
182
- <View style={[s.statusBadge, { backgroundColor: config.bgColor }]}>
183
- <Text style={[s.statusText, { color: config.color }]}>{config.label}</Text>
184
- </View>
185
- {!isPending && <Text style={s.duration}>{formatDuration(item.duration)}</Text>}
186
- {isPending && <Text style={[s.duration, { color: theme.warning }]}>pending</Text>}
187
- {item.responseSize !== undefined && (
188
- <Text style={s.size}>{formatSize(item.responseSize)}</Text>
189
- )}
190
- <Text style={s.timestamp}>{formatTimestamp(item.startTime)}</Text>
191
- </View>
192
-
193
- <Text style={s.path} numberOfLines={expanded ? undefined : 1}>
194
- {extractPath(item.url)}
195
- </Text>
196
- <Text style={s.host} numberOfLines={1}>
197
- {extractHost(item.url)}
198
- </Text>
199
-
200
- {item.error && (
201
- <Text style={s.errorText} numberOfLines={expanded ? undefined : 1}>
202
- ✕ {item.error}
203
- </Text>
204
- )}
205
-
206
- {/* ── Expanded Detail ───────────────────────────────────── */}
207
- {expanded && (
208
- <View style={s.detailContainer}>
209
- <View style={s.sectionTabs}>
210
- {(['general', 'request', 'response'] as const).map(section => (
211
- <TouchableOpacity
212
- key={section}
213
- style={[s.sectionTab, detailSection === section && s.sectionTabActive]}
214
- onPress={() => setDetailSection(section)}
215
- >
216
- <Text
217
- style={[s.sectionTabText, detailSection === section && s.sectionTabTextActive]}
218
- >
219
- {section.charAt(0).toUpperCase() + section.slice(1)}
220
- </Text>
221
- </TouchableOpacity>
222
- ))}
223
- </View>
224
-
225
- {detailSection === 'general' && (
226
- <View style={s.sectionContent}>
227
- {renderDetailRow('Method', item.method)}
228
- {renderDetailRow('URL', item.url, true)}
229
- {renderDetailRow(
230
- 'Status',
231
- item.status ? `${item.status} ${item.statusText || ''}` : '—',
232
- )}
233
- {renderDetailRow('Duration', formatDuration(item.duration))}
234
- {item.responseSize !== undefined &&
235
- renderDetailRow('Size', formatSize(item.responseSize))}
236
- {item.error && renderDetailRow('Error', item.error, false, true)}
237
- </View>
238
- )}
239
-
240
- {detailSection === 'request' && (
241
- <View style={s.sectionContent}>
242
- {item.requestHeaders && Object.keys(item.requestHeaders).length > 0 ? (
243
- <>
244
- <Text style={s.subsectionTitle}>HEADERS</Text>
245
- <View style={s.jsonContainer}>
246
- <JsonTreeView data={item.requestHeaders} hideRoot maxDepth={jsonMaxDepth} />
247
- </View>
248
- </>
249
- ) : (
250
- <Text style={s.emptyNote}>No request headers captured</Text>
251
- )}
252
- {item.requestBody ? (
253
- <>
254
- <Text style={s.subsectionTitle}>BODY</Text>
255
- {tryParseJSON(item.requestBody) ? (
256
- <View style={s.jsonContainer}>
257
- <JsonTreeView
258
- data={tryParseJSON(item.requestBody)}
259
- hideRoot
260
- maxDepth={jsonMaxDepth}
261
- />
262
- </View>
263
- ) : (
264
- <Text style={s.bodyText} selectable>
265
- {item.requestBody}
266
- </Text>
267
- )}
268
- </>
269
- ) : null}
270
- </View>
271
- )}
272
-
273
- {detailSection === 'response' && (
274
- <View style={s.sectionContent}>
275
- {item.responseHeaders && Object.keys(item.responseHeaders).length > 0 ? (
276
- <>
277
- <Text style={s.subsectionTitle}>HEADERS</Text>
278
- <View style={s.jsonContainer}>
279
- <JsonTreeView data={item.responseHeaders} hideRoot maxDepth={jsonMaxDepth} />
280
- </View>
281
- </>
282
- ) : (
283
- <Text style={s.emptyNote}>No response headers captured</Text>
284
- )}
285
- {item.responseBody ? (
286
- <>
287
- <Text style={s.subsectionTitle}>BODY</Text>
288
- {tryParseJSON(item.responseBody) ? (
289
- <View style={s.jsonContainer}>
290
- <JsonTreeView
291
- data={tryParseJSON(item.responseBody)}
292
- hideRoot
293
- maxDepth={jsonMaxDepth}
294
- />
295
- </View>
296
- ) : (
297
- <Text style={s.bodyText} selectable>
298
- {item.responseBody}
299
- </Text>
300
- )}
301
- </>
302
- ) : (
303
- <Text style={s.emptyNote}>
304
- {isPending ? 'Awaiting response…' : 'No response body'}
305
- </Text>
306
- )}
307
- </View>
308
- )}
309
-
310
- {onCopy && (
311
- <TouchableOpacity style={s.curlButton} onPress={handleCopyCurl} activeOpacity={0.7}>
312
- <Text style={s.curlButtonText}>⧉ Copy as cURL</Text>
313
- </TouchableOpacity>
314
- )}
315
- </View>
316
- )}
317
-
318
- {!expanded && <Text style={s.expandHint}>tap to inspect ▾</Text>}
319
- </TouchableOpacity>
320
- )
321
- },
322
- )
323
-
324
- // ─── Styles ──────────────────────────────────────────────────────────────────
325
-
326
- const createStyles = (t: BackstageTheme) =>
327
- StyleSheet.create({
328
- container: {
329
- paddingHorizontal: 14,
330
- paddingVertical: 10,
331
- borderBottomWidth: 1,
332
- borderBottomColor: t.border,
333
- },
334
- headerRow: { flexDirection: 'row', alignItems: 'center', marginBottom: 4, gap: 6 },
335
- methodBadge: { borderRadius: 4, borderWidth: 1, paddingHorizontal: 6, paddingVertical: 1 },
336
- methodText: { fontFamily: MonospaceFont, fontSize: 10, fontWeight: '800', letterSpacing: 0.5 },
337
- statusBadge: { borderRadius: 4, paddingHorizontal: 6, paddingVertical: 2 },
338
- statusText: { fontFamily: MonospaceFont, fontSize: 10, fontWeight: '800', letterSpacing: 0.5 },
339
- duration: { fontFamily: MonospaceFont, fontSize: 11, color: t.textMuted },
340
- size: { fontFamily: MonospaceFont, fontSize: 10, color: t.textMuted, opacity: 0.7 },
341
- timestamp: { fontFamily: MonospaceFont, fontSize: 10, color: t.textMuted, marginLeft: 'auto' },
342
- path: { fontFamily: MonospaceFont, fontSize: 12, color: t.text, lineHeight: 18 },
343
- host: { fontFamily: MonospaceFont, fontSize: 10, color: t.textMuted, marginTop: 1 },
344
- errorText: { fontFamily: MonospaceFont, fontSize: 11, color: t.error, marginTop: 4 },
345
- expandHint: {
346
- fontFamily: MonospaceFont,
347
- fontSize: 10,
348
- color: t.textMuted,
349
- marginTop: 4,
350
- fontStyle: 'italic',
351
- },
352
- detailContainer: {
353
- marginTop: 10,
354
- paddingTop: 10,
355
- borderTopWidth: 1,
356
- borderTopColor: t.border,
357
- },
358
- sectionTabs: { flexDirection: 'row', gap: 4, marginBottom: 10 },
359
- sectionTab: {
360
- paddingHorizontal: 12,
361
- paddingVertical: 6,
362
- borderRadius: 6,
363
- backgroundColor: t.surfaceElevated,
364
- borderWidth: 1,
365
- borderColor: t.border,
366
- },
367
- sectionTabActive: { backgroundColor: t.accentDim, borderColor: t.accent },
368
- sectionTabText: {
369
- fontFamily: MonospaceFont,
370
- fontSize: 11,
371
- fontWeight: '600',
372
- color: t.textMuted,
373
- },
374
- sectionTabTextActive: { color: t.accent },
375
- sectionContent: { marginBottom: 8 },
376
- subsectionTitle: {
377
- fontFamily: MonospaceFont,
378
- fontSize: 10,
379
- fontWeight: '700',
380
- color: t.textMuted,
381
- letterSpacing: 1.2,
382
- marginBottom: 6,
383
- marginTop: 8,
384
- },
385
- jsonContainer: {
386
- backgroundColor: t.surfaceElevated,
387
- borderRadius: 8,
388
- borderWidth: 1,
389
- borderColor: t.border,
390
- padding: 8,
391
- },
392
- bodyText: {
393
- fontFamily: MonospaceFont,
394
- fontSize: 11,
395
- color: t.text,
396
- lineHeight: 16,
397
- backgroundColor: t.surfaceElevated,
398
- borderRadius: 8,
399
- borderWidth: 1,
400
- borderColor: t.border,
401
- padding: 8,
402
- overflow: 'hidden',
403
- },
404
- emptyNote: {
405
- fontFamily: MonospaceFont,
406
- fontSize: 11,
407
- color: t.textMuted,
408
- fontStyle: 'italic',
409
- paddingVertical: 8,
410
- },
411
- detailRow: { flexDirection: 'row', paddingVertical: 4 },
412
- detailLabel: { fontFamily: MonospaceFont, fontSize: 11, color: t.textMuted, width: 70 },
413
- detailValue: { fontFamily: MonospaceFont, fontSize: 11, color: t.text, flex: 1 },
414
- curlButton: {
415
- marginTop: 10,
416
- backgroundColor: t.accentDim,
417
- borderRadius: 8,
418
- borderWidth: 1,
419
- borderColor: t.accent,
420
- paddingVertical: 8,
421
- paddingHorizontal: 14,
422
- alignSelf: 'flex-start',
423
- },
424
- curlButtonText: { fontFamily: MonospaceFont, fontSize: 12, fontWeight: '700', color: t.accent },
425
- })
@@ -1,239 +0,0 @@
1
- import React, { useCallback, useMemo, useState } from 'react'
2
- import { FlatList, StyleSheet, Text, TextInput, TouchableOpacity, View } from 'react-native'
3
- import { MonospaceFont, TestIDs } from '../constants'
4
- import { useBackstageTheme } from '../ThemeContext'
5
- import { NetworkState } from '../types'
6
- import type { NetworkEntry, BackstageTheme } from '../types'
7
- import { NetworkItem } from './NetworkItem'
8
-
9
- // ─── Types ───────────────────────────────────────────────────────────────────
10
-
11
- interface NetworkTabProps {
12
- entries: NetworkEntry[]
13
- onRefresh: () => void
14
- onClear: () => void
15
- onCopy?: (text: string) => void
16
- jsonMaxDepth?: number
17
- }
18
-
19
- // ─── Component ───────────────────────────────────────────────────────────────
20
-
21
- export const NetworkTab: React.FC<NetworkTabProps> = ({
22
- entries,
23
- onRefresh,
24
- onClear,
25
- onCopy,
26
- jsonMaxDepth,
27
- }) => {
28
- const theme = useBackstageTheme()
29
- const s = useMemo(() => createStyles(theme), [theme])
30
- const [searchText, setSearchText] = useState('')
31
-
32
- const filteredEntries = useMemo(() => {
33
- if (!searchText.trim()) return entries
34
- const query = searchText.toLowerCase()
35
- return entries.filter(entry => {
36
- return (
37
- entry.url.toLowerCase().includes(query) ||
38
- entry.method.toLowerCase().includes(query) ||
39
- (entry.status !== undefined && String(entry.status).includes(query)) ||
40
- (entry.error && entry.error.toLowerCase().includes(query))
41
- )
42
- })
43
- }, [entries, searchText])
44
-
45
- const stats = useMemo(() => {
46
- const total = entries.length
47
- const errors = entries.filter(
48
- e => e.state === NetworkState.error || (e.status !== undefined && e.status >= 400),
49
- ).length
50
- const pending = entries.filter(e => e.state === NetworkState.pending).length
51
- const completed = entries.filter(
52
- e => e.state === NetworkState.completed && e.duration !== undefined,
53
- )
54
- const avgDuration =
55
- completed.length > 0
56
- ? Math.round(completed.reduce((sum, e) => sum + (e.duration ?? 0), 0) / completed.length)
57
- : 0
58
- return { total, errors, pending, avgDuration }
59
- }, [entries])
60
-
61
- const renderItem = useCallback(
62
- ({ item }: { item: NetworkEntry }) => (
63
- <NetworkItem item={item} onCopy={onCopy} jsonMaxDepth={jsonMaxDepth} />
64
- ),
65
- [onCopy],
66
- )
67
-
68
- const keyExtractor = useCallback((item: NetworkEntry) => item.id, [])
69
-
70
- return (
71
- <View testID={TestIDs.networkTab.container} style={s.container}>
72
- <View style={s.searchContainer}>
73
- <View style={s.searchInputWrapper}>
74
- <Text style={s.searchIcon}>⌕</Text>
75
- <TextInput
76
- testID={TestIDs.networkTab.searchInput}
77
- style={s.searchInput}
78
- placeholder="Filter by URL, method, status..."
79
- placeholderTextColor={theme.textMuted}
80
- value={searchText}
81
- onChangeText={setSearchText}
82
- autoCapitalize="none"
83
- autoCorrect={false}
84
- clearButtonMode="while-editing"
85
- returnKeyType="search"
86
- />
87
- </View>
88
- <TouchableOpacity
89
- testID={TestIDs.networkTab.clearButton}
90
- style={s.clearButton}
91
- onPress={onClear}
92
- activeOpacity={0.7}
93
- >
94
- <Text style={s.clearButtonText}>Clear</Text>
95
- </TouchableOpacity>
96
- </View>
97
-
98
- <View testID={TestIDs.networkTab.statsBar} style={s.statsBar}>
99
- <View style={s.statsRow}>
100
- <Text style={s.statText}>
101
- {filteredEntries.length === entries.length
102
- ? `${stats.total} request${stats.total !== 1 ? 's' : ''}`
103
- : `${filteredEntries.length} of ${stats.total}`}
104
- </Text>
105
- {stats.errors > 0 && (
106
- <View style={s.statBadge}>
107
- <Text style={s.statBadgeError}>
108
- {stats.errors} error{stats.errors !== 1 ? 's' : ''}
109
- </Text>
110
- </View>
111
- )}
112
- {stats.pending > 0 && (
113
- <View style={[s.statBadge, s.statBadgePending]}>
114
- <Text style={s.statBadgePendingText}>{stats.pending} pending</Text>
115
- </View>
116
- )}
117
- </View>
118
- {stats.avgDuration > 0 && <Text style={s.avgText}>avg {stats.avgDuration}ms</Text>}
119
- </View>
120
-
121
- <FlatList
122
- testID={TestIDs.networkTab.list}
123
- data={filteredEntries}
124
- renderItem={renderItem}
125
- keyExtractor={keyExtractor}
126
- refreshing={false}
127
- onRefresh={onRefresh}
128
- style={s.list}
129
- contentContainerStyle={filteredEntries.length === 0 ? s.emptyContainer : undefined}
130
- ListEmptyComponent={
131
- <View style={s.emptyState}>
132
- <Text style={s.emptyIcon}>🌐</Text>
133
- <Text style={s.emptyTitle}>No network requests</Text>
134
- <Text style={s.emptySubtitle}>
135
- HTTP requests made via fetch or XMLHttpRequest will appear here
136
- </Text>
137
- </View>
138
- }
139
- maxToRenderPerBatch={20}
140
- windowSize={10}
141
- initialNumToRender={20}
142
- />
143
- </View>
144
- )
145
- }
146
-
147
- // ─── Styles ──────────────────────────────────────────────────────────────────
148
-
149
- const createStyles = (t: BackstageTheme) =>
150
- StyleSheet.create({
151
- container: { flex: 1 },
152
- searchContainer: {
153
- flexDirection: 'row',
154
- alignItems: 'center',
155
- paddingHorizontal: 14,
156
- paddingVertical: 10,
157
- gap: 10,
158
- borderBottomWidth: 1,
159
- borderBottomColor: t.border,
160
- },
161
- searchInputWrapper: {
162
- flex: 1,
163
- flexDirection: 'row',
164
- alignItems: 'center',
165
- backgroundColor: t.surfaceElevated,
166
- borderRadius: 10,
167
- borderWidth: 1,
168
- borderColor: t.border,
169
- paddingHorizontal: 12,
170
- height: 38,
171
- },
172
- searchIcon: { fontSize: 16, color: t.textMuted, marginRight: 8 },
173
- searchInput: {
174
- flex: 1,
175
- fontFamily: MonospaceFont,
176
- fontSize: 13,
177
- color: t.text,
178
- padding: 0,
179
- },
180
- clearButton: {
181
- backgroundColor: t.errorDim,
182
- borderRadius: 8,
183
- borderWidth: 1,
184
- borderColor: t.error,
185
- paddingHorizontal: 14,
186
- height: 38,
187
- justifyContent: 'center',
188
- },
189
- clearButtonText: {
190
- fontFamily: MonospaceFont,
191
- fontSize: 12,
192
- fontWeight: '700',
193
- color: t.error,
194
- letterSpacing: 0.5,
195
- },
196
- statsBar: {
197
- flexDirection: 'row',
198
- justifyContent: 'space-between',
199
- alignItems: 'center',
200
- paddingHorizontal: 16,
201
- paddingVertical: 6,
202
- backgroundColor: t.surface,
203
- },
204
- statsRow: { flexDirection: 'row', alignItems: 'center', gap: 8 },
205
- statText: { fontFamily: MonospaceFont, fontSize: 11, color: t.textMuted },
206
- statBadge: {
207
- backgroundColor: t.errorDim,
208
- borderRadius: 4,
209
- paddingHorizontal: 6,
210
- paddingVertical: 1,
211
- },
212
- statBadgeError: { fontFamily: MonospaceFont, fontSize: 10, fontWeight: '700', color: t.error },
213
- statBadgePending: { backgroundColor: t.warningDim },
214
- statBadgePendingText: {
215
- fontFamily: MonospaceFont,
216
- fontSize: 10,
217
- fontWeight: '700',
218
- color: t.warning,
219
- },
220
- avgText: { fontFamily: MonospaceFont, fontSize: 10, color: t.textMuted, fontStyle: 'italic' },
221
- list: { flex: 1 },
222
- emptyContainer: { flex: 1, justifyContent: 'center' },
223
- emptyState: { alignItems: 'center', justifyContent: 'center', padding: 40 },
224
- emptyIcon: { fontSize: 48, marginBottom: 16, opacity: 0.5 },
225
- emptyTitle: {
226
- fontFamily: MonospaceFont,
227
- fontSize: 16,
228
- fontWeight: '700',
229
- color: t.textSecondary,
230
- marginBottom: 8,
231
- },
232
- emptySubtitle: {
233
- fontFamily: MonospaceFont,
234
- fontSize: 13,
235
- color: t.textMuted,
236
- textAlign: 'center',
237
- lineHeight: 20,
238
- },
239
- })