@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,184 @@
1
+ /**
2
+ * CenteredModal - the content-agnostic centered-popup shell.
3
+ *
4
+ * Renders its children as an alert-style card in the MIDDLE of the screen, over a dimmed
5
+ * backdrop, with a spring/fade entrance. It knows nothing about what it presents: the reviews
6
+ * gate uses it (via the `ReviewModal` back-compat re-export) and the questionnaire module reuses
7
+ * the same shell, neither depends on the other.
8
+ *
9
+ * Adapted from the mobile-launcher boilerplate's `animated-modal` (transparent RN Modal +
10
+ * centered container + backdrop-tap dismiss + content stop-propagation + springy zoom-in), but
11
+ * reimplemented on RN's built-in `Animated` (native driver) so the shell stays dependency-free:
12
+ * the boilerplate uses react-native-reanimated, which the kit does not.
13
+ *
14
+ * Dismissal (backdrop tap / Android back / the imperative `close()` handle) animates the card
15
+ * out, then fires `onDismiss`. It never auto-closes on resolve: the host keeps the card showing
16
+ * whatever payoff it wants (a thank-you, a summary) until the user taps the backdrop to close,
17
+ * exactly like CompletionView.
18
+ */
19
+ import React, {
20
+ forwardRef,
21
+ useCallback,
22
+ useEffect,
23
+ useImperativeHandle,
24
+ useRef,
25
+ useState,
26
+ } from "react";
27
+ import { Animated, BackHandler, Easing, Modal, Pressable, StyleSheet } from "react-native";
28
+ import { SafeAreaView } from "react-native-safe-area-context";
29
+
30
+ import type { OnboardingTheme } from "../theme/types";
31
+ import { REDUCED_FADE_MS } from "../motion/motionSpec";
32
+ import { useReducedMotion } from "../motion/useReducedMotion";
33
+ import { CENTERED_MODAL_ANIM } from "../motion/centeredModalMotion";
34
+
35
+ /** Imperative handle so an in-card "Not now"/"Skip" link can trigger the exit animation. */
36
+ export type CenteredModalHandle = { close: () => void };
37
+
38
+ export type CenteredModalProps = {
39
+ /** The (already merged) active theme - card surface/radius/spacing come from here. */
40
+ theme: OnboardingTheme;
41
+ /** Whether a backdrop tap / back press dismisses. Default true (never a dead end). */
42
+ dismissible?: boolean;
43
+ /** Fired once, AFTER the exit animation, on any dismiss. Wire to the host's resolve. */
44
+ onDismiss: () => void;
45
+ children: React.ReactNode;
46
+ };
47
+
48
+ const _CenteredModal = forwardRef<CenteredModalHandle, CenteredModalProps>(
49
+ ({ theme, dismissible = true, onDismiss, children }, ref) => {
50
+ const progress = useRef(new Animated.Value(0)).current;
51
+ const [open, setOpen] = useState(true);
52
+ const closingRef = useRef(false);
53
+ const reduced = useReducedMotion();
54
+
55
+ // Entrance: spring the card in (scale + fade); the backdrop fades up in lock-step.
56
+ // Reduce motion: a short fade to the settled card, no spring/scale.
57
+ useEffect(() => {
58
+ const anim = reduced
59
+ ? Animated.timing(progress, {
60
+ toValue: 1,
61
+ duration: REDUCED_FADE_MS,
62
+ useNativeDriver: true,
63
+ })
64
+ : Animated.spring(progress, {
65
+ toValue: 1,
66
+ useNativeDriver: true,
67
+ friction: CENTERED_MODAL_ANIM.enterSpring.friction,
68
+ tension: CENTERED_MODAL_ANIM.enterSpring.tension,
69
+ });
70
+ anim.start();
71
+ return () => anim.stop();
72
+ }, [progress, reduced]);
73
+
74
+ const close = useCallback(() => {
75
+ if (closingRef.current) return;
76
+ closingRef.current = true;
77
+ Animated.timing(progress, {
78
+ toValue: 0,
79
+ duration: reduced ? REDUCED_FADE_MS : CENTERED_MODAL_ANIM.exitMs,
80
+ easing: Easing.in(Easing.cubic),
81
+ useNativeDriver: true,
82
+ }).start(() => {
83
+ setOpen(false);
84
+ onDismiss();
85
+ });
86
+ }, [progress, onDismiss, reduced]);
87
+
88
+ useImperativeHandle(ref, () => ({ close }), [close]);
89
+
90
+ // Android hardware back -> dismiss (when allowed). Always consume so it never pops a route.
91
+ useEffect(() => {
92
+ const sub = BackHandler.addEventListener("hardwareBackPress", () => {
93
+ if (dismissible) close();
94
+ return true;
95
+ });
96
+ return () => sub.remove();
97
+ }, [dismissible, close]);
98
+
99
+ const onBackdrop = useCallback(() => {
100
+ if (dismissible) close();
101
+ }, [dismissible, close]);
102
+
103
+ const backdropOpacity = progress.interpolate({
104
+ inputRange: [0, 1],
105
+ outputRange: [0, CENTERED_MODAL_ANIM.backdropOpacity],
106
+ });
107
+ // Reduce motion: the card fades in already settled (no zoom).
108
+ const cardScale = reduced
109
+ ? 1
110
+ : progress.interpolate({
111
+ inputRange: [0, 1],
112
+ outputRange: [CENTERED_MODAL_ANIM.scaleFrom, 1],
113
+ });
114
+
115
+ return (
116
+ <Modal
117
+ visible={open}
118
+ transparent
119
+ animationType="none"
120
+ statusBarTranslucent
121
+ onRequestClose={dismissible ? close : undefined}
122
+ >
123
+ {/* Dimmed backdrop (fades with the entrance). */}
124
+ <Animated.View
125
+ style={[styles.backdrop, { opacity: backdropOpacity }]}
126
+ pointerEvents="none"
127
+ />
128
+
129
+ {/* Full-screen tap target: a tap outside the card dismisses. */}
130
+ <Pressable
131
+ style={styles.fill}
132
+ onPress={onBackdrop}
133
+ accessibilityRole="button"
134
+ accessibilityLabel="Dismiss"
135
+ >
136
+ <SafeAreaView style={styles.center} pointerEvents="box-none">
137
+ {/* Inner Pressable swallows taps so the card itself never dismisses. */}
138
+ <Pressable onPress={() => {}} style={styles.cardWrap}>
139
+ <Animated.View
140
+ accessibilityViewIsModal
141
+ accessibilityRole="alert"
142
+ style={[
143
+ styles.card,
144
+ {
145
+ backgroundColor: theme.colors.surface,
146
+ borderRadius: theme.radius.lg,
147
+ padding: theme.spacing.lg,
148
+ gap: theme.spacing.md,
149
+ opacity: progress,
150
+ transform: [{ scale: cardScale }],
151
+ },
152
+ ]}
153
+ >
154
+ {children}
155
+ </Animated.View>
156
+ </Pressable>
157
+ </SafeAreaView>
158
+ </Pressable>
159
+ </Modal>
160
+ );
161
+ },
162
+ );
163
+
164
+ _CenteredModal.displayName = "CenteredModal";
165
+
166
+ export const CenteredModal = React.memo(_CenteredModal);
167
+
168
+ const styles = StyleSheet.create({
169
+ fill: { flex: 1 },
170
+ backdrop: { position: "absolute", top: 0, left: 0, right: 0, bottom: 0, backgroundColor: "#000" },
171
+ center: { flex: 1, alignItems: "center", justifyContent: "center", padding: 24 },
172
+ cardWrap: { width: "100%", maxWidth: 420 },
173
+ card: {
174
+ width: "100%",
175
+ // Soft elevation so the popup reads as lifted above the app in both themes.
176
+ shadowColor: "#000",
177
+ shadowOffset: { width: 0, height: 8 },
178
+ shadowOpacity: 0.28,
179
+ shadowRadius: 24,
180
+ elevation: 12,
181
+ },
182
+ });
183
+
184
+ export default CenteredModal;
@@ -0,0 +1,186 @@
1
+ /**
2
+ * CompletionView — the terminal "you're all set" screen, shown when the flow
3
+ * reaches its final StatusCard. It surfaces the backend's recap (what the app
4
+ * will do for the user, derived from their answers) and a single clear CTA.
5
+ *
6
+ * WHY a dedicated screen (not an instant hand-off): the backend spends the last
7
+ * turn summarizing the user's tastes and what their experience will now look
8
+ * like. The user should actually SEE that payoff and tap to continue — so
9
+ * `onContinue` fires only on the button press, never automatically.
10
+ *
11
+ * Motion (design-refs/EXTRACTED-SPEC.md rows 14-16, the peak-end beat): the
12
+ * badge pops in with a spring (scale 0.4→1, slight overshoot), ONE soft accent
13
+ * burst radiates once behind it (scale 0.4→1.5, opacity 0.5→0, 500ms) —
14
+ * celebratory without confetti — and the copy fades up 80ms apart. Everything
15
+ * settles well under a second. Reduce motion: final frame, no burst.
16
+ */
17
+ import React, { useCallback, useEffect, useRef, useState } from "react";
18
+ import { Animated, Easing, StyleSheet, Text, View } from "react-native";
19
+ import { useOnboardingTheme } from "../theme/ThemeContext";
20
+ import { bodyStyle, headingStyle } from "../theme/typography";
21
+ import { Button } from "./Button";
22
+ import { CardLayout } from "./CardLayout";
23
+ import {
24
+ STATUS_BURST_MS,
25
+ STATUS_BURST_OPACITY_FROM,
26
+ STATUS_BURST_SCALE_FROM,
27
+ STATUS_BURST_SCALE_TO,
28
+ STATUS_COPY_MS,
29
+ STATUS_POP_SCALE_FROM,
30
+ STATUS_POP_SPRING,
31
+ WIRE_BEZIER,
32
+ scaledMs,
33
+ statusCopyDelay,
34
+ } from "../motion/motionSpec";
35
+ import { useReducedMotion } from "../motion/useReducedMotion";
36
+
37
+ const easeWire = Easing.bezier(...WIRE_BEZIER);
38
+ const BADGE_SIZE = 64;
39
+ const BURST_SIZE = 104;
40
+
41
+ export type CompletionViewProps = {
42
+ title: string;
43
+ message?: string;
44
+ ctaLabel?: string;
45
+ onContinue: () => void;
46
+ };
47
+
48
+ const _CompletionView: React.FC<CompletionViewProps> = ({
49
+ title,
50
+ message,
51
+ ctaLabel = "Let's go",
52
+ onContinue,
53
+ }) => {
54
+ const t = useOnboardingTheme();
55
+ const reduced = useReducedMotion();
56
+ const [submitted, setSubmitted] = useState(false);
57
+
58
+ const pop = useRef(new Animated.Value(reduced ? 1 : 0)).current;
59
+ const burst = useRef(new Animated.Value(reduced ? 1 : 0)).current;
60
+ const titleT = useRef(new Animated.Value(reduced ? 1 : 0)).current;
61
+ const messageT = useRef(new Animated.Value(reduced ? 1 : 0)).current;
62
+
63
+ useEffect(() => {
64
+ if (reduced) {
65
+ // Final frame: badge + copy settled, burst already faded out.
66
+ pop.setValue(1);
67
+ burst.setValue(1);
68
+ titleT.setValue(1);
69
+ messageT.setValue(1);
70
+ return;
71
+ }
72
+ const rise = (value: Animated.Value, delay: number) =>
73
+ Animated.timing(value, {
74
+ toValue: 1,
75
+ duration: scaledMs(STATUS_COPY_MS),
76
+ delay,
77
+ easing: easeWire,
78
+ useNativeDriver: true,
79
+ });
80
+ const anims = [
81
+ Animated.spring(pop, {
82
+ toValue: 1,
83
+ friction: STATUS_POP_SPRING.friction,
84
+ tension: STATUS_POP_SPRING.tension,
85
+ useNativeDriver: true,
86
+ }),
87
+ // One-shot celebratory burst: fires once, never loops.
88
+ Animated.timing(burst, {
89
+ toValue: 1,
90
+ duration: scaledMs(STATUS_BURST_MS),
91
+ easing: Easing.out(Easing.ease),
92
+ useNativeDriver: true,
93
+ }),
94
+ rise(titleT, scaledMs(statusCopyDelay(0))),
95
+ rise(messageT, scaledMs(statusCopyDelay(1))),
96
+ ];
97
+ anims.forEach((a) => a.start());
98
+ return () => anims.forEach((a) => a.stop());
99
+ }, [reduced, pop, burst, titleT, messageT]);
100
+
101
+ const handlePress = useCallback(() => {
102
+ if (submitted) return;
103
+ setSubmitted(true);
104
+ onContinue();
105
+ }, [submitted, onContinue]);
106
+
107
+ const scale = pop.interpolate({ inputRange: [0, 1], outputRange: [STATUS_POP_SCALE_FROM, 1] });
108
+ const burstScale = burst.interpolate({
109
+ inputRange: [0, 1],
110
+ outputRange: [STATUS_BURST_SCALE_FROM, STATUS_BURST_SCALE_TO],
111
+ });
112
+ const burstOpacity = burst.interpolate({
113
+ inputRange: [0, 1],
114
+ outputRange: [STATUS_BURST_OPACITY_FROM, 0],
115
+ });
116
+ const riseStyle = (value: Animated.Value) => ({
117
+ opacity: value,
118
+ transform: [{ translateY: value.interpolate({ inputRange: [0, 1], outputRange: [10, 0] }) }],
119
+ });
120
+
121
+ return (
122
+ <CardLayout
123
+ align="center"
124
+ footer={<Button title={ctaLabel} onPress={handlePress} variant="primary" disabled={submitted} />}
125
+ >
126
+ <View style={[styles.center, { gap: t.spacing.md }]}>
127
+ <View style={styles.badgeStage}>
128
+ {/* Soft accent burst behind the badge: primarySoft keeps the tint calm. */}
129
+ <Animated.View
130
+ pointerEvents="none"
131
+ style={[
132
+ styles.burst,
133
+ {
134
+ backgroundColor: t.colors.primarySoft,
135
+ borderRadius: t.radius.full,
136
+ opacity: burstOpacity,
137
+ transform: [{ scale: burstScale }],
138
+ },
139
+ ]}
140
+ />
141
+ <Animated.View
142
+ style={[
143
+ styles.badge,
144
+ {
145
+ backgroundColor: t.colors.primarySoft,
146
+ borderRadius: t.radius.full,
147
+ transform: [{ scale }],
148
+ opacity: pop,
149
+ },
150
+ ]}
151
+ >
152
+ <Text style={[styles.check, { color: t.colors.primary }]}>✓</Text>
153
+ </Animated.View>
154
+ </View>
155
+
156
+ <Animated.View style={riseStyle(titleT)}>
157
+ <Text style={[headingStyle(t.fonts), { color: t.colors.text, textAlign: "center" }]}>
158
+ {title}
159
+ </Text>
160
+ </Animated.View>
161
+ {message ? (
162
+ <Animated.View style={riseStyle(messageT)}>
163
+ <Text style={[bodyStyle(t.fonts), { color: t.colors.textMuted, textAlign: "center" }]}>
164
+ {message}
165
+ </Text>
166
+ </Animated.View>
167
+ ) : null}
168
+ </View>
169
+ </CardLayout>
170
+ );
171
+ };
172
+
173
+ export const CompletionView = React.memo(_CompletionView);
174
+
175
+ const styles = StyleSheet.create({
176
+ center: { width: "100%", alignItems: "center", justifyContent: "center" },
177
+ badgeStage: {
178
+ width: BURST_SIZE,
179
+ height: BURST_SIZE,
180
+ alignItems: "center",
181
+ justifyContent: "center",
182
+ },
183
+ burst: { position: "absolute", width: BURST_SIZE, height: BURST_SIZE },
184
+ badge: { width: BADGE_SIZE, height: BADGE_SIZE, alignItems: "center", justifyContent: "center" },
185
+ check: { fontSize: 34, fontWeight: "800" },
186
+ });
@@ -0,0 +1,37 @@
1
+ /**
2
+ * DoneBlock — a brief themed "all set" state shown when the flow completes, while
3
+ * the host persists results and navigates away. Purely cosmetic; the real
4
+ * terminal signal is the SDK's StatusCard (handled in OnboardingFlow).
5
+ */
6
+ import React from "react";
7
+ import { StyleSheet, Text, View } from "react-native";
8
+ import { useOnboardingTheme } from "../theme/ThemeContext";
9
+ import { bodyStyle, headingStyle } from "../theme/typography";
10
+
11
+ export type DoneBlockProps = {
12
+ title?: string;
13
+ message?: string;
14
+ };
15
+
16
+ const _DoneBlock: React.FC<DoneBlockProps> = ({
17
+ title = "You're all set",
18
+ message = "Personalizing your experience…",
19
+ }) => {
20
+ const t = useOnboardingTheme();
21
+ return (
22
+ <View style={[styles.center, { gap: t.spacing.sm, padding: t.spacing.lg }]}>
23
+ <Text style={[headingStyle(t.fonts), { color: t.colors.success, textAlign: "center" }]}>
24
+ {title}
25
+ </Text>
26
+ <Text style={[bodyStyle(t.fonts), { color: t.colors.textMuted, textAlign: "center" }]}>
27
+ {message}
28
+ </Text>
29
+ </View>
30
+ );
31
+ };
32
+
33
+ export const DoneBlock = React.memo(_DoneBlock);
34
+
35
+ const styles = StyleSheet.create({
36
+ center: { flex: 1, alignItems: "center", justifyContent: "center" },
37
+ });
@@ -0,0 +1,42 @@
1
+ /**
2
+ * ErrorBlock — shown on backend error/timeout. Carries a retry action so the flow
3
+ * never dead-ends. The host decides what retry does (re-send start, or fall back).
4
+ */
5
+ import React from "react";
6
+ import { StyleSheet, Text, View } from "react-native";
7
+ import { useOnboardingTheme } from "../theme/ThemeContext";
8
+ import { bodyStyle, headingStyle } from "../theme/typography";
9
+ import { Button } from "./Button";
10
+
11
+ export type ErrorBlockProps = {
12
+ title?: string;
13
+ message?: string;
14
+ retryLabel?: string;
15
+ onRetry?: () => void;
16
+ };
17
+
18
+ const _ErrorBlock: React.FC<ErrorBlockProps> = ({
19
+ title = "Something went wrong",
20
+ message = "We couldn't load this step. You can try again.",
21
+ retryLabel = "Try again",
22
+ onRetry,
23
+ }) => {
24
+ const t = useOnboardingTheme();
25
+ return (
26
+ <View style={[styles.center, { gap: t.spacing.md, padding: t.spacing.lg }]}>
27
+ <Text style={[headingStyle(t.fonts), { color: t.colors.text, textAlign: "center" }]}>
28
+ {title}
29
+ </Text>
30
+ <Text style={[bodyStyle(t.fonts), { color: t.colors.textMuted, textAlign: "center" }]}>
31
+ {message}
32
+ </Text>
33
+ {onRetry ? <Button title={retryLabel} onPress={onRetry} variant="outline" /> : null}
34
+ </View>
35
+ );
36
+ };
37
+
38
+ export const ErrorBlock = React.memo(_ErrorBlock);
39
+
40
+ const styles = StyleSheet.create({
41
+ center: { flex: 1, alignItems: "center", justifyContent: "center" },
42
+ });
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Illustration registry — lets the host app supply brand artwork (SVG / Lottie /
3
+ * Image nodes) keyed by name, which the backend references from an InterstitialCard
4
+ * by `illustration`. Keeping the registry in React context (rather than as a card
5
+ * prop) is what keeps the kit dependency-free: the kit never imports react-native-svg
6
+ * or Lottie — the app brings its own nodes and the kit just slots the matching one.
7
+ *
8
+ * <WireOnboarding illustrations={{ "before-after": <BeforeAfter/> }} ... />
9
+ *
10
+ * When the backend names an illustration the registry doesn't have, the
11
+ * InterstitialCard falls back to its `imageUrl` (a plain RN <Image>).
12
+ */
13
+ import React, { createContext, useContext } from "react";
14
+
15
+ export type IllustrationRegistry = Record<string, React.ReactNode>;
16
+
17
+ const IllustrationContext = createContext<IllustrationRegistry>({});
18
+
19
+ export type IllustrationProviderProps = {
20
+ registry?: IllustrationRegistry;
21
+ children: React.ReactNode;
22
+ };
23
+
24
+ export const IllustrationProvider: React.FC<IllustrationProviderProps> = ({
25
+ registry,
26
+ children,
27
+ }) => (
28
+ <IllustrationContext.Provider value={registry ?? {}}>{children}</IllustrationContext.Provider>
29
+ );
30
+
31
+ /** Look up an app-supplied illustration node by name. Returns undefined if absent. */
32
+ export const useIllustration = (name?: string): React.ReactNode | undefined => {
33
+ const registry = useContext(IllustrationContext);
34
+ if (!name) return undefined;
35
+ return registry[name];
36
+ };
@@ -0,0 +1,182 @@
1
+ /**
2
+ * LoadingBlock — the "AI is thinking" indicator, shown before the first card and
3
+ * between every turn (each round-trip to the backend). The Wire Activation
4
+ * loading treatment: no generic spinner — the brand mark breathes inside a
5
+ * dashed ring while an accent dot orbits it, and skeleton lines appear (softly
6
+ * pulsing) only when the fetch runs past 300ms so a fast turn never flashes
7
+ * placeholder chrome (design-refs/EXTRACTED-SPEC.md rows 17-19).
8
+ *
9
+ * Reduce motion: static mark + copy, no orbit, no pulse.
10
+ */
11
+ import React, { useEffect, useRef, useState } from "react";
12
+ import { Animated, Easing, StyleSheet, Text, View } from "react-native";
13
+ import { useOnboardingTheme } from "../theme/ThemeContext";
14
+ import { bodyStyle, headingStyle } from "../theme/typography";
15
+ import { AnimatedSparkle } from "./AnimatedSparkle";
16
+ import {
17
+ LOADER_ORBIT_MS,
18
+ SKELETON_DELAY_MS,
19
+ SKELETON_MIN_OPACITY,
20
+ SKELETON_PULSE_MS,
21
+ loopEnabled,
22
+ scaledMs,
23
+ } from "../motion/motionSpec";
24
+ import { useReducedMotion } from "../motion/useReducedMotion";
25
+ import { useLoaderChrome } from "./loaderChrome";
26
+
27
+ const STAGE_SIZE = 96;
28
+ const ORBIT_DOT = 9;
29
+
30
+ export type LoadingBlockProps = {
31
+ /** Primary line, e.g. "Thinking…". */
32
+ title?: string;
33
+ /** Supporting line under the title. */
34
+ hint?: string;
35
+ };
36
+
37
+ /** The orbiting accent dot: a full-stage rotating layer with the dot at 12 o'clock. */
38
+ const OrbitDot: React.FC<{ color: string; reduced: boolean }> = ({ color, reduced }) => {
39
+ const spin = useRef(new Animated.Value(0)).current;
40
+ useEffect(() => {
41
+ if (!loopEnabled(reduced)) return;
42
+ const loop = Animated.loop(
43
+ Animated.timing(spin, {
44
+ toValue: 1,
45
+ duration: scaledMs(LOADER_ORBIT_MS),
46
+ easing: Easing.linear,
47
+ useNativeDriver: true,
48
+ }),
49
+ );
50
+ loop.start();
51
+ return () => loop.stop();
52
+ }, [spin, reduced]);
53
+
54
+ if (reduced) return null;
55
+ const rotate = spin.interpolate({ inputRange: [0, 1], outputRange: ["0deg", "360deg"] });
56
+ return (
57
+ <Animated.View pointerEvents="none" style={[StyleSheet.absoluteFill, { transform: [{ rotate }] }]}>
58
+ <View style={[styles.orbitDot, { backgroundColor: color }]} />
59
+ </Animated.View>
60
+ );
61
+ };
62
+
63
+ /** A skeleton line that softly pulses (static under reduce motion). */
64
+ const SkeletonLine: React.FC<{ width: `${number}%`; delay: number; color: string; reduced: boolean }> = ({
65
+ width,
66
+ delay,
67
+ color,
68
+ reduced,
69
+ }) => {
70
+ const pulse = useRef(new Animated.Value(1)).current;
71
+ useEffect(() => {
72
+ if (!loopEnabled(reduced)) return;
73
+ const loop = Animated.loop(
74
+ Animated.sequence([
75
+ Animated.delay(delay),
76
+ Animated.timing(pulse, {
77
+ toValue: SKELETON_MIN_OPACITY,
78
+ duration: scaledMs(SKELETON_PULSE_MS / 2),
79
+ easing: Easing.inOut(Easing.ease),
80
+ useNativeDriver: true,
81
+ }),
82
+ Animated.timing(pulse, {
83
+ toValue: 1,
84
+ duration: scaledMs(SKELETON_PULSE_MS / 2),
85
+ easing: Easing.inOut(Easing.ease),
86
+ useNativeDriver: true,
87
+ }),
88
+ ]),
89
+ );
90
+ loop.start();
91
+ return () => loop.stop();
92
+ }, [pulse, delay, reduced]);
93
+
94
+ return (
95
+ <Animated.View
96
+ style={[styles.skeletonLine, { width, backgroundColor: color, opacity: reduced ? 1 : pulse }]}
97
+ />
98
+ );
99
+ };
100
+
101
+ const _LoadingBlock: React.FC<LoadingBlockProps> = ({
102
+ title = "Thinking…",
103
+ hint = "Personalizing your experience",
104
+ }) => {
105
+ const t = useOnboardingTheme();
106
+ const reduced = useReducedMotion();
107
+ // When the loader renders inside the scaffold body it sits below the header; the scaffold
108
+ // publishes how far to shift UP so the loader centers in the full flow area, not the body.
109
+ // Standalone (no provider) → 0. A static transform (visual only): it never reflows the body,
110
+ // so the card underneath and the card↔loader handoff are unaffected.
111
+ const { offsetY } = useLoaderChrome();
112
+
113
+ // Skeleton lines are gated behind the 300ms window: a fast fetch never shows them.
114
+ const [showSkeleton, setShowSkeleton] = useState(false);
115
+ useEffect(() => {
116
+ const timer = setTimeout(() => setShowSkeleton(true), SKELETON_DELAY_MS);
117
+ return () => clearTimeout(timer);
118
+ }, []);
119
+
120
+ return (
121
+ <View
122
+ style={[
123
+ styles.center,
124
+ { gap: t.spacing.md, padding: t.spacing.lg },
125
+ offsetY ? { transform: [{ translateY: offsetY }] } : null,
126
+ ]}
127
+ >
128
+ <View style={styles.stage}>
129
+ <View style={[styles.ring, { borderColor: t.colors.border, borderRadius: t.radius.full }]} />
130
+ <OrbitDot color={t.colors.primary} reduced={reduced} />
131
+ <AnimatedSparkle size={40} variant="pulse" />
132
+ </View>
133
+ {title ? (
134
+ <Text style={[headingStyle(t.fonts), { color: t.colors.text, textAlign: "center" }]}>
135
+ {title}
136
+ </Text>
137
+ ) : null}
138
+ {hint ? (
139
+ <Text style={[bodyStyle(t.fonts), { color: t.colors.textMuted, textAlign: "center" }]}>
140
+ {hint}
141
+ </Text>
142
+ ) : null}
143
+ {showSkeleton ? (
144
+ <View style={[styles.skeleton, { gap: t.spacing.sm, marginTop: t.spacing.sm }]}>
145
+ <SkeletonLine width="80%" delay={0} color={t.colors.surface} reduced={reduced} />
146
+ <SkeletonLine width="60%" delay={200} color={t.colors.surface} reduced={reduced} />
147
+ </View>
148
+ ) : null}
149
+ </View>
150
+ );
151
+ };
152
+
153
+ export const LoadingBlock = React.memo(_LoadingBlock);
154
+
155
+ const styles = StyleSheet.create({
156
+ center: { flex: 1, alignItems: "center", justifyContent: "center" },
157
+ stage: {
158
+ width: STAGE_SIZE,
159
+ height: STAGE_SIZE,
160
+ alignItems: "center",
161
+ justifyContent: "center",
162
+ },
163
+ ring: {
164
+ position: "absolute",
165
+ top: 0,
166
+ left: 0,
167
+ right: 0,
168
+ bottom: 0,
169
+ borderWidth: 1.5,
170
+ borderStyle: "dashed",
171
+ },
172
+ orbitDot: {
173
+ position: "absolute",
174
+ top: -ORBIT_DOT / 2 + 1,
175
+ left: STAGE_SIZE / 2 - ORBIT_DOT / 2,
176
+ width: ORBIT_DOT,
177
+ height: ORBIT_DOT,
178
+ borderRadius: ORBIT_DOT / 2,
179
+ },
180
+ skeleton: { width: "100%", alignItems: "center" },
181
+ skeletonLine: { height: 12, borderRadius: 6 },
182
+ });