@tapthrough/expo 0.0.0-stage → 0.1.1

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 (135) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +105 -2
  3. package/app.plugin.d.ts +37 -0
  4. package/app.plugin.js +79 -0
  5. package/dist/Annotator.d.ts +14 -0
  6. package/dist/Annotator.d.ts.map +1 -0
  7. package/dist/Annotator.js +160 -0
  8. package/dist/Annotator.js.map +1 -0
  9. package/dist/Bubble.d.ts +27 -0
  10. package/dist/Bubble.d.ts.map +1 -0
  11. package/dist/Bubble.js +234 -0
  12. package/dist/Bubble.js.map +1 -0
  13. package/dist/Feedback.d.ts +14 -0
  14. package/dist/Feedback.d.ts.map +1 -0
  15. package/dist/Feedback.js +977 -0
  16. package/dist/Feedback.js.map +1 -0
  17. package/dist/Overlay.d.ts +13 -0
  18. package/dist/Overlay.d.ts.map +1 -0
  19. package/dist/Overlay.js +44 -0
  20. package/dist/Overlay.js.map +1 -0
  21. package/dist/Popover.d.ts +98 -0
  22. package/dist/Popover.d.ts.map +1 -0
  23. package/dist/Popover.js +531 -0
  24. package/dist/Popover.js.map +1 -0
  25. package/dist/Tapthrough.d.ts +27 -0
  26. package/dist/Tapthrough.d.ts.map +1 -0
  27. package/dist/Tapthrough.js +111 -0
  28. package/dist/Tapthrough.js.map +1 -0
  29. package/dist/core/annotations.d.ts +63 -0
  30. package/dist/core/annotations.d.ts.map +1 -0
  31. package/dist/core/annotations.js +76 -0
  32. package/dist/core/annotations.js.map +1 -0
  33. package/dist/core/bubble.d.ts +21 -0
  34. package/dist/core/bubble.d.ts.map +1 -0
  35. package/dist/core/bubble.js +25 -0
  36. package/dist/core/bubble.js.map +1 -0
  37. package/dist/core/controller.d.ts +46 -0
  38. package/dist/core/controller.d.ts.map +1 -0
  39. package/dist/core/controller.js +103 -0
  40. package/dist/core/controller.js.map +1 -0
  41. package/dist/core/diagnostics.d.ts +47 -0
  42. package/dist/core/diagnostics.d.ts.map +1 -0
  43. package/dist/core/diagnostics.js +101 -0
  44. package/dist/core/diagnostics.js.map +1 -0
  45. package/dist/core/feedback.d.ts +302 -0
  46. package/dist/core/feedback.d.ts.map +1 -0
  47. package/dist/core/feedback.js +345 -0
  48. package/dist/core/feedback.js.map +1 -0
  49. package/dist/core/flags.d.ts +13 -0
  50. package/dist/core/flags.d.ts.map +1 -0
  51. package/dist/core/flags.js +25 -0
  52. package/dist/core/flags.js.map +1 -0
  53. package/dist/core/ideas.d.ts +166 -0
  54. package/dist/core/ideas.d.ts.map +1 -0
  55. package/dist/core/ideas.js +159 -0
  56. package/dist/core/ideas.js.map +1 -0
  57. package/dist/core/launch.d.ts +43 -0
  58. package/dist/core/launch.d.ts.map +1 -0
  59. package/dist/core/launch.js +81 -0
  60. package/dist/core/launch.js.map +1 -0
  61. package/dist/core/reset.d.ts +17 -0
  62. package/dist/core/reset.d.ts.map +1 -0
  63. package/dist/core/reset.js +19 -0
  64. package/dist/core/reset.js.map +1 -0
  65. package/dist/core/review.d.ts +49 -0
  66. package/dist/core/review.d.ts.map +1 -0
  67. package/dist/core/review.js +92 -0
  68. package/dist/core/review.js.map +1 -0
  69. package/dist/core/url.d.ts +9 -0
  70. package/dist/core/url.d.ts.map +1 -0
  71. package/dist/core/url.js +23 -0
  72. package/dist/core/url.js.map +1 -0
  73. package/dist/hooks.d.ts +6 -0
  74. package/dist/hooks.d.ts.map +1 -0
  75. package/dist/hooks.js +13 -0
  76. package/dist/hooks.js.map +1 -0
  77. package/dist/index.d.ts +9 -0
  78. package/dist/index.d.ts.map +1 -0
  79. package/dist/index.js +7 -0
  80. package/dist/index.js.map +1 -0
  81. package/dist/native-intent.d.ts +18 -0
  82. package/dist/native-intent.d.ts.map +1 -0
  83. package/dist/native-intent.js +32 -0
  84. package/dist/native-intent.js.map +1 -0
  85. package/dist/runtime.d.ts +24 -0
  86. package/dist/runtime.d.ts.map +1 -0
  87. package/dist/runtime.js +17 -0
  88. package/dist/runtime.js.map +1 -0
  89. package/dist/screenshot.d.ts +7 -0
  90. package/dist/screenshot.d.ts.map +1 -0
  91. package/dist/screenshot.js +48 -0
  92. package/dist/screenshot.js.map +1 -0
  93. package/dist/supabase.d.ts +20 -0
  94. package/dist/supabase.d.ts.map +1 -0
  95. package/dist/supabase.js +18 -0
  96. package/dist/supabase.js.map +1 -0
  97. package/dist/ui/native.d.ts +34 -0
  98. package/dist/ui/native.d.ts.map +1 -0
  99. package/dist/ui/native.js +76 -0
  100. package/dist/ui/native.js.map +1 -0
  101. package/dist/ui/parts.d.ts +62 -0
  102. package/dist/ui/parts.d.ts.map +1 -0
  103. package/dist/ui/parts.js +110 -0
  104. package/dist/ui/parts.js.map +1 -0
  105. package/dist/ui/theme.d.ts +90 -0
  106. package/dist/ui/theme.d.ts.map +1 -0
  107. package/dist/ui/theme.js +61 -0
  108. package/dist/ui/theme.js.map +1 -0
  109. package/package.json +82 -3
  110. package/src/Annotator.tsx +279 -0
  111. package/src/Bubble.tsx +331 -0
  112. package/src/Feedback.tsx +1739 -0
  113. package/src/Overlay.tsx +61 -0
  114. package/src/Popover.tsx +1393 -0
  115. package/src/Tapthrough.tsx +171 -0
  116. package/src/core/annotations.ts +116 -0
  117. package/src/core/bubble.ts +45 -0
  118. package/src/core/controller.ts +119 -0
  119. package/src/core/diagnostics.ts +128 -0
  120. package/src/core/feedback.ts +589 -0
  121. package/src/core/flags.ts +31 -0
  122. package/src/core/ideas.ts +337 -0
  123. package/src/core/launch.ts +119 -0
  124. package/src/core/reset.ts +28 -0
  125. package/src/core/review.ts +148 -0
  126. package/src/core/url.ts +21 -0
  127. package/src/hooks.ts +15 -0
  128. package/src/index.ts +8 -0
  129. package/src/native-intent.ts +36 -0
  130. package/src/runtime.ts +30 -0
  131. package/src/screenshot.ts +64 -0
  132. package/src/supabase.ts +27 -0
  133. package/src/ui/native.ts +116 -0
  134. package/src/ui/parts.tsx +249 -0
  135. package/src/ui/theme.ts +75 -0
