@wireai/activation 0.1.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 (142) hide show
  1. package/AGENTS.md +73 -0
  2. package/CHANGELOG.md +572 -0
  3. package/INTEGRATION_PROMPT.md +55 -0
  4. package/LICENSE +21 -0
  5. package/README.md +923 -0
  6. package/dist/CenteredModal-C3qQBHsA.d.mts +38 -0
  7. package/dist/CenteredModal-Cdgns6--.d.ts +38 -0
  8. package/dist/coachmarks/index.d.mts +352 -0
  9. package/dist/coachmarks/index.d.ts +352 -0
  10. package/dist/coachmarks/index.js +944 -0
  11. package/dist/coachmarks/index.js.map +1 -0
  12. package/dist/coachmarks/index.mjs +918 -0
  13. package/dist/coachmarks/index.mjs.map +1 -0
  14. package/dist/decision-CFvGY6nP.d.ts +228 -0
  15. package/dist/decision-Cl8OFYzu.d.mts +228 -0
  16. package/dist/index.d.mts +1390 -0
  17. package/dist/index.d.ts +1390 -0
  18. package/dist/index.js +3260 -0
  19. package/dist/index.js.map +1 -0
  20. package/dist/index.mjs +3186 -0
  21. package/dist/index.mjs.map +1 -0
  22. package/dist/questionnaire/index.d.mts +249 -0
  23. package/dist/questionnaire/index.d.ts +249 -0
  24. package/dist/questionnaire/index.js +922 -0
  25. package/dist/questionnaire/index.js.map +1 -0
  26. package/dist/questionnaire/index.mjs +906 -0
  27. package/dist/questionnaire/index.mjs.map +1 -0
  28. package/dist/reviews/index.d.mts +213 -0
  29. package/dist/reviews/index.d.ts +213 -0
  30. package/dist/reviews/index.js +1001 -0
  31. package/dist/reviews/index.js.map +1 -0
  32. package/dist/reviews/index.mjs +971 -0
  33. package/dist/reviews/index.mjs.map +1 -0
  34. package/dist/showcase/index.d.mts +119 -0
  35. package/dist/showcase/index.d.ts +119 -0
  36. package/dist/showcase/index.js +660 -0
  37. package/dist/showcase/index.js.map +1 -0
  38. package/dist/showcase/index.mjs +651 -0
  39. package/dist/showcase/index.mjs.map +1 -0
  40. package/dist/types-BKfpdZzX.d.mts +84 -0
  41. package/dist/types-BKfpdZzX.d.ts +84 -0
  42. package/dist/types-CMuOexw0.d.mts +116 -0
  43. package/dist/types-CMuOexw0.d.ts +116 -0
  44. package/dist/types-GL_hQ0TN.d.mts +46 -0
  45. package/dist/types-GL_hQ0TN.d.ts +46 -0
  46. package/llms.txt +36 -0
  47. package/metro/index.d.ts +32 -0
  48. package/metro/index.js +83 -0
  49. package/package.json +171 -0
  50. package/src/DemoOnboarding.tsx +133 -0
  51. package/src/OnboardingFlow.tsx +504 -0
  52. package/src/WireOnboarding.tsx +248 -0
  53. package/src/analytics/analyticsEvent.ts +69 -0
  54. package/src/analytics/reportClientEvent.ts +119 -0
  55. package/src/analytics/sendPreview.ts +73 -0
  56. package/src/attribution/attribution.ts +46 -0
  57. package/src/cards/ChipSelectCard.tsx +265 -0
  58. package/src/cards/InterstitialCard.tsx +299 -0
  59. package/src/cards/NumberStepperCard.tsx +121 -0
  60. package/src/cards/SelectionCard.tsx +270 -0
  61. package/src/cards/StatusCard.tsx +170 -0
  62. package/src/cards/TextInputCard.tsx +147 -0
  63. package/src/cards/index.ts +23 -0
  64. package/src/coachmarks/CoachmarkOverlayHost.tsx +44 -0
  65. package/src/coachmarks/CoachmarkProvider.tsx +108 -0
  66. package/src/coachmarks/GestureHint.tsx +331 -0
  67. package/src/coachmarks/SpotlightOverlay.tsx +254 -0
  68. package/src/coachmarks/coachmarkAnchorRegistry.ts +70 -0
  69. package/src/coachmarks/coachmarkOverlayStore.ts +63 -0
  70. package/src/coachmarks/index.ts +62 -0
  71. package/src/coachmarks/runtime.ts +103 -0
  72. package/src/coachmarks/selectTourSteps.ts +45 -0
  73. package/src/coachmarks/types.ts +58 -0
  74. package/src/coachmarks/useCoachmarkAnchor.ts +49 -0
  75. package/src/coachmarks/useCoachmarkTour.ts +200 -0
  76. package/src/components/AnimatedSparkle.tsx +101 -0
  77. package/src/components/Button.tsx +129 -0
  78. package/src/components/CardHandoff.tsx +212 -0
  79. package/src/components/CardLayout.tsx +103 -0
  80. package/src/components/CenteredModal.tsx +184 -0
  81. package/src/components/CompletionView.tsx +186 -0
  82. package/src/components/DoneBlock.tsx +37 -0
  83. package/src/components/ErrorBlock.tsx +42 -0
  84. package/src/components/Illustration.tsx +36 -0
  85. package/src/components/LoadingBlock.tsx +182 -0
  86. package/src/components/LoadingScreen.tsx +35 -0
  87. package/src/components/OnboardingScaffold.tsx +105 -0
  88. package/src/components/StepProgress.tsx +85 -0
  89. package/src/components/loaderChrome.ts +28 -0
  90. package/src/config/onboardingFlag.ts +39 -0
  91. package/src/config/wireConfigFromEnv.ts +52 -0
  92. package/src/device/deviceContext.ts +158 -0
  93. package/src/features/WireFeaturesProvider.tsx +81 -0
  94. package/src/features/cache.ts +92 -0
  95. package/src/features/defaults.ts +67 -0
  96. package/src/features/fetchWireFeatures.ts +86 -0
  97. package/src/features/index.ts +39 -0
  98. package/src/features/types.ts +61 -0
  99. package/src/features/useWireFeatures.ts +57 -0
  100. package/src/identity/userIdentity.ts +100 -0
  101. package/src/illustrations/defaultIllustrations.tsx +99 -0
  102. package/src/index.ts +159 -0
  103. package/src/motion/centeredModalMotion.ts +45 -0
  104. package/src/motion/motionSpec.ts +133 -0
  105. package/src/motion/useReducedMotion.ts +43 -0
  106. package/src/questionnaire/QuestionnaireGate.tsx +235 -0
  107. package/src/questionnaire/decision.ts +63 -0
  108. package/src/questionnaire/index.ts +54 -0
  109. package/src/questionnaire/runtime.ts +20 -0
  110. package/src/questionnaire/transport.ts +83 -0
  111. package/src/questionnaire/types.ts +182 -0
  112. package/src/questionnaire/useQuestionnaireGate.ts +159 -0
  113. package/src/reviews/ReviewGate.tsx +316 -0
  114. package/src/reviews/ReviewModal.tsx +13 -0
  115. package/src/reviews/decision.ts +120 -0
  116. package/src/reviews/equality.ts +54 -0
  117. package/src/reviews/expo-store-review.d.ts +16 -0
  118. package/src/reviews/feedbackForm.ts +41 -0
  119. package/src/reviews/index.ts +78 -0
  120. package/src/reviews/presentation.ts +29 -0
  121. package/src/reviews/runtime.ts +45 -0
  122. package/src/reviews/storeReview.ts +62 -0
  123. package/src/reviews/transport.ts +89 -0
  124. package/src/reviews/types.ts +172 -0
  125. package/src/reviews/useReviewGate.ts +137 -0
  126. package/src/session/persistedSession.ts +143 -0
  127. package/src/session-analytics/index.ts +18 -0
  128. package/src/session-analytics/reportSessionStart.ts +144 -0
  129. package/src/session-analytics/useSessionStart.ts +114 -0
  130. package/src/showcase/FeatureShowcase.tsx +233 -0
  131. package/src/showcase/index.ts +19 -0
  132. package/src/showcase/selectShowcaseSlides.ts +45 -0
  133. package/src/showcase/showcaseColors.ts +91 -0
  134. package/src/showcase/types.ts +74 -0
  135. package/src/theme/ThemeContext.tsx +38 -0
  136. package/src/theme/defaultTheme.ts +70 -0
  137. package/src/theme/themeFromBrand.ts +93 -0
  138. package/src/theme/types.ts +88 -0
  139. package/src/theme/typography.ts +46 -0
  140. package/src/types.ts +215 -0
  141. package/src/utils/deriveAnswers.ts +43 -0
  142. package/src/utils/readProgress.ts +33 -0
