rn-backstage 1.2.0 → 1.3.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.
Files changed (110) hide show
  1. package/README.md +84 -2
  2. package/lib/commonjs/Backstage.js +46 -33
  3. package/lib/commonjs/Backstage.js.map +1 -1
  4. package/lib/commonjs/ThemeContext.js +43 -0
  5. package/lib/commonjs/ThemeContext.js.map +1 -0
  6. package/lib/commonjs/components/BackstagePanel.js +35 -12
  7. package/lib/commonjs/components/BackstagePanel.js.map +1 -1
  8. package/lib/commonjs/components/FlagsTab.js +60 -57
  9. package/lib/commonjs/components/FlagsTab.js.map +1 -1
  10. package/lib/commonjs/components/FloatingPill.js +86 -24
  11. package/lib/commonjs/components/FloatingPill.js.map +1 -1
  12. package/lib/commonjs/components/InfoTab.js +77 -107
  13. package/lib/commonjs/components/InfoTab.js.map +1 -1
  14. package/lib/commonjs/components/JsonTreeView.js +39 -34
  15. package/lib/commonjs/components/JsonTreeView.js.map +1 -1
  16. package/lib/commonjs/components/LogItem.js +39 -29
  17. package/lib/commonjs/components/LogItem.js.map +1 -1
  18. package/lib/commonjs/components/LogsTab.js +39 -42
  19. package/lib/commonjs/components/LogsTab.js.map +1 -1
  20. package/lib/commonjs/components/NetworkItem.js +111 -135
  21. package/lib/commonjs/components/NetworkItem.js.map +1 -1
  22. package/lib/commonjs/components/NetworkTab.js +50 -49
  23. package/lib/commonjs/components/NetworkTab.js.map +1 -1
  24. package/lib/commonjs/components/StorageTab.js +655 -0
  25. package/lib/commonjs/components/StorageTab.js.map +1 -0
  26. package/lib/commonjs/components/TabBar.js +8 -5
  27. package/lib/commonjs/components/TabBar.js.map +1 -1
  28. package/lib/commonjs/constants.js +96 -7
  29. package/lib/commonjs/constants.js.map +1 -1
  30. package/lib/commonjs/index.js +96 -0
  31. package/lib/commonjs/index.js.map +1 -1
  32. package/lib/commonjs/types.js +1 -0
  33. package/lib/commonjs/types.js.map +1 -1
  34. package/lib/module/Backstage.js +46 -33
  35. package/lib/module/Backstage.js.map +1 -1
  36. package/lib/module/ThemeContext.js +37 -0
  37. package/lib/module/ThemeContext.js.map +1 -0
  38. package/lib/module/components/BackstagePanel.js +37 -14
  39. package/lib/module/components/BackstagePanel.js.map +1 -1
  40. package/lib/module/components/FlagsTab.js +61 -58
  41. package/lib/module/components/FlagsTab.js.map +1 -1
  42. package/lib/module/components/FloatingPill.js +90 -28
  43. package/lib/module/components/FloatingPill.js.map +1 -1
  44. package/lib/module/components/InfoTab.js +77 -107
  45. package/lib/module/components/InfoTab.js.map +1 -1
  46. package/lib/module/components/JsonTreeView.js +42 -38
  47. package/lib/module/components/JsonTreeView.js.map +1 -1
  48. package/lib/module/components/LogItem.js +41 -31
  49. package/lib/module/components/LogItem.js.map +1 -1
  50. package/lib/module/components/LogsTab.js +40 -43
  51. package/lib/module/components/LogsTab.js.map +1 -1
  52. package/lib/module/components/NetworkItem.js +113 -137
  53. package/lib/module/components/NetworkItem.js.map +1 -1
  54. package/lib/module/components/NetworkTab.js +51 -50
  55. package/lib/module/components/NetworkTab.js.map +1 -1
  56. package/lib/module/components/StorageTab.js +650 -0
  57. package/lib/module/components/StorageTab.js.map +1 -0
  58. package/lib/module/components/TabBar.js +10 -7
  59. package/lib/module/components/TabBar.js.map +1 -1
  60. package/lib/module/constants.js +95 -6
  61. package/lib/module/constants.js.map +1 -1
  62. package/lib/module/index.js +14 -0
  63. package/lib/module/index.js.map +1 -1
  64. package/lib/module/types.js +2 -0
  65. package/lib/module/types.js.map +1 -1
  66. package/lib/typescript/Backstage.d.ts.map +1 -1
  67. package/lib/typescript/ThemeContext.d.ts +11 -0
  68. package/lib/typescript/ThemeContext.d.ts.map +1 -0
  69. package/lib/typescript/components/BackstagePanel.d.ts +3 -1
  70. package/lib/typescript/components/BackstagePanel.d.ts.map +1 -1
  71. package/lib/typescript/components/FlagsTab.d.ts.map +1 -1
  72. package/lib/typescript/components/FloatingPill.d.ts +2 -0
  73. package/lib/typescript/components/FloatingPill.d.ts.map +1 -1
  74. package/lib/typescript/components/InfoTab.d.ts +1 -0
  75. package/lib/typescript/components/InfoTab.d.ts.map +1 -1
  76. package/lib/typescript/components/JsonTreeView.d.ts.map +1 -1
  77. package/lib/typescript/components/LogItem.d.ts +1 -0
  78. package/lib/typescript/components/LogItem.d.ts.map +1 -1
  79. package/lib/typescript/components/LogsTab.d.ts +1 -0
  80. package/lib/typescript/components/LogsTab.d.ts.map +1 -1
  81. package/lib/typescript/components/NetworkItem.d.ts +1 -0
  82. package/lib/typescript/components/NetworkItem.d.ts.map +1 -1
  83. package/lib/typescript/components/NetworkTab.d.ts +1 -0
  84. package/lib/typescript/components/NetworkTab.d.ts.map +1 -1
  85. package/lib/typescript/components/StorageTab.d.ts +9 -0
  86. package/lib/typescript/components/StorageTab.d.ts.map +1 -0
  87. package/lib/typescript/components/TabBar.d.ts.map +1 -1
  88. package/lib/typescript/constants.d.ts +60 -4
  89. package/lib/typescript/constants.d.ts.map +1 -1
  90. package/lib/typescript/index.d.ts +14 -1
  91. package/lib/typescript/index.d.ts.map +1 -1
  92. package/lib/typescript/types.d.ts +20 -0
  93. package/lib/typescript/types.d.ts.map +1 -1
  94. package/package.json +6 -4
  95. package/src/Backstage.tsx +45 -33
  96. package/src/ThemeContext.tsx +38 -0
  97. package/src/components/BackstagePanel.tsx +89 -61
  98. package/src/components/FlagsTab.tsx +125 -174
  99. package/src/components/FloatingPill.tsx +127 -30
  100. package/src/components/InfoTab.tsx +153 -186
  101. package/src/components/JsonTreeView.tsx +53 -73
  102. package/src/components/LogItem.tsx +64 -100
  103. package/src/components/LogsTab.tsx +110 -132
  104. package/src/components/NetworkItem.tsx +306 -417
  105. package/src/components/NetworkTab.tsx +129 -163
  106. package/src/components/StorageTab.tsx +607 -0
  107. package/src/components/TabBar.tsx +51 -47
  108. package/src/constants.ts +103 -4
  109. package/src/index.ts +17 -0
  110. package/src/types.ts +28 -0