@@ -0,0 +1,61 @@
1
+ import { DynamicColorIOS, Platform, PlatformColor, useColorScheme, } from 'react-native';
2
+ /** Light and dark values; on iOS they back the system colors' names below. */
3
+ const TABLE = {
4
+ surface: { light: '#FFFFFF', dark: '#1C1C1E' },
5
+ label: { light: '#000000', dark: '#FFFFFF' },
6
+ secondary: { light: 'rgba(60,60,67,0.6)', dark: 'rgba(235,235,245,0.6)' },
7
+ tertiary: { light: 'rgba(60,60,67,0.3)', dark: 'rgba(235,235,245,0.3)' },
8
+ separator: { light: 'rgba(60,60,67,0.29)', dark: 'rgba(84,84,88,0.65)' },
9
+ fill: { light: 'rgba(118,118,128,0.12)', dark: 'rgba(118,118,128,0.24)' },
10
+ tint: { light: '#007AFF', dark: '#0A84FF' },
11
+ pass: { light: '#34C759', dark: '#30D158' },
12
+ fail: { light: '#FF3B30', dark: '#FF453A' },
13
+ tintFill: { light: 'rgba(0,122,255,0.12)', dark: 'rgba(10,132,255,0.22)' },
14
+ /** The selected segment of a segmented control. */
15
+ segment: { light: '#FFFFFF', dark: '#636366' },
16
+ };
17
+ const IOS_NAMES = {
18
+ surface: 'secondarySystemGroupedBackground',
19
+ label: 'label',
20
+ secondary: 'secondaryLabel',
21
+ tertiary: 'tertiaryLabel',
22
+ separator: 'separator',
23
+ fill: 'tertiarySystemFill',
24
+ tint: 'systemBlue',
25
+ pass: 'systemGreen',
26
+ fail: 'systemRed',
27
+ };
28
+ function iosColors() {
29
+ const colors = {};
30
+ for (const name of Object.keys(TABLE)) {
31
+ const system = IOS_NAMES[name];
32
+ // System colors follow dark mode and Increase Contrast by themselves. They
33
+ // can't take an alpha, so the tinted fill is a dynamic pair instead.
34
+ colors[name] = system ? PlatformColor(system) : DynamicColorIOS(TABLE[name]);
35
+ }
36
+ return colors;
37
+ }
38
+ const ios = Platform.OS === 'ios' ? iosColors() : null;
39
+ /** The SDK's colors: iOS system colors, or a light/dark table elsewhere. */
40
+ export function useColors() {
41
+ const scheme = useColorScheme();
42
+ if (ios)
43
+ return ios;
44
+ const mode = scheme === 'dark' ? 'dark' : 'light';
45
+ const colors = {};
46
+ for (const name of Object.keys(TABLE))
47
+ colors[name] = TABLE[name][mode];
48
+ return colors;
49
+ }
50
+ /** Apple's text styles at the default Dynamic Type size. */
51
+ export const text = {
52
+ title2: { fontSize: 22, lineHeight: 28, fontWeight: '700' },
53
+ headline: { fontSize: 17, lineHeight: 22, fontWeight: '600' },
54
+ body: { fontSize: 17, lineHeight: 22 },
55
+ subhead: { fontSize: 15, lineHeight: 20 },
56
+ footnote: { fontSize: 13, lineHeight: 18 },
57
+ caption: { fontSize: 12, lineHeight: 16 },
58
+ };
59
+ /** The iOS curve for things that enter or leave, as a spring. */
60
+ export const SPRING = { stiffness: 260, damping: 28, mass: 1 };
61
+ //# sourceMappingURL=theme.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"theme.js","sourceRoot":"","sources":["../../src/ui/theme.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,85 @@
1
1
  {
2
2
  "name": "@tapthrough/expo",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.1",
4
+ "description": "Tapthrough SDK for Expo: opens a pull request preview on the right screen, logged in with seeded data",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/tapthrough/tapthrough.git",
9
+ "directory": "packages/expo"
10
+ },
11
+ "homepage": "https://github.com/tapthrough/tapthrough/tree/main/packages/expo",
12
+ "sideEffects": false,
13
+ "files": [
14
+ "dist",
15
+ "src",
16
+ "!src/**/*.test.ts",
17
+ "app.plugin.js",
18
+ "app.plugin.d.ts"
19
+ ],
20
+ "exports": {
21
+ ".": {
22
+ "types": "./dist/index.d.ts",
23
+ "default": "./dist/index.js"
24
+ },
25
+ "./supabase": {
26
+ "types": "./dist/supabase.d.ts",
27
+ "default": "./dist/supabase.js"
28
+ },
29
+ "./app.plugin": {
30
+ "types": "./app.plugin.d.ts",
31
+ "default": "./app.plugin.js"
32
+ },
33
+ "./app.plugin.js": {
34
+ "types": "./app.plugin.d.ts",
35
+ "default": "./app.plugin.js"
36
+ },
37
+ "./package.json": "./package.json"
38
+ },
39
+ "dependencies": {
40
+ "@tapthrough/protocol": "^0.3.0"
41
+ },
42
+ "peerDependencies": {
43
+ "expo-constants": ">=17",
44
+ "expo-glass-effect": ">=0.1",
45
+ "expo-haptics": ">=14",
46
+ "expo-linking": ">=7",
47
+ "expo-router": ">=4",
48
+ "expo-symbols": ">=0.4",
49
+ "react": ">=18",
50
+ "react-native": ">=0.76",
51
+ "react-native-screens": ">=4",
52
+ "react-native-view-shot": ">=4"
53
+ },
54
+ "devDependencies": {
55
+ "@supabase/supabase-js": "2.118.0",
56
+ "@types/react": "19.3.0",
57
+ "expo-constants": "58.0.12",
58
+ "expo-linking": "58.0.13",
59
+ "expo-router": "58.0.23",
60
+ "react": "19.3.1",
61
+ "react-native": "0.88.0"
62
+ },
63
+ "peerDependenciesMeta": {
64
+ "expo-glass-effect": {
65
+ "optional": true
66
+ },
67
+ "expo-haptics": {
68
+ "optional": true
69
+ },
70
+ "expo-symbols": {
71
+ "optional": true
72
+ },
73
+ "react-native-screens": {
74
+ "optional": true
75
+ },
76
+ "react-native-view-shot": {
77
+ "optional": true
78
+ }
79
+ },
80
+ "scripts": {
81
+ "build": "tsc -p tsconfig.build.json",
82
+ "typecheck": "tsc --noEmit",
83
+ "test": "vitest run"
84
+ }
6
85
  }