@@ -0,0 +1,108 @@
1
+ import React, { useEffect } from "react";
2
+
3
+ import { useResolvedFeatures } from "../features/WireFeaturesProvider";
4
+ import type { WireFeatures, WireFeaturesConfig } from "../features/types";
5
+ import { CoachmarkOverlayHost } from "./CoachmarkOverlayHost";
6
+ import {
7
+ getCoachmarkStorage,
8
+ setCoachmarkStorage,
9
+ setCoachmarksEnabled,
10
+ setCoachmarkTesting,
11
+ } from "./runtime";
12
+ import type { CoachmarkStorage } from "./types";
13
+
14
+ export interface CoachmarkProviderProps {
15
+ /**
16
+ * SYNCHRONOUS gate storage (MMKV-friendly). Reads must not be async or a ring
17
+ * would flash before the "already seen" gate resolves. See CoachmarkStorage.
18
+ */
19
+ storage: CoachmarkStorage;
20
+ /**
21
+ * QA replay switch. When true, every seen-gate reads as unseen AND seen-writes
22
+ * are suppressed, so every tour + showcase replays on each mount. Flip ONE
23
+ * boolean to re-see the whole coachmark surface.
24
+ */
25
+ isTestingCoachmark?: boolean;
26
+ /** Ring + tooltip color for every tour. Defaults to the onboarding theme's `primary`. */
27
+ accentColor?: string;
28
+ /**
29
+ * Text/icon color on the accent surface (e.g. the tooltip's CTA label).
30
+ * Defaults to the onboarding theme's `onPrimary`.
31
+ */
32
+ accentTextColor?: string;
33
+ /**
34
+ * OPTIONAL feature-flag wiring. When `flags.coachmarks.enabled` is false, every tour + overlay
35
+ * request becomes a silent no-op (once-gates are NOT consumed, so re-enabling replays). Provide
36
+ * pre-resolved `features`, OR a `featuresConfig` (serverUrl + apiKey) to lazily fetch once, OR
37
+ * mount a `WireFeaturesProvider` above (context is read automatically). Omit all three and
38
+ * coachmarks stay on (fail-open) — zero behavior change for hosts that never adopt flags.
39
+ */
40
+ features?: WireFeatures;
41
+ /** Lazy-fetch config for the flags, used when `features` is absent and no provider is above. */
42
+ featuresConfig?: WireFeaturesConfig;
43
+ children: React.ReactNode;
44
+ }
45
+
46
+ /**
47
+ * Mount ONCE at the app root — wrap it around (or just inside) your
48
+ * NavigationContainer so the overlay host is a root-level sibling of the whole
49
+ * app tree. That placement is what lets a ring + blur paint ABOVE the bottom tab
50
+ * bar, which react-navigation renders over screen components.
51
+ *
52
+ * It feeds the sync storage + testing flag into the engine's module-level
53
+ * singletons so `useCoachmarkTour` / `useCoachmarkAnchor` work from anywhere in
54
+ * the tree without prop-drilling, then renders the overlay host as a sibling
55
+ * after `children` (the SpotlightOverlay self-positions absolute-fill at
56
+ * zIndex/elevation 9999).
57
+ *
58
+ * The singletons are set during render (not in an effect) so a child's mount
59
+ * effect — which runs BEFORE this parent's effects — already sees the storage
60
+ * when it reads its gate.
61
+ */
62
+ export const CoachmarkProvider: React.FC<CoachmarkProviderProps> = ({
63
+ storage,
64
+ isTestingCoachmark = false,
65
+ accentColor,
66
+ accentTextColor,
67
+ features,
68
+ featuresConfig,
69
+ children,
70
+ }) => {
71
+ // Resolve the coachmarks kill switch: explicit `features` → context → lazy fetch → all-on.
72
+ const flags = useResolvedFeatures({ flags: features, config: featuresConfig });
73
+ const coachmarksOn = flags.coachmarks.enabled;
74
+
75
+ // Apply during render so gates + the kill switch are readable before any child effect fires.
76
+ setCoachmarkStorage(storage);
77
+ setCoachmarkTesting(isTestingCoachmark);
78
+ setCoachmarksEnabled(coachmarksOn);
79
+
80
+ useEffect(() => {
81
+ setCoachmarkStorage(storage);
82
+ setCoachmarkTesting(isTestingCoachmark);
83
+ setCoachmarksEnabled(coachmarksOn);
84
+ return () => {
85
+ // Only clear if THIS provider's storage is still the live singleton — a
86
+ // newer provider that mounted before this one unmounts must not be wiped.
87
+ if (getCoachmarkStorage() === storage) {
88
+ setCoachmarkStorage(null);
89
+ }
90
+ // Restore the fail-open default so a torn-down provider never leaves coachmarks dark.
91
+ setCoachmarksEnabled(true);
92
+ };
93
+ }, [storage, isTestingCoachmark, coachmarksOn]);
94
+
95
+ return (
96
+ <>
97
+ {children}
98
+ <CoachmarkOverlayHost
99
+ accentColor={accentColor}
100
+ accentTextColor={accentTextColor}
101
+ />
102
+ </>
103
+ );
104
+ };
105
+
106
+ CoachmarkProvider.displayName = "CoachmarkProvider";
107
+
108
+ export default CoachmarkProvider;
@@ -0,0 +1,331 @@
1
+ import React, { useEffect, useMemo, type ReactNode } from "react";
2
+ import { StyleSheet, View } from "react-native";
3
+ import Animated, {
4
+ useAnimatedStyle,
5
+ useReducedMotion,
6
+ useSharedValue,
7
+ withDelay,
8
+ withRepeat,
9
+ withSequence,
10
+ withTiming,
11
+ } from "react-native-reanimated";
12
+
13
+ import type { GestureKind } from "./types";
14
+
15
+ /**
16
+ * Abstract gesture pictogram shown beside a coachmark tooltip. Pure reanimated —
17
+ * every animation runs on the UI thread (shared values + worklets), never
18
+ * setState, so it costs nothing on the JS thread. Honors Reduce Motion by
19
+ * dropping the motion loop and rendering the glyph static.
20
+ *
21
+ * NO drawn-hand anatomy. Swipes render the standard two-finger swipe glyph: two
22
+ * rounded vertical capsules (an abstract index + middle finger, slightly
23
+ * different heights) with a short, fading motion track trailing behind them along
24
+ * the swipe axis. The whole glyph is rotated per direction (up/down/left/right)
25
+ * and the capsules slide along the axis, fading, in a UI-thread loop. Pinch/zoom
26
+ * reuse the two-capsule abstraction spreading apart. Tap / double-tap render a
27
+ * minimal pulsing tap-target (ring + dot).
28
+ *
29
+ * Every glyph is a self-drawn primitive built from Views/borders (no vector-icon
30
+ * dependency, like AnimatedSparkle). Pass `renderHand` — the glyph slot — to
31
+ * substitute your own node while keeping the tuned motion. (The name is kept for
32
+ * backward compatibility; despite it, the default is a glyph, not a drawn hand.)
33
+ *
34
+ * NOTE: the coachmark path (SpotlightOverlay) and the showcase path
35
+ * (FeatureShowcase) both SKIP this component for `tap` / `double_tap` — those
36
+ * steps show only the highlight ring + tooltip, no glyph. The tap visual here
37
+ * exists for direct / standalone use.
38
+ */
39
+ export interface GestureHintProps {
40
+ type: GestureKind;
41
+ /** Overall glyph size in px. */
42
+ size?: number;
43
+ /** Glyph color. Defaults to white (reads on the dark blur backdrop). */
44
+ color?: string;
45
+ /**
46
+ * Provide a custom glyph node in place of the default self-drawn primitive; it
47
+ * inherits the tuned motion (slide+fade for swipes, pulse for tap). Kept as
48
+ * `renderHand` for backward compatibility — despite the name it is just the
49
+ * glyph slot, no longer a drawn hand.
50
+ */
51
+ renderHand?: (props: { size: number; color: string }) => ReactNode;
52
+ }
53
+
54
+ /** Travel distance for the directional swipe loop (local glyph axis). */
55
+ const TRAVEL = 26;
56
+ const SWIPE_MS = 700;
57
+ const TAP_MS = 320;
58
+ const SPREAD_MS = 620;
59
+
60
+ /**
61
+ * Per-direction rotation of the whole glyph. Canonical orientation is "up":
62
+ * capsules point up, travel up, motion track trailing below. Rotating the
63
+ * container reorients both the capsules and the track together, and rotates the
64
+ * local translate axis so the same upward loop reads correctly per direction.
65
+ */
66
+ const GLYPH_ROTATION: Record<string, string> = {
67
+ swipe_up: "0deg",
68
+ swipe_down: "180deg",
69
+ swipe_left: "-90deg",
70
+ swipe_right: "90deg",
71
+ };
72
+
73
+ /**
74
+ * The two-finger abstraction: two rounded vertical capsules side by side with
75
+ * slightly different heights (index taller than middle), aligned at the base —
76
+ * the standard swipe pictogram look. Self-drawn from Views only.
77
+ */
78
+ const FingerPair: React.FC<{ size: number; color: string }> = ({ size, color }) => {
79
+ const { row, index, middle } = useMemo(() => {
80
+ const w = size * 0.2;
81
+ const r = w / 2;
82
+ return {
83
+ row: { flexDirection: "row" as const, alignItems: "flex-end" as const, gap: size * 0.08 },
84
+ index: { width: w, height: size * 0.54, borderRadius: r, backgroundColor: color },
85
+ middle: { width: w, height: size * 0.44, borderRadius: r, backgroundColor: color },
86
+ };
87
+ }, [size, color]);
88
+
89
+ return (
90
+ <View style={row} pointerEvents="none">
91
+ <View style={index} />
92
+ <View style={middle} />
93
+ </View>
94
+ );
95
+ };
96
+
97
+ /**
98
+ * A short, tapering motion track that sits behind the capsules (below, in the
99
+ * canonical "up" orientation) — two slim, fading ticks that shrink and dim with
100
+ * distance. Subtle: it reads as the path the fingers just travelled, not a comet
101
+ * tail. Static (the capsules carry the motion via the slide loop).
102
+ */
103
+ const MotionTrack: React.FC<{ size: number; color: string }> = ({ size, color }) => {
104
+ const { wrap, tick0, tick1 } = useMemo(() => {
105
+ const h = Math.max(2, size * 0.05);
106
+ return {
107
+ wrap: { alignItems: "center" as const, marginTop: size * 0.1, gap: size * 0.06 },
108
+ tick0: {
109
+ width: size * 0.3,
110
+ height: h,
111
+ borderRadius: h / 2,
112
+ backgroundColor: color,
113
+ opacity: 0.4,
114
+ },
115
+ tick1: {
116
+ width: size * 0.18,
117
+ height: h,
118
+ borderRadius: h / 2,
119
+ backgroundColor: color,
120
+ opacity: 0.16,
121
+ },
122
+ };
123
+ }, [size, color]);
124
+
125
+ return (
126
+ <View style={wrap} pointerEvents="none">
127
+ <View style={tick0} />
128
+ <View style={tick1} />
129
+ </View>
130
+ );
131
+ };
132
+
133
+ const GestureHintComponent: React.FC<GestureHintProps> = ({
134
+ type,
135
+ size = 34,
136
+ color = "white",
137
+ renderHand,
138
+ }) => {
139
+ const translate = useSharedValue(0);
140
+ const opacity = useSharedValue(1);
141
+ const pulse = useSharedValue(1);
142
+ const spread = useSharedValue(0);
143
+ const reduced = useReducedMotion();
144
+
145
+ const isSwipe =
146
+ type === "swipe_up" ||
147
+ type === "swipe_down" ||
148
+ type === "swipe_left" ||
149
+ type === "swipe_right";
150
+ const isSpread = type === "pinch" || type === "zoom";
151
+ const isTap = type === "tap" || type === "double_tap";
152
+
153
+ useEffect(() => {
154
+ if (type === "none" || reduced) return undefined;
155
+
156
+ if (isTap) {
157
+ pulse.value = withRepeat(
158
+ withSequence(
159
+ withTiming(0.82, { duration: TAP_MS }),
160
+ withTiming(1, { duration: TAP_MS }),
161
+ ),
162
+ -1,
163
+ false,
164
+ );
165
+ opacity.value = withRepeat(
166
+ withSequence(
167
+ withTiming(0.55, { duration: TAP_MS }),
168
+ withTiming(1, { duration: TAP_MS }),
169
+ ),
170
+ -1,
171
+ false,
172
+ );
173
+ return undefined;
174
+ }
175
+
176
+ if (isSpread) {
177
+ // Two capsules easing apart and back — a minimal pinch/zoom abstraction.
178
+ spread.value = withRepeat(
179
+ withSequence(
180
+ withTiming(size * 0.26, { duration: SPREAD_MS }),
181
+ withTiming(0, { duration: SPREAD_MS }),
182
+ ),
183
+ -1,
184
+ false,
185
+ );
186
+ return undefined;
187
+ }
188
+
189
+ // Directional swipe: slide the capsules along the local "up" axis, fading
190
+ // out, then snap back and repeat. The container rotation (below) reorients
191
+ // this loop per direction, so the local motion is always the same.
192
+ translate.value = withRepeat(
193
+ withSequence(
194
+ withTiming(0, { duration: 0 }),
195
+ withTiming(-TRAVEL, { duration: SWIPE_MS }),
196
+ withDelay(140, withTiming(0, { duration: 0 })),
197
+ ),
198
+ -1,
199
+ false,
200
+ );
201
+ opacity.value = withRepeat(
202
+ withSequence(
203
+ withTiming(1, { duration: SWIPE_MS / 2 }),
204
+ withTiming(0, { duration: SWIPE_MS / 2 }),
205
+ withDelay(140, withTiming(1, { duration: 0 })),
206
+ ),
207
+ -1,
208
+ false,
209
+ );
210
+ return undefined;
211
+ }, [type, reduced, isTap, isSpread, translate, opacity, pulse, spread, size]);
212
+
213
+ const swipeAnimatedStyle = useAnimatedStyle(() => ({
214
+ opacity: opacity.value,
215
+ transform: [{ translateY: translate.value }],
216
+ }));
217
+ const tapAnimatedStyle = useAnimatedStyle(() => ({
218
+ opacity: opacity.value,
219
+ transform: [{ scale: pulse.value }],
220
+ }));
221
+ const spreadLeftStyle = useAnimatedStyle(() => ({
222
+ transform: [{ translateX: -spread.value }],
223
+ }));
224
+ const spreadRightStyle = useAnimatedStyle(() => ({
225
+ transform: [{ translateX: spread.value }],
226
+ }));
227
+
228
+ if (type === "none") return null;
229
+
230
+ const customGlyph = renderHand ? renderHand({ size, color }) : null;
231
+
232
+ if (isSwipe) {
233
+ return (
234
+ <View
235
+ style={{ transform: [{ rotate: GLYPH_ROTATION[type] ?? "0deg" }] }}
236
+ pointerEvents="none"
237
+ >
238
+ <View style={styles.swipeStack}>
239
+ <Animated.View style={swipeAnimatedStyle} pointerEvents="none">
240
+ {customGlyph ?? <FingerPair size={size} color={color} />}
241
+ </Animated.View>
242
+ <MotionTrack size={size} color={color} />
243
+ </View>
244
+ </View>
245
+ );
246
+ }
247
+
248
+ if (isSpread) {
249
+ const w = size * 0.2;
250
+ const capsule = {
251
+ width: w,
252
+ height: size * 0.5,
253
+ borderRadius: w / 2,
254
+ backgroundColor: color,
255
+ };
256
+ return (
257
+ <View style={[styles.spreadRow, { gap: size * 0.12 }]} pointerEvents="none">
258
+ <Animated.View style={spreadLeftStyle}>
259
+ <View style={capsule} />
260
+ </Animated.View>
261
+ <Animated.View style={spreadRightStyle}>
262
+ <View style={capsule} />
263
+ </Animated.View>
264
+ </View>
265
+ );
266
+ }
267
+
268
+ // tap / double_tap — a minimal pulsing tap-target (ring + center dot).
269
+ const ringSize = size * 0.86;
270
+ const ringBorder = Math.max(2, size * 0.08);
271
+ const dotSize = size * 0.32;
272
+ return (
273
+ <View style={[styles.tapWrap, { width: size, height: size }]} pointerEvents="none">
274
+ {customGlyph ? (
275
+ <Animated.View style={tapAnimatedStyle} pointerEvents="none">
276
+ {customGlyph}
277
+ </Animated.View>
278
+ ) : (
279
+ <>
280
+ <Animated.View
281
+ style={[
282
+ styles.tapRing,
283
+ {
284
+ width: ringSize,
285
+ height: ringSize,
286
+ borderRadius: ringSize / 2,
287
+ borderWidth: ringBorder,
288
+ borderColor: color,
289
+ },
290
+ tapAnimatedStyle,
291
+ ]}
292
+ pointerEvents="none"
293
+ />
294
+ <View
295
+ style={{
296
+ position: "absolute",
297
+ width: dotSize,
298
+ height: dotSize,
299
+ borderRadius: dotSize / 2,
300
+ backgroundColor: color,
301
+ }}
302
+ />
303
+ </>
304
+ )}
305
+ </View>
306
+ );
307
+ };
308
+
309
+ const styles = StyleSheet.create({
310
+ swipeStack: {
311
+ alignItems: "center",
312
+ justifyContent: "center",
313
+ },
314
+ spreadRow: {
315
+ flexDirection: "row",
316
+ alignItems: "center",
317
+ justifyContent: "center",
318
+ },
319
+ tapWrap: {
320
+ alignItems: "center",
321
+ justifyContent: "center",
322
+ },
323
+ tapRing: {
324
+ backgroundColor: "transparent",
325
+ },
326
+ });
327
+
328
+ export const GestureHint = React.memo(GestureHintComponent);
329
+ GestureHint.displayName = "GestureHint";
330
+
331
+ export default GestureHint;
@@ -0,0 +1,254 @@
1
+ import { BlurView } from "expo-blur";
2
+ import React, { useEffect, useMemo } from "react";
3
+ import {
4
+ Pressable,
5
+ StyleSheet,
6
+ Text,
7
+ useWindowDimensions,
8
+ View,
9
+ } from "react-native";
10
+ import Animated, {
11
+ FadeIn,
12
+ FadeOut,
13
+ useAnimatedProps,
14
+ useAnimatedStyle,
15
+ useReducedMotion,
16
+ useSharedValue,
17
+ withRepeat,
18
+ withSequence,
19
+ withTiming,
20
+ } from "react-native-reanimated";
21
+
22
+ import { useOnboardingTheme } from "../theme/ThemeContext";
23
+ import { GestureHint } from "./GestureHint";
24
+ import type { GestureKind, Placement, TargetRect } from "./types";
25
+
26
+ const AnimatedBlurView = Animated.createAnimatedComponent(BlurView);
27
+
28
+ const BLUR_INTENSITY = 26;
29
+ const FADE_MS = 280;
30
+ /** Breathing room between the highlighted element and the glowing ring. */
31
+ const RING_PADDING = 10;
32
+ /** Gap between the ring and the tooltip pill. */
33
+ const TOOLTIP_GAP = 14;
34
+ const SIDE_MARGIN = 20;
35
+ const PILL_MAX_WIDTH = 260;
36
+
37
+ export interface SpotlightOverlayProps {
38
+ message: string;
39
+ /** Window-space rect of the element to highlight. Null → centered hint (e.g. a scroll gesture). */
40
+ targetRect?: TargetRect | null;
41
+ gesture?: GestureKind;
42
+ placement?: Placement;
43
+ /** Tapping the highlighted target or the pill — advance / act. */
44
+ onEngage: () => void;
45
+ /** Tapping the dimmed backdrop — advance to the next step (ends the tour only on the last step). */
46
+ onDismiss: () => void;
47
+ /** Ring + tooltip color. Defaults to the onboarding theme's `primary`. */
48
+ accentColor?: string;
49
+ /** Tooltip label color. Defaults to the theme's `onPrimary`. */
50
+ accentTextColor?: string;
51
+ }
52
+
53
+ /**
54
+ * A single coachmark step: the whole screen is frosted with an animated blur,
55
+ * and a bright pulsing ring + tooltip point at one measured target. Mounted at
56
+ * the app root (above the tab bar) via CoachmarkOverlayHost.
57
+ *
58
+ * Performance: one BlurView (mounted only while a step is visible), the ring
59
+ * glow is a single reanimated view, and all animation runs on the UI thread.
60
+ * Reduce Motion drops the pulse.
61
+ */
62
+ const SpotlightOverlayComponent: React.FC<SpotlightOverlayProps> = ({
63
+ message,
64
+ targetRect,
65
+ gesture = "none",
66
+ placement = "bottom",
67
+ onEngage,
68
+ onDismiss,
69
+ accentColor,
70
+ accentTextColor,
71
+ }) => {
72
+ const { height: screenH } = useWindowDimensions();
73
+ const reduced = useReducedMotion();
74
+ const theme = useOnboardingTheme();
75
+
76
+ const accent = accentColor ?? theme.colors.primary;
77
+ const accentText = accentTextColor ?? theme.colors.onPrimary;
78
+
79
+ // Tap / double-tap steps show ONLY the ring + tooltip — no gesture glyph. The
80
+ // pulsing highlight ring already reads as "tap here", so an extra glyph is
81
+ // noise. Every app inherits this centrally, with no config change.
82
+ const showGesture =
83
+ gesture !== "none" && gesture !== "tap" && gesture !== "double_tap";
84
+
85
+ const intensity = useSharedValue(0);
86
+ const ringScale = useSharedValue(1);
87
+
88
+ useEffect(() => {
89
+ intensity.value = withTiming(BLUR_INTENSITY, { duration: FADE_MS });
90
+ }, [intensity]);
91
+
92
+ useEffect(() => {
93
+ if (reduced) return;
94
+ ringScale.value = withRepeat(
95
+ withSequence(
96
+ withTiming(1.06, { duration: 700 }),
97
+ withTiming(1, { duration: 700 }),
98
+ ),
99
+ -1,
100
+ true,
101
+ );
102
+ }, [reduced, ringScale]);
103
+
104
+ const blurAnimatedProps = useAnimatedProps(() => ({
105
+ intensity: intensity.value,
106
+ }));
107
+
108
+ const ringAnimatedStyle = useAnimatedStyle(() => ({
109
+ transform: [{ scale: ringScale.value }],
110
+ }));
111
+
112
+ // Geometry for the ring + tooltip, derived from the measured target.
113
+ const geometry = useMemo(() => {
114
+ if (!targetRect) return null;
115
+ const ringLeft = targetRect.x - RING_PADDING;
116
+ const ringTop = targetRect.y - RING_PADDING;
117
+ const ringW = targetRect.width + RING_PADDING * 2;
118
+ const ringH = targetRect.height + RING_PADDING * 2;
119
+ return {
120
+ ring: {
121
+ left: ringLeft,
122
+ top: ringTop,
123
+ width: ringW,
124
+ height: ringH,
125
+ borderRadius: Math.min(ringW, ringH) / 2,
126
+ },
127
+ // Tooltip above or below the ring depending on placement.
128
+ tooltip:
129
+ placement === "top"
130
+ ? { bottom: screenH - ringTop + TOOLTIP_GAP }
131
+ : { top: ringTop + ringH + TOOLTIP_GAP },
132
+ };
133
+ }, [targetRect, placement, screenH]);
134
+
135
+ return (
136
+ <Animated.View
137
+ style={[StyleSheet.absoluteFill, styles.root]}
138
+ entering={FadeIn.duration(FADE_MS)}
139
+ exiting={FadeOut.duration(FADE_MS)}
140
+ >
141
+ <AnimatedBlurView
142
+ tint="dark"
143
+ animatedProps={blurAnimatedProps}
144
+ style={StyleSheet.absoluteFill}
145
+ />
146
+
147
+ {/* Tap-the-backdrop to move on (dismisses the tour on the last step). */}
148
+ <Pressable
149
+ style={StyleSheet.absoluteFill}
150
+ onPress={onDismiss}
151
+ accessibilityRole="button"
152
+ accessibilityLabel="Next hint"
153
+ />
154
+
155
+ {/* Glowing ring over the highlighted target (tap = engage). */}
156
+ {geometry && (
157
+ <Animated.View
158
+ style={[
159
+ styles.ring,
160
+ geometry.ring,
161
+ { borderColor: accent, shadowColor: accent },
162
+ ringAnimatedStyle,
163
+ ]}
164
+ pointerEvents="box-none"
165
+ >
166
+ <Pressable
167
+ style={StyleSheet.absoluteFill}
168
+ onPress={onEngage}
169
+ accessibilityRole="button"
170
+ accessibilityLabel={message}
171
+ />
172
+ </Animated.View>
173
+ )}
174
+
175
+ {/* Tooltip pill: anchored to the ring, or centered for a target-less hint. */}
176
+ <View
177
+ style={[
178
+ styles.tooltipWrapper,
179
+ geometry
180
+ ? geometry.tooltip
181
+ : { top: screenH * 0.42, alignItems: "center" },
182
+ ]}
183
+ pointerEvents="box-none"
184
+ >
185
+ {showGesture && (
186
+ <View style={styles.gestureSlot} pointerEvents="none">
187
+ <GestureHint type={gesture} color={accent} />
188
+ </View>
189
+ )}
190
+ <Pressable
191
+ onPress={onEngage}
192
+ style={[styles.pill, { backgroundColor: accent }]}
193
+ accessibilityRole="button"
194
+ accessibilityLabel={message}
195
+ >
196
+ <Text numberOfLines={3} style={[styles.pillText, { color: accentText }]}>
197
+ {message}
198
+ </Text>
199
+ </Pressable>
200
+ </View>
201
+ </Animated.View>
202
+ );
203
+ };
204
+
205
+ const styles = StyleSheet.create({
206
+ // High elevation/zIndex so the whole overlay (incl. the frosted blur) sits
207
+ // above the bottom tab bar on Android, where sibling z-order follows
208
+ // elevation, not tree order.
209
+ root: {
210
+ zIndex: 9999,
211
+ elevation: 9999,
212
+ },
213
+ ring: {
214
+ position: "absolute",
215
+ borderWidth: 2.5,
216
+ backgroundColor: "transparent",
217
+ shadowOffset: { width: 0, height: 0 },
218
+ shadowOpacity: 0.9,
219
+ shadowRadius: 12,
220
+ elevation: 10,
221
+ },
222
+ tooltipWrapper: {
223
+ position: "absolute",
224
+ left: SIDE_MARGIN,
225
+ right: SIDE_MARGIN,
226
+ alignItems: "center",
227
+ gap: 10,
228
+ },
229
+ gestureSlot: {
230
+ alignItems: "center",
231
+ justifyContent: "center",
232
+ },
233
+ pill: {
234
+ maxWidth: PILL_MAX_WIDTH,
235
+ paddingVertical: 12,
236
+ paddingHorizontal: 18,
237
+ borderRadius: 18,
238
+ shadowColor: "#000",
239
+ shadowOffset: { width: 0, height: 2 },
240
+ shadowOpacity: 0.25,
241
+ shadowRadius: 6,
242
+ elevation: 6,
243
+ },
244
+ pillText: {
245
+ fontSize: 14,
246
+ fontWeight: "800",
247
+ textAlign: "center",
248
+ },
249
+ });
250
+
251
+ export const SpotlightOverlay = React.memo(SpotlightOverlayComponent);
252
+ SpotlightOverlay.displayName = "SpotlightOverlay";
253
+
254
+ export default SpotlightOverlay;