@@ -1,7 +1,8 @@
1
1
  import React, { useMemo, useState } from 'react'
2
2
  import { ScrollView, StyleSheet, Switch, Text, TextInput, View } from 'react-native'
3
- import { DarkTheme, MonospaceFont } from '../constants'
4
- import type { FeatureFlag } from '../types'
3
+ import { MonospaceFont } from '../constants'
4
+ import { useBackstageTheme } from '../ThemeContext'
5
+ import type { FeatureFlag, BackstageTheme } from '../types'
5
6
 
6
7
  // ─── Types ───────────────────────────────────────────────────────────────────
7
8
 
@@ -13,6 +14,8 @@ interface FlagsTabProps {
13
14
  // ─── Component ───────────────────────────────────────────────────────────────
14
15
 
15
16
  export const FlagsTab: React.FC<FlagsTabProps> = ({ flags, onToggle }) => {
17
+ const theme = useBackstageTheme()
18
+ const s = useMemo(() => createStyles(theme), [theme])
16
19
  const [searchText, setSearchText] = useState('')
17
20
 
18
21
  const filteredFlags = useMemo(() => {
@@ -26,19 +29,17 @@ export const FlagsTab: React.FC<FlagsTabProps> = ({ flags, onToggle }) => {
26
29
  )
27
30
  }, [flags, searchText])
28
31
 
29
- // Stats
30
32
  const enabledCount = flags.filter(f => f.value).length
31
33
 
32
34
  return (
33
- <View style={styles.container}>
34
- {/* Search Bar */}
35
- <View style={styles.searchContainer}>
36
- <View style={styles.searchInputWrapper}>
37
- <Text style={styles.searchIcon}>⌕</Text>
35
+ <View style={s.container}>
36
+ <View style={s.searchContainer}>
37
+ <View style={s.searchInputWrapper}>
38
+ <Text style={s.searchIcon}>⌕</Text>
38
39
  <TextInput
39
- style={styles.searchInput}
40
+ style={s.searchInput}
40
41
  placeholder="Filter flags..."
41
- placeholderTextColor={DarkTheme.textMuted}
42
+ placeholderTextColor={theme.textMuted}
42
43
  value={searchText}
43
44
  onChangeText={setSearchText}
44
45
  autoCapitalize="none"
@@ -49,59 +50,51 @@ export const FlagsTab: React.FC<FlagsTabProps> = ({ flags, onToggle }) => {
49
50
  </View>
50
51
  </View>
51
52
 
52
- {/* Stats Bar */}
53
- <View style={styles.statsBar}>
54
- <Text style={styles.statText}>
53
+ <View style={s.statsBar}>
54
+ <Text style={s.statText}>
55
55
  {filteredFlags.length === flags.length
56
56
  ? `${flags.length} flag${flags.length !== 1 ? 's' : ''}`
57
57
  : `${filteredFlags.length} of ${flags.length}`}
58
58
  </Text>
59
- <Text style={styles.statText}>
60
- <Text style={styles.statEnabled}>{enabledCount} on</Text>
59
+ <Text style={s.statText}>
60
+ <Text style={s.statEnabled}>{enabledCount} on</Text>
61
61
  {' · '}
62
- <Text style={styles.statDisabled}>{flags.length - enabledCount} off</Text>
62
+ <Text style={s.statDisabled}>{flags.length - enabledCount} off</Text>
63
63
  </Text>
64
64
  </View>
65
65
 
66
- {/* Flag List */}
67
66
  <ScrollView
68
- style={styles.list}
69
- contentContainerStyle={
70
- filteredFlags.length === 0 ? styles.emptyContainer : styles.listContent
71
- }
67
+ style={s.list}
68
+ contentContainerStyle={filteredFlags.length === 0 ? s.emptyContainer : s.listContent}
72
69
  showsVerticalScrollIndicator={false}
73
70
  >
74
71
  {filteredFlags.length === 0 ? (
75
- <View style={styles.emptyState}>
76
- <Text style={styles.emptyIcon}>🎚</Text>
77
- <Text style={styles.emptyTitle}>No flags match</Text>
78
- <Text style={styles.emptySubtitle}>Try adjusting your search</Text>
72
+ <View style={s.emptyState}>
73
+ <Text style={s.emptyIcon}>🎚</Text>
74
+ <Text style={s.emptyTitle}>No flags match</Text>
75
+ <Text style={s.emptySubtitle}>Try adjusting your search</Text>
79
76
  </View>
80
77
  ) : (
81
78
  filteredFlags.map((flag, index) => (
82
79
  <View key={flag.key}>
83
- <View style={styles.flagRow}>
84
- <View style={styles.flagInfo}>
85
- <View style={styles.flagHeader}>
86
- <Text style={styles.flagLabel}>{flag.label}</Text>
87
- <Text style={[styles.flagKey, flag.value && styles.flagKeyActive]}>
88
- {flag.key}
89
- </Text>
80
+ <View style={s.flagRow}>
81
+ <View style={s.flagInfo}>
82
+ <View style={s.flagHeader}>
83
+ <Text style={s.flagLabel} numberOfLines={2}>{flag.label}</Text>
90
84
  </View>
91
- {flag.description && (
92
- <Text style={styles.flagDescription}>{flag.description}</Text>
93
- )}
85
+ <Text style={[s.flagKey, flag.value && s.flagKeyActive]}>{flag.key}</Text>
86
+ {flag.description && <Text style={s.flagDescription}>{flag.description}</Text>}
94
87
  </View>
95
88
  <Switch
96
89
  testID={`backstage.flag.${flag.key}`}
97
90
  value={flag.value}
98
91
  onValueChange={(newValue: boolean) => onToggle?.(flag.key, newValue)}
99
- trackColor={{ false: DarkTheme.border, true: DarkTheme.accent }}
100
- thumbColor={flag.value ? '#FFFFFF' : DarkTheme.textMuted}
101
- ios_backgroundColor={DarkTheme.border}
92
+ trackColor={{ false: theme.border, true: theme.accent }}
93
+ thumbColor={flag.value ? '#FFFFFF' : theme.textMuted}
94
+ ios_backgroundColor={theme.border}
102
95
  />
103
96
  </View>
104
- {index < filteredFlags.length - 1 && <View style={styles.divider} />}
97
+ {index < filteredFlags.length - 1 && <View style={s.divider} />}
105
98
  </View>
106
99
  ))
107
100
  )}
@@ -112,138 +105,96 @@ export const FlagsTab: React.FC<FlagsTabProps> = ({ flags, onToggle }) => {
112
105
 
113
106
  // ─── Styles ──────────────────────────────────────────────────────────────────
114
107
 
115
- const styles = StyleSheet.create({
116
- container: {
117
- flex: 1,
118
- },
119
- searchContainer: {
120
- paddingHorizontal: 14,
121
- paddingVertical: 10,
122
- borderBottomWidth: 1,
123
- borderBottomColor: DarkTheme.border,
124
- },
125
- searchInputWrapper: {
126
- flexDirection: 'row',
127
- alignItems: 'center',
128
- backgroundColor: DarkTheme.surfaceElevated,
129
- borderRadius: 10,
130
- borderWidth: 1,
131
- borderColor: DarkTheme.border,
132
- paddingHorizontal: 12,
133
- height: 38,
134
- },
135
- searchIcon: {
136
- fontSize: 16,
137
- color: DarkTheme.textMuted,
138
- marginRight: 8,
139
- },
140
- searchInput: {
141
- flex: 1,
142
- fontFamily: MonospaceFont,
143
- fontSize: 13,
144
- color: DarkTheme.text,
145
- padding: 0,
146
- },
147
- statsBar: {
148
- flexDirection: 'row',
149
- justifyContent: 'space-between',
150
- alignItems: 'center',
151
- paddingHorizontal: 16,
152
- paddingVertical: 6,
153
- backgroundColor: DarkTheme.surface,
154
- },
155
- statText: {
156
- fontFamily: MonospaceFont,
157
- fontSize: 11,
158
- color: DarkTheme.textMuted,
159
- },
160
- statEnabled: {
161
- color: DarkTheme.success,
162
- fontWeight: '700',
163
- },
164
- statDisabled: {
165
- color: DarkTheme.textMuted,
166
- },
167
- list: {
168
- flex: 1,
169
- },
170
- listContent: {
171
- paddingHorizontal: 16,
172
- paddingVertical: 8,
173
- },
174
- emptyContainer: {
175
- flex: 1,
176
- justifyContent: 'center',
177
- },
178
- emptyState: {
179
- alignItems: 'center',
180
- justifyContent: 'center',
181
- padding: 40,
182
- },
183
- emptyIcon: {
184
- fontSize: 48,
185
- marginBottom: 16,
186
- opacity: 0.5,
187
- },
188
- emptyTitle: {
189
- fontFamily: MonospaceFont,
190
- fontSize: 16,
191
- fontWeight: '700',
192
- color: DarkTheme.textSecondary,
193
- marginBottom: 8,
194
- },
195
- emptySubtitle: {
196
- fontFamily: MonospaceFont,
197
- fontSize: 13,
198
- color: DarkTheme.textMuted,
199
- textAlign: 'center',
200
- },
201
- flagRow: {
202
- flexDirection: 'row',
203
- justifyContent: 'space-between',
204
- alignItems: 'center',
205
- paddingVertical: 14,
206
- paddingHorizontal: 4,
207
- },
208
- flagInfo: {
209
- flex: 1,
210
- marginRight: 16,
211
- },
212
- flagHeader: {
213
- flexDirection: 'row',
214
- alignItems: 'center',
215
- gap: 8,
216
- },
217
- flagLabel: {
218
- fontFamily: MonospaceFont,
219
- fontSize: 14,
220
- color: DarkTheme.text,
221
- fontWeight: '600',
222
- },
223
- flagKey: {
224
- fontFamily: MonospaceFont,
225
- fontSize: 10,
226
- color: DarkTheme.textMuted,
227
- backgroundColor: DarkTheme.surfaceElevated,
228
- borderRadius: 4,
229
- paddingHorizontal: 6,
230
- paddingVertical: 1,
231
- overflow: 'hidden',
232
- },
233
- flagKeyActive: {
234
- color: DarkTheme.accent,
235
- backgroundColor: DarkTheme.accentDim,
236
- },
237
- flagDescription: {
238
- fontFamily: MonospaceFont,
239
- fontSize: 12,
240
- color: DarkTheme.textMuted,
241
- marginTop: 4,
242
- lineHeight: 18,
243
- },
244
- divider: {
245
- height: 1,
246
- backgroundColor: DarkTheme.border,
247
- marginHorizontal: 4,
248
- },
249
- })
108
+ const createStyles = (t: BackstageTheme) =>
109
+ StyleSheet.create({
110
+ container: { flex: 1 },
111
+ searchContainer: {
112
+ paddingHorizontal: 14,
113
+ paddingVertical: 10,
114
+ borderBottomWidth: 1,
115
+ borderBottomColor: t.border,
116
+ },
117
+ searchInputWrapper: {
118
+ flexDirection: 'row',
119
+ alignItems: 'center',
120
+ backgroundColor: t.surfaceElevated,
121
+ borderRadius: 10,
122
+ borderWidth: 1,
123
+ borderColor: t.border,
124
+ paddingHorizontal: 12,
125
+ height: 38,
126
+ },
127
+ searchIcon: { fontSize: 16, color: t.textMuted, marginRight: 8 },
128
+ searchInput: {
129
+ flex: 1,
130
+ fontFamily: MonospaceFont,
131
+ fontSize: 13,
132
+ color: t.text,
133
+ padding: 0,
134
+ },
135
+ statsBar: {
136
+ flexDirection: 'row',
137
+ justifyContent: 'space-between',
138
+ alignItems: 'center',
139
+ paddingHorizontal: 16,
140
+ paddingVertical: 6,
141
+ backgroundColor: t.surface,
142
+ },
143
+ statText: { fontFamily: MonospaceFont, fontSize: 11, color: t.textMuted },
144
+ statEnabled: { color: t.success, fontWeight: '700' },
145
+ statDisabled: { color: t.textMuted },
146
+ list: { flex: 1 },
147
+ listContent: { paddingHorizontal: 16, paddingVertical: 8 },
148
+ emptyContainer: { flex: 1, justifyContent: 'center' },
149
+ emptyState: { alignItems: 'center', justifyContent: 'center', padding: 40 },
150
+ emptyIcon: { fontSize: 48, marginBottom: 16, opacity: 0.5 },
151
+ emptyTitle: {
152
+ fontFamily: MonospaceFont,
153
+ fontSize: 16,
154
+ fontWeight: '700',
155
+ color: t.textSecondary,
156
+ marginBottom: 8,
157
+ },
158
+ emptySubtitle: {
159
+ fontFamily: MonospaceFont,
160
+ fontSize: 13,
161
+ color: t.textMuted,
162
+ textAlign: 'center',
163
+ },
164
+ flagRow: {
165
+ flexDirection: 'row',
166
+ justifyContent: 'space-between',
167
+ alignItems: 'center',
168
+ paddingVertical: 14,
169
+ paddingHorizontal: 4,
170
+ },
171
+ flagInfo: { flex: 1, marginRight: 16 },
172
+ flagHeader: { flexDirection: 'row', alignItems: 'center' },
173
+ flagLabel: {
174
+ fontFamily: MonospaceFont,
175
+ fontSize: 14,
176
+ color: t.text,
177
+ fontWeight: '600',
178
+ },
179
+ flagKey: {
180
+ fontFamily: MonospaceFont,
181
+ fontSize: 10,
182
+ color: t.textMuted,
183
+ backgroundColor: t.surfaceElevated,
184
+ borderRadius: 4,
185
+ paddingHorizontal: 6,
186
+ paddingVertical: 1,
187
+ overflow: 'hidden',
188
+ alignSelf: 'flex-start',
189
+ marginTop: 4,
190
+ },
191
+ flagKeyActive: { color: t.accent, backgroundColor: t.accentDim },
192
+ flagDescription: {
193
+ fontFamily: MonospaceFont,
194
+ fontSize: 12,
195
+ color: t.textMuted,
196
+ marginTop: 4,
197
+ lineHeight: 18,
198
+ },
199
+ divider: { height: 1, backgroundColor: t.border, marginHorizontal: 4 },
200
+ })
@@ -1,6 +1,16 @@
1
- import React, { useRef } from 'react'
2
- import { Animated, Dimensions, PanResponder, StyleSheet, Text } from 'react-native'
3
- import { DarkTheme, Metrics, MonospaceFont } from '../constants'
1
+ import React, { useCallback, useRef, useState } from 'react'
2
+ import {
3
+ Animated,
4
+ Dimensions,
5
+ LayoutChangeEvent,
6
+ PanResponder,
7
+ SafeAreaView,
8
+ StyleSheet,
9
+ Text,
10
+ View,
11
+ } from 'react-native'
12
+ import { Metrics, MonospaceFont, TestIDs } from '../constants'
13
+ import { useBackstageTheme } from '../ThemeContext'
4
14
  import type { BackstageStyleOverrides } from '../types'
5
15
 
6
16
  // ─── Types ───────────────────────────────────────────────────────────────────
@@ -11,6 +21,8 @@ interface FloatingPillProps {
11
21
  onPress: () => void
12
22
  initialX?: number
13
23
  initialY?: number
24
+ pillWidth?: number
25
+ pillHeight?: number
14
26
  styles?: BackstageStyleOverrides
15
27
  }
16
28
 
@@ -19,12 +31,32 @@ interface FloatingPillProps {
19
31
  const PILL_WIDTH = Metrics.pillWidth
20
32
  const PILL_HEIGHT = Metrics.pillHeight
21
33
  const DRAG_THRESHOLD = 5
34
+ const INSET_PADDING = 8 // extra padding from safe area edges
22
35
 
23
- function clampPosition(x: number, y: number): { x: number; y: number } {
36
+ interface SafeAreaInsets {
37
+ top: number
38
+ bottom: number
39
+ left: number
40
+ right: number
41
+ }
42
+
43
+ function clampPosition(
44
+ x: number,
45
+ y: number,
46
+ insets: SafeAreaInsets,
47
+ pillW: number,
48
+ pillH: number,
49
+ ): { x: number; y: number } {
24
50
  const { width, height } = Dimensions.get('window')
25
51
  return {
26
- x: Math.max(0, Math.min(x, width - PILL_WIDTH)),
27
- y: Math.max(0, Math.min(y, height - PILL_HEIGHT)),
52
+ x: Math.max(
53
+ insets.left + INSET_PADDING,
54
+ Math.min(x, width - pillW - insets.right - INSET_PADDING),
55
+ ),
56
+ y: Math.max(
57
+ insets.top + INSET_PADDING,
58
+ Math.min(y, height - pillH - insets.bottom - INSET_PADDING),
59
+ ),
28
60
  }
29
61
  }
30
62
 
@@ -36,17 +68,55 @@ export const FloatingPill: React.FC<FloatingPillProps> = ({
36
68
  onPress,
37
69
  initialX,
38
70
  initialY,
71
+ pillWidth: propWidth,
72
+ pillHeight: propHeight,
39
73
  styles: propStyles,
40
74
  }) => {
75
+ const pillW = propWidth ?? PILL_WIDTH
76
+ const pillH = propHeight ?? PILL_HEIGHT
77
+
41
78
  const { width: screenW, height: screenH } = Dimensions.get('window')
42
- const defaultX = initialX ?? screenW - PILL_WIDTH - 16
43
- const defaultY = initialY ?? screenH - PILL_HEIGHT - 120
79
+ const defaultX = initialX ?? screenW - pillW - 16
80
+ const defaultY = initialY ?? screenH - pillH - 120
44
81
 
45
82
  const pan = useRef(new Animated.ValueXY({ x: defaultX, y: defaultY })).current
46
83
  const lastPosition = useRef({ x: defaultX, y: defaultY })
47
84
  const isDragging = useRef(false)
48
85
  const dragDistance = useRef(0)
49
86
 
87
+ // Safe area insets measured from invisible SafeAreaView
88
+ const [insets, setInsets] = useState<SafeAreaInsets>({
89
+ top: 0,
90
+ bottom: 0,
91
+ left: 0,
92
+ right: 0,
93
+ })
94
+
95
+ const handleSafeAreaLayout = useCallback(
96
+ (event: LayoutChangeEvent) => {
97
+ const { x, y, width, height } = event.nativeEvent.layout
98
+ const screen = Dimensions.get('window')
99
+ const newInsets: SafeAreaInsets = {
100
+ top: y,
101
+ left: x,
102
+ bottom: screen.height - y - height,
103
+ right: screen.width - x - width,
104
+ }
105
+ setInsets(newInsets)
106
+
107
+ // Re-clamp current position to new safe area
108
+ const clamped = clampPosition(lastPosition.current.x, lastPosition.current.y, newInsets, pillW, pillH)
109
+ if (clamped.x !== lastPosition.current.x || clamped.y !== lastPosition.current.y) {
110
+ lastPosition.current = clamped
111
+ pan.setValue(clamped)
112
+ }
113
+ },
114
+ [pan],
115
+ )
116
+
117
+ const insetsRef = useRef(insets)
118
+ insetsRef.current = insets
119
+
50
120
  const panResponder = useRef(
51
121
  PanResponder.create({
52
122
  onStartShouldSetPanResponder: () => true,
@@ -71,6 +141,8 @@ export const FloatingPill: React.FC<FloatingPillProps> = ({
71
141
  if (dragDistance.current > DRAG_THRESHOLD) {
72
142
  isDragging.current = true
73
143
  }
144
+
145
+ // Allow free dragging — bounce back happens on release
74
146
  Animated.event([null, { dx: pan.x, dy: pan.y }], {
75
147
  useNativeDriver: false,
76
148
  })(_, gestureState)
@@ -81,6 +153,9 @@ export const FloatingPill: React.FC<FloatingPillProps> = ({
81
153
  const newPos = clampPosition(
82
154
  lastPosition.current.x + gestureState.dx,
83
155
  lastPosition.current.y + gestureState.dy,
156
+ insetsRef.current,
157
+ pillW,
158
+ pillH,
84
159
  )
85
160
 
86
161
  lastPosition.current = newPos
@@ -100,37 +175,51 @@ export const FloatingPill: React.FC<FloatingPillProps> = ({
100
175
  }),
101
176
  ).current
102
177
 
103
- const backgroundColor = hasError ? DarkTheme.error : DarkTheme.accent
104
- const shadowColor = hasError ? DarkTheme.error : DarkTheme.accent
178
+ const theme = useBackstageTheme()
179
+
180
+ const backgroundColor = hasError ? theme.error : theme.accent
181
+ const shadowColor = hasError ? theme.error : theme.accent
105
182
 
106
183
  return (
107
- <Animated.View
108
- {...panResponder.panHandlers}
109
- style={[
110
- styles.pill,
111
- {
112
- backgroundColor,
113
- transform: pan.getTranslateTransform(),
114
- shadowColor,
115
- },
116
- propStyles?.pillStyle,
117
- ]}
118
- >
119
- <Text style={[styles.pillText, propStyles?.pillTextStyle]} numberOfLines={1}>
120
- {text}
121
- </Text>
122
- </Animated.View>
184
+ <>
185
+ {/* Invisible SafeAreaView to measure insets */}
186
+ <SafeAreaView style={componentStyles.measurer} pointerEvents="none">
187
+ <View style={componentStyles.measurerInner} onLayout={handleSafeAreaLayout} />
188
+ </SafeAreaView>
189
+
190
+ <Animated.View
191
+ testID={TestIDs.floatingPill}
192
+ {...panResponder.panHandlers}
193
+ style={[
194
+ componentStyles.pill,
195
+ {
196
+ minWidth: pillW,
197
+ height: pillH,
198
+ borderRadius: pillH / 2,
199
+ backgroundColor,
200
+ transform: pan.getTranslateTransform(),
201
+ shadowColor,
202
+ },
203
+ propStyles?.pillStyle,
204
+ ]}
205
+ >
206
+ <Text
207
+ testID={TestIDs.floatingPillText}
208
+ style={[componentStyles.pillText, propStyles?.pillTextStyle]}
209
+ numberOfLines={1}
210
+ >
211
+ {text}
212
+ </Text>
213
+ </Animated.View>
214
+ </>
123
215
  )
124
216
  }
125
217
 
126
218
  // ─── Styles ──────────────────────────────────────────────────────────────────
127
219
 
128
- const styles = StyleSheet.create({
220
+ const componentStyles = StyleSheet.create({
129
221
  pill: {
130
222
  position: 'absolute',
131
- minWidth: PILL_WIDTH,
132
- height: PILL_HEIGHT,
133
- borderRadius: PILL_HEIGHT / 2,
134
223
  paddingHorizontal: 14,
135
224
  alignItems: 'center',
136
225
  justifyContent: 'center',
@@ -147,4 +236,12 @@ const styles = StyleSheet.create({
147
236
  color: '#FFFFFF',
148
237
  letterSpacing: 0.5,
149
238
  },
239
+ measurer: {
240
+ ...StyleSheet.absoluteFillObject,
241
+ zIndex: -1,
242
+ opacity: 0,
243
+ },
244
+ measurerInner: {
245
+ flex: 1,
246
+ },
150
247
  })