@@ -0,0 +1,279 @@
1
+ import { useEffect, useMemo, useRef, useState } from 'react'
2
+ import {
3
+ Animated,
4
+ Image,
5
+ PanResponder,
6
+ Pressable,
7
+ StyleSheet,
8
+ Text,
9
+ useWindowDimensions,
10
+ View,
11
+ } from 'react-native'
12
+ import {
13
+ type Annotation,
14
+ type AnnotationStroke,
15
+ addStrokePoint,
16
+ boxFromDrag,
17
+ finishStroke,
18
+ MAX_ANNOTATIONS,
19
+ strokeSegments,
20
+ } from './core/annotations.js'
21
+ import { useBackPress } from './Overlay.js'
22
+ import type { Screenshot } from './screenshot.js'
23
+
24
+ type Mode = 'draw' | 'highlight' | 'blur'
25
+
26
+ const MODES: { mode: Mode; title: string; hint: string }[] = [
27
+ { mode: 'draw', title: 'Draw', hint: 'Draw on the screenshot to circle or point at something.' },
28
+ { mode: 'highlight', title: 'Box', hint: 'Drag on the screenshot to draw a box.' },
29
+ { mode: 'blur', title: 'Blur', hint: 'Drag over anything private to blur it.' },
30
+ ]
31
+
32
+ /**
33
+ * Full-screen markup for a report's screenshot: Draw to circle or point at
34
+ * something freehand, Box to outline it, Blur to hide personal data. The
35
+ * server applies the marks before storing the image.
36
+ */
37
+ export function Annotator({
38
+ screenshot,
39
+ initial,
40
+ onDone,
41
+ onCancel,
42
+ }: {
43
+ screenshot: Screenshot
44
+ initial: Annotation[]
45
+ onDone: (marks: Annotation[]) => void
46
+ onCancel: () => void
47
+ }) {
48
+ const window = useWindowDimensions()
49
+ const [boxes, setBoxes] = useState(initial)
50
+ const [kind, setKind] = useState<Mode>('draw')
51
+ const [draft, setDraft] = useState<Annotation | null>(null)
52
+ const [aspect, setAspect] = useState(window.width / window.height)
53
+ const uri = `data:${screenshot.contentType};base64,${screenshot.base64}`
54
+ const shown = useRef(new Animated.Value(0)).current
55
+ useBackPress(onCancel)
56
+ useEffect(() => {
57
+ Animated.timing(shown, { toValue: 1, duration: 180, useNativeDriver: true }).start()
58
+ }, [shown])
59
+
60
+ // The image is shown at the screenshot's aspect ratio, so fractions map 1:1.
61
+ const maxWidth = window.width - 32
62
+ const maxHeight = window.height - 200
63
+ const width = Math.min(maxWidth, maxHeight * aspect)
64
+ const height = width / aspect
65
+
66
+ const state = useRef({
67
+ start: { x: 0, y: 0 },
68
+ draft: null as Annotation | null,
69
+ points: [] as [number, number][],
70
+ kind,
71
+ boxes,
72
+ })
73
+ state.current.kind = kind
74
+ state.current.boxes = boxes
75
+
76
+ const responder = useMemo(
77
+ () =>
78
+ PanResponder.create({
79
+ onStartShouldSetPanResponder: () => true,
80
+ onMoveShouldSetPanResponder: () => true,
81
+ onPanResponderGrant: (event) => {
82
+ const start = { x: event.nativeEvent.locationX, y: event.nativeEvent.locationY }
83
+ state.current.start = start
84
+ state.current.draft = null
85
+ state.current.points =
86
+ state.current.kind === 'draw' ? addStrokePoint([], start, { width, height }) : []
87
+ },
88
+ onPanResponderMove: (_event, gesture) => {
89
+ const { start, kind: mode } = state.current
90
+ const at = { x: start.x + gesture.dx, y: start.y + gesture.dy }
91
+ if (mode === 'draw') {
92
+ const points = addStrokePoint(state.current.points, at, { width, height })
93
+ if (points === state.current.points) return
94
+ state.current.points = points
95
+ const stroke: AnnotationStroke = { kind: 'draw', points }
96
+ state.current.draft = stroke
97
+ setDraft(stroke)
98
+ return
99
+ }
100
+ const next = boxFromDrag(mode, start, at, { width, height })
101
+ state.current.draft = next
102
+ setDraft(next)
103
+ },
104
+ onPanResponderRelease: () => {
105
+ const { draft: current } = state.current
106
+ const finished = current?.kind === 'draw' ? finishStroke(current.points) : current
107
+ if (finished && state.current.boxes.length < MAX_ANNOTATIONS) {
108
+ setBoxes([...state.current.boxes, finished])
109
+ }
110
+ state.current.draft = null
111
+ setDraft(null)
112
+ },
113
+ }),
114
+ [width, height],
115
+ )
116
+
117
+ return (
118
+ <Animated.View style={[StyleSheet.absoluteFill, { opacity: shown }]} accessibilityViewIsModal>
119
+ <View style={styles.screen}>
120
+ <View style={styles.toolbar}>
121
+ <Pressable onPress={onCancel} accessibilityRole="button" hitSlop={8}>
122
+ <Text style={styles.toolbarText}>Cancel</Text>
123
+ </Pressable>
124
+ <View style={styles.modes}>
125
+ {MODES.map(({ mode, title }) => (
126
+ <Pressable
127
+ key={mode}
128
+ onPress={() => setKind(mode)}
129
+ style={[styles.mode, kind === mode && styles.modeActive]}
130
+ accessibilityRole="radio"
131
+ accessibilityState={{ selected: kind === mode }}
132
+ >
133
+ <Text style={[styles.modeText, kind === mode && styles.modeTextActive]}>
134
+ {title}
135
+ </Text>
136
+ </Pressable>
137
+ ))}
138
+ </View>
139
+ <Pressable onPress={() => onDone(boxes)} accessibilityRole="button" hitSlop={8}>
140
+ <Text style={[styles.toolbarText, styles.done]}>Done</Text>
141
+ </Pressable>
142
+ </View>
143
+
144
+ <View style={{ width, height }} {...responder.panHandlers}>
145
+ <Image
146
+ source={{ uri }}
147
+ style={{ width, height }}
148
+ onLoad={(event) => {
149
+ const { width: w, height: h } = event.nativeEvent.source
150
+ if (w && h) setAspect(w / h)
151
+ }}
152
+ accessibilityLabel="Screenshot to mark up"
153
+ />
154
+ {[...boxes, ...(draft ? [draft] : [])].map((box, index) =>
155
+ box.kind === 'draw' ? (
156
+ // biome-ignore lint/suspicious/noArrayIndexKey: marks have no identity beyond their order
157
+ <Stroke key={index} points={box.points} width={width} height={height} />
158
+ ) : (
159
+ <View
160
+ // biome-ignore lint/suspicious/noArrayIndexKey: boxes have no identity beyond their order
161
+ key={index}
162
+ pointerEvents="none"
163
+ style={[
164
+ styles.box,
165
+ box.kind === 'blur' ? styles.blurBox : styles.highlightBox,
166
+ {
167
+ left: box.x * width,
168
+ top: box.y * height,
169
+ width: box.w * width,
170
+ height: box.h * height,
171
+ },
172
+ ]}
173
+ >
174
+ {box.kind === 'blur' ? <Text style={styles.blurLabel}>Blurred</Text> : null}
175
+ </View>
176
+ ),
177
+ )}
178
+ </View>
179
+
180
+ <View style={styles.footer}>
181
+ <Text style={styles.hint}>
182
+ {boxes.length === 0
183
+ ? MODES.find((m) => m.mode === kind)?.hint
184
+ : boxes.length >= MAX_ANNOTATIONS
185
+ ? `${MAX_ANNOTATIONS} marks is the most. Undo one to add another.`
186
+ : `${boxes.length} mark${boxes.length === 1 ? '' : 's'}`}
187
+ </Text>
188
+ {boxes.length > 0 ? (
189
+ <Pressable
190
+ onPress={() => setBoxes(boxes.slice(0, -1))}
191
+ accessibilityRole="button"
192
+ hitSlop={8}
193
+ >
194
+ <Text style={styles.toolbarText}>Undo</Text>
195
+ </Pressable>
196
+ ) : null}
197
+ </View>
198
+ </View>
199
+ </Animated.View>
200
+ )
201
+ }
202
+
203
+ const STROKE_WIDTH = 4
204
+
205
+ /** A freehand mark, drawn as short rotated bars so it needs no SVG library. */
206
+ function Stroke({
207
+ points,
208
+ width,
209
+ height,
210
+ }: {
211
+ points: [number, number][]
212
+ width: number
213
+ height: number
214
+ }) {
215
+ return (
216
+ <>
217
+ {strokeSegments(points, { width, height }).map((segment, i) => (
218
+ <View
219
+ // biome-ignore lint/suspicious/noArrayIndexKey: segments are positional
220
+ key={i}
221
+ pointerEvents="none"
222
+ style={[
223
+ styles.segment,
224
+ {
225
+ left: segment.left,
226
+ top: segment.top - STROKE_WIDTH / 2,
227
+ // A little longer than the gap, so joints don't show.
228
+ width: segment.length + STROKE_WIDTH / 2,
229
+ transform: [{ rotate: `${segment.angle}rad` }],
230
+ },
231
+ ]}
232
+ />
233
+ ))}
234
+ </>
235
+ )
236
+ }
237
+
238
+ const styles = StyleSheet.create({
239
+ segment: {
240
+ position: 'absolute',
241
+ height: STROKE_WIDTH,
242
+ borderRadius: STROKE_WIDTH / 2,
243
+ backgroundColor: '#e5484d',
244
+ },
245
+ screen: {
246
+ flex: 1,
247
+ alignItems: 'center',
248
+ justifyContent: 'center',
249
+ gap: 16,
250
+ backgroundColor: '#111114',
251
+ paddingVertical: 48,
252
+ },
253
+ toolbar: {
254
+ width: '100%',
255
+ paddingHorizontal: 16,
256
+ flexDirection: 'row',
257
+ alignItems: 'center',
258
+ justifyContent: 'space-between',
259
+ },
260
+ toolbarText: { color: '#ffffff', fontSize: 16 },
261
+ done: { fontWeight: '700', color: '#8ab4f8' },
262
+ modes: { flexDirection: 'row', borderRadius: 999, backgroundColor: '#2a2b31', padding: 3 },
263
+ mode: { paddingVertical: 6, paddingHorizontal: 14, borderRadius: 999 },
264
+ modeActive: { backgroundColor: '#ffffff' },
265
+ modeText: { color: '#d4d4d8', fontSize: 14, fontWeight: '600' },
266
+ modeTextActive: { color: '#111114' },
267
+ box: { position: 'absolute', borderRadius: 6, alignItems: 'center', justifyContent: 'center' },
268
+ highlightBox: { borderWidth: 3, borderColor: '#e5484d' },
269
+ blurBox: { backgroundColor: 'rgba(40, 40, 46, 0.85)' },
270
+ blurLabel: { color: '#ffffff', fontSize: 12, fontWeight: '600' },
271
+ footer: {
272
+ width: '100%',
273
+ paddingHorizontal: 16,
274
+ flexDirection: 'row',
275
+ alignItems: 'center',
276
+ justifyContent: 'space-between',
277
+ },
278
+ hint: { color: '#a1a1aa', fontSize: 14 },
279
+ })