@wireai/activation 0.3.0 → 0.4.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 (57) hide show
  1. package/dist/analytics/index.js +46 -4
  2. package/dist/analytics/index.js.map +1 -1
  3. package/dist/analytics/index.mjs +46 -4
  4. package/dist/analytics/index.mjs.map +1 -1
  5. package/dist/coachmarks/index.js +6 -1
  6. package/dist/coachmarks/index.js.map +1 -1
  7. package/dist/coachmarks/index.mjs +6 -1
  8. package/dist/coachmarks/index.mjs.map +1 -1
  9. package/dist/index.d.mts +3 -2
  10. package/dist/index.d.ts +3 -2
  11. package/dist/index.js +220 -151
  12. package/dist/index.js.map +1 -1
  13. package/dist/index.mjs +221 -152
  14. package/dist/index.mjs.map +1 -1
  15. package/dist/questionnaire/index.d.mts +1 -1
  16. package/dist/questionnaire/index.d.ts +1 -1
  17. package/dist/questionnaire/index.js +29 -5
  18. package/dist/questionnaire/index.js.map +1 -1
  19. package/dist/questionnaire/index.mjs +30 -6
  20. package/dist/questionnaire/index.mjs.map +1 -1
  21. package/dist/reviews/index.d.mts +1 -1
  22. package/dist/reviews/index.d.ts +1 -1
  23. package/dist/reviews/index.js +40 -9
  24. package/dist/reviews/index.js.map +1 -1
  25. package/dist/reviews/index.mjs +42 -11
  26. package/dist/reviews/index.mjs.map +1 -1
  27. package/dist/showcase/index.d.mts +1 -14
  28. package/dist/showcase/index.d.ts +1 -14
  29. package/dist/showcase/index.js +8 -2
  30. package/dist/showcase/index.js.map +1 -1
  31. package/dist/showcase/index.mjs +8 -2
  32. package/dist/showcase/index.mjs.map +1 -1
  33. package/package.json +3 -2
  34. package/src/OnboardingFlow.tsx +8 -4
  35. package/src/WireOnboarding.tsx +8 -3
  36. package/src/analytics/analyticsFacade.ts +14 -1
  37. package/src/analytics/eventQueue.ts +5 -0
  38. package/src/analytics/useAnalytics.ts +7 -1
  39. package/src/analytics/useScreenTracking.ts +23 -1
  40. package/src/cards/InterstitialCard.tsx +1 -1
  41. package/src/cards/NumberStepperCard.tsx +12 -7
  42. package/src/cards/StatusCard.tsx +13 -8
  43. package/src/cards/TextInputCard.tsx +10 -5
  44. package/src/components/AnimatedSparkle.tsx +20 -3
  45. package/src/components/Button.tsx +10 -1
  46. package/src/components/CardHandoff.tsx +2 -6
  47. package/src/components/CardLayout.tsx +16 -10
  48. package/src/components/Illustration.tsx +9 -5
  49. package/src/components/LoadingBlock.tsx +44 -29
  50. package/src/components/LoadingScreen.tsx +3 -2
  51. package/src/components/OnboardingScaffold.tsx +21 -23
  52. package/src/features/WireFeaturesProvider.tsx +7 -2
  53. package/src/questionnaire/QuestionnaireGate.tsx +15 -3
  54. package/src/reviews/ReviewGate.tsx +27 -7
  55. package/src/reviews/ReviewModal.tsx +4 -0
  56. package/src/showcase/FeatureShowcase.tsx +2 -1
  57. package/src/components/loaderChrome.ts +0 -28
@@ -9,7 +9,7 @@
9
9
  * Reduce motion: final frame instantly. (The terminal payoff with the burst is
10
10
  * CompletionView; this card is the inline status treatment.)
11
11
  */
12
- import React, { useCallback, useEffect, useRef, useState } from "react";
12
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
13
13
  import { Animated, Easing, StyleSheet, Text, View } from "react-native";
14
14
  import { z } from "zod";
15
15
  import type { InjectedProps, WireAIComponent } from "wireai-rn";
@@ -60,13 +60,18 @@ const _StatusCard: React.FC<Props> = ({ status, title, message, ctaLabel, onCont
60
60
  const reduced = useReducedMotion();
61
61
  const [submitted, setSubmitted] = useState(false);
62
62
 
63
- const colorFor: Record<string, string> = {
64
- success: t.colors.success,
65
- warning: t.colors.warning,
66
- error: t.colors.error,
67
- info: t.colors.info,
68
- };
69
- const color = colorFor[status] ?? t.colors.info;
63
+ const color = useMemo(() => {
64
+ switch (status) {
65
+ case "success":
66
+ return t.colors.success;
67
+ case "warning":
68
+ return t.colors.warning;
69
+ case "error":
70
+ return t.colors.error;
71
+ default:
72
+ return t.colors.info;
73
+ }
74
+ }, [status, t.colors]);
70
75
 
71
76
  // Glyph pop + copy fade-up (final frame under reduce motion).
72
77
  const popT = useRef(new Animated.Value(reduced ? 1 : 0)).current;
@@ -9,11 +9,12 @@
9
9
  * multi-line box by default so the user has room to actually write. Short fields
10
10
  * (a name, a username) set `multiline: false` for the compact single-line height.
11
11
  */
12
- import React, { useCallback, useState } from "react";
12
+ import React, { useCallback, useMemo, useState } from "react";
13
13
  import { StyleSheet, Text, TextInput, View, useWindowDimensions } from "react-native";
14
14
  import { z } from "zod";
15
15
  import type { InjectedProps, WireAIComponent } from "wireai-rn";
16
16
  import { useOnboardingTheme } from "../theme/ThemeContext";
17
+ import type { OnboardingTheme } from "../theme/types";
17
18
  import { bodyStyle, captionStyle } from "../theme/typography";
18
19
  import { Button } from "../components/Button";
19
20
  import { CardLayout } from "../components/CardLayout";
@@ -66,6 +67,7 @@ const _TextInputCard: React.FC<Props> = ({
66
67
  lines = 6,
67
68
  }) => {
68
69
  const t = useOnboardingTheme();
70
+ const styles = useMemo(() => makeStyles(t), [t]);
69
71
  const { height: screenHeight } = useWindowDimensions();
70
72
  const [value, setValue] = useState("");
71
73
  const [submitted, setSubmitted] = useState(false);
@@ -102,7 +104,7 @@ const _TextInputCard: React.FC<Props> = ({
102
104
  />
103
105
  }
104
106
  >
105
- <View style={{ gap: t.spacing.sm }}>
107
+ <View style={styles.row}>
106
108
  <TextInput
107
109
  value={value}
108
110
  onChangeText={setValue}
@@ -142,6 +144,9 @@ export const TextInputCard: WireAIComponent = {
142
144
  defaultProps: { submitLabel: "Submit", multiline: true, lines: 6 },
143
145
  };
144
146
 
145
- const styles = StyleSheet.create({
146
- input: { borderWidth: 1 },
147
- });
147
+ function makeStyles(t: OnboardingTheme) {
148
+ return StyleSheet.create({
149
+ row: { gap: t.spacing.sm },
150
+ input: { borderWidth: 1 },
151
+ });
152
+ }
@@ -81,12 +81,24 @@ const _AnimatedSparkle: React.FC<AnimatedSparkleProps> = ({ size = 30, color, va
81
81
  ? { opacity: pulse }
82
82
  : { transform: [{ rotate }] };
83
83
 
84
+ // The glyph lives in a fixed size×size box (alignItems/justifyContent:center) so the
85
+ // font's side-bearing and Android includeFontPadding can't shove the visible mark off
86
+ // center. The box carries the transform too, so a spinning box still rotates around its
87
+ // own center → the ✦ spins in place, optically centered on both axes.
84
88
  return (
85
- <Animated.View style={animatedStyle}>
89
+ <Animated.View style={[styles.box, { width: size, height: size }, animatedStyle]}>
86
90
  <Text
87
91
  accessibilityElementsHidden
88
92
  importantForAccessibility="no"
89
- style={[styles.glyph, { fontSize: size, lineHeight: size * 1.1, color: color ?? t.colors.primary }]}
93
+ style={[
94
+ styles.glyph,
95
+ {
96
+ width: size,
97
+ fontSize: size,
98
+ lineHeight: size,
99
+ color: color ?? t.colors.primary,
100
+ },
101
+ ]}
90
102
  >
91
103
 
92
104
  </Text>
@@ -97,5 +109,10 @@ const _AnimatedSparkle: React.FC<AnimatedSparkleProps> = ({ size = 30, color, va
97
109
  export const AnimatedSparkle = React.memo(_AnimatedSparkle);
98
110
 
99
111
  const styles = StyleSheet.create({
100
- glyph: { textAlign: "center" },
112
+ box: { alignItems: "center", justifyContent: "center" },
113
+ glyph: {
114
+ textAlign: "center",
115
+ textAlignVertical: "center",
116
+ includeFontPadding: false,
117
+ },
101
118
  });
@@ -64,6 +64,15 @@ const _Button: React.FC<ButtonProps> = ({
64
64
  },
65
65
  [pressT, reduced],
66
66
  );
67
+ const lastPress = useRef<number>(0);
68
+ const handlePress = useCallback(() => {
69
+ if (!onPress) return;
70
+ const now = Date.now();
71
+ if (now - lastPress.current < 500) return;
72
+ lastPress.current = now;
73
+ onPress();
74
+ }, [onPress]);
75
+
67
76
  const handlePressIn = useCallback(() => pressTo(1), [pressTo]);
68
77
  const handlePressOut = useCallback(() => pressTo(0), [pressTo]);
69
78
 
@@ -78,7 +87,7 @@ const _Button: React.FC<ButtonProps> = ({
78
87
 
79
88
  return (
80
89
  <Pressable
81
- onPress={disabled ? undefined : onPress}
90
+ onPress={disabled ? undefined : handlePress}
82
91
  onPressIn={disabled ? undefined : handlePressIn}
83
92
  onPressOut={disabled ? undefined : handlePressOut}
84
93
  disabled={disabled}
@@ -62,8 +62,6 @@ const _CardHandoff: React.FC<CardHandoffProps> = ({
62
62
  const exitT = useRef(new Animated.Value(0)).current;
63
63
  // The previous render's child, snapshotted so it can play the exit layer.
64
64
  const prev = useRef<Snapshot>({ key: transitionKey, node: children, variant });
65
- // Variant of the child currently entering (drives the enter interpolations).
66
- const [enterVariant, setEnterVariant] = useState<CardHandoffVariant>(variant);
67
65
  const running = useRef<{ enter?: Animated.CompositeAnimation; exit?: Animated.CompositeAnimation }>({});
68
66
  const mounted = useRef(false);
69
67
 
@@ -78,7 +76,6 @@ const _CardHandoff: React.FC<CardHandoffProps> = ({
78
76
  // First mount: enter only (the design's initial reveal), nothing exits.
79
77
  if (!mounted.current) {
80
78
  mounted.current = true;
81
- setEnterVariant(variant);
82
79
  running.current.enter = startEnter(enterT, variant, reduced, 0);
83
80
  return;
84
81
  }
@@ -87,7 +84,6 @@ const _CardHandoff: React.FC<CardHandoffProps> = ({
87
84
  running.current.enter?.stop();
88
85
  running.current.exit?.stop();
89
86
  setLeaving(last);
90
- setEnterVariant(variant);
91
87
  enterT.setValue(0);
92
88
  exitT.setValue(0);
93
89
 
@@ -122,7 +118,7 @@ const _CardHandoff: React.FC<CardHandoffProps> = ({
122
118
 
123
119
  const enterStyle = {
124
120
  opacity: enterT,
125
- transform: transformFor(enterVariant, enterT, reduced, "enter"),
121
+ transform: transformFor(variant, enterT, reduced, "enter"),
126
122
  };
127
123
 
128
124
  return (
@@ -141,7 +137,7 @@ const _CardHandoff: React.FC<CardHandoffProps> = ({
141
137
  {leaving.node}
142
138
  </Animated.View>
143
139
  ) : null}
144
- <Animated.View style={[styles.fill, enterStyle]}>{children}</Animated.View>
140
+ <Animated.View key={variant} style={[styles.fill, enterStyle]}>{children}</Animated.View>
145
141
  </>
146
142
  );
147
143
  };
@@ -22,9 +22,10 @@
22
22
  * whose window already resizes for the keyboard the computed overlap is ~0, so the
23
23
  * padding is a no-op — safe on both iOS and Android, edge-to-edge or not.
24
24
  */
25
- import React from "react";
25
+ import React, { useMemo } from "react";
26
26
  import { KeyboardAvoidingView, ScrollView, StyleSheet, Text, View } from "react-native";
27
27
  import { useOnboardingTheme } from "../theme/ThemeContext";
28
+ import type { OnboardingTheme } from "../theme/types";
28
29
  import { bodyStyle, labelStyle } from "../theme/typography";
29
30
 
30
31
  export type CardLayoutProps = {
@@ -51,12 +52,13 @@ const _CardLayout: React.FC<CardLayoutProps> = ({
51
52
  titleNode,
52
53
  }) => {
53
54
  const t = useOnboardingTheme();
55
+ const styles = useMemo(() => makeStyles(t), [t]);
54
56
  const hasHeader = !!title || !!subtitle || !!titleNode;
55
57
 
56
58
  return (
57
59
  <KeyboardAvoidingView style={styles.fill} behavior="padding">
58
60
  {hasHeader ? (
59
- <View style={{ marginBottom: t.spacing.lg }}>
61
+ <View style={styles.header}>
60
62
  {titleNode ??
61
63
  (title ? (
62
64
  <Text style={[labelStyle(t.fonts), { color: t.colors.text }]}>{title}</Text>
@@ -87,17 +89,21 @@ const _CardLayout: React.FC<CardLayoutProps> = ({
87
89
  {children}
88
90
  </ScrollView>
89
91
 
90
- {footer ? <View style={{ paddingTop: t.spacing.lg }}>{footer}</View> : null}
92
+ {footer ? <View style={styles.footer}>{footer}</View> : null}
91
93
  </KeyboardAvoidingView>
92
94
  );
93
95
  };
94
96
 
95
97
  export const CardLayout = React.memo(_CardLayout);
96
98
 
97
- const styles = StyleSheet.create({
98
- fill: { flex: 1 },
99
- // flexGrow lets a short content block grow to fill (pinning the footer) while a
100
- // tall one scrolls past the visible area.
101
- scrollContent: { flexGrow: 1 },
102
- center: { justifyContent: "center" },
103
- });
99
+ function makeStyles(t: OnboardingTheme) {
100
+ return StyleSheet.create({
101
+ fill: { flex: 1 },
102
+ header: { marginBottom: t.spacing.lg },
103
+ footer: { paddingTop: t.spacing.lg },
104
+ // flexGrow lets a short content block grow to fill (pinning the footer) while a
105
+ // tall one scrolls past the visible area.
106
+ scrollContent: { flexGrow: 1 },
107
+ center: { justifyContent: "center" },
108
+ });
109
+ }
@@ -10,11 +10,14 @@
10
10
  * When the backend names an illustration the registry doesn't have, the
11
11
  * InterstitialCard falls back to its `imageUrl` (a plain RN <Image>).
12
12
  */
13
- import React, { createContext, useContext } from "react";
13
+ import React, { createContext, useContext, useMemo } from "react";
14
14
 
15
15
  export type IllustrationRegistry = Record<string, React.ReactNode>;
16
16
 
17
- const IllustrationContext = createContext<IllustrationRegistry>({});
17
+ /** One shared empty registry so an unconfigured provider never allocates a fresh {} per render. */
18
+ const EMPTY_REGISTRY: IllustrationRegistry = {};
19
+
20
+ const IllustrationContext = createContext<IllustrationRegistry>(EMPTY_REGISTRY);
18
21
 
19
22
  export type IllustrationProviderProps = {
20
23
  registry?: IllustrationRegistry;
@@ -24,9 +27,10 @@ export type IllustrationProviderProps = {
24
27
  export const IllustrationProvider: React.FC<IllustrationProviderProps> = ({
25
28
  registry,
26
29
  children,
27
- }) => (
28
- <IllustrationContext.Provider value={registry ?? {}}>{children}</IllustrationContext.Provider>
29
- );
30
+ }) => {
31
+ const value = useMemo(() => registry ?? EMPTY_REGISTRY, [registry]);
32
+ return <IllustrationContext.Provider value={value}>{children}</IllustrationContext.Provider>;
33
+ };
30
34
 
31
35
  /** Look up an app-supplied illustration node by name. Returns undefined if absent. */
32
36
  export const useIllustration = (name?: string): React.ReactNode | undefined => {
@@ -22,7 +22,6 @@ import {
22
22
  scaledMs,
23
23
  } from "../motion/motionSpec";
24
24
  import { useReducedMotion } from "../motion/useReducedMotion";
25
- import { useLoaderChrome } from "./loaderChrome";
26
25
 
27
26
  const STAGE_SIZE = 96;
28
27
  const ORBIT_DOT = 9;
@@ -104,11 +103,12 @@ const _LoadingBlock: React.FC<LoadingBlockProps> = ({
104
103
  }) => {
105
104
  const t = useOnboardingTheme();
106
105
  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();
106
+ // The loader fills its parent (flex:1) and centers in it. In the flow that parent is the
107
+ // scaffold BODY (the same content region the cards render into), so the loader lands at the
108
+ // content-area center visually consistent with the cards it hands off to. Standalone
109
+ // (LoadingScreen / a playground CardFrame) it centers in whatever bounds it. No screen-inset
110
+ // math and no transform: it inherits the content area it is dropped into, so it is centered
111
+ // in every context by construction.
112
112
 
113
113
  // Skeleton lines are gated behind the 300ms window: a fast fetch never shows them.
114
114
  const [showSkeleton, setShowSkeleton] = useState(false);
@@ -118,34 +118,44 @@ const _LoadingBlock: React.FC<LoadingBlockProps> = ({
118
118
  }, []);
119
119
 
120
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
- >
121
+ <View style={[styles.center, { padding: t.spacing.lg }]}>
122
+ {/*
123
+ * The stage is the ANCHOR: it's the only in-flow child, so `justifyContent:
124
+ * center` lands the icon at the true center of the flow area. Title, hint and
125
+ * the (300ms-gated) skeleton hang BELOW it in an absolutely-positioned column,
126
+ * so they never displace the icon — previously they lived in the same centered
127
+ * column, which re-balanced (and jumped the icon UP ~½ its height) the moment
128
+ * the skeleton lines mounted at 300ms.
129
+ */}
128
130
  <View style={styles.stage}>
129
131
  <View style={[styles.ring, { borderColor: t.colors.border, borderRadius: t.radius.full }]} />
130
132
  <OrbitDot color={t.colors.primary} reduced={reduced} />
131
133
  <AnimatedSparkle size={40} variant="pulse" />
132
134
  </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}
135
+ <View
136
+ style={[
137
+ styles.labels,
138
+ { marginTop: STAGE_SIZE / 2 + t.spacing.lg, gap: t.spacing.md, paddingHorizontal: t.spacing.lg },
139
+ ]}
140
+ pointerEvents="none"
141
+ >
142
+ {title ? (
143
+ <Text style={[headingStyle(t.fonts), { color: t.colors.text, textAlign: "center" }]}>
144
+ {title}
145
+ </Text>
146
+ ) : null}
147
+ {hint ? (
148
+ <Text style={[bodyStyle(t.fonts), { color: t.colors.textMuted, textAlign: "center" }]}>
149
+ {hint}
150
+ </Text>
151
+ ) : null}
152
+ {showSkeleton ? (
153
+ <View style={[styles.skeleton, { gap: t.spacing.sm, marginTop: t.spacing.sm }]}>
154
+ <SkeletonLine width="80%" delay={0} color={t.colors.surface} reduced={reduced} />
155
+ <SkeletonLine width="60%" delay={200} color={t.colors.surface} reduced={reduced} />
156
+ </View>
157
+ ) : null}
158
+ </View>
149
159
  </View>
150
160
  );
151
161
  };
@@ -154,6 +164,11 @@ export const LoadingBlock = React.memo(_LoadingBlock);
154
164
 
155
165
  const styles = StyleSheet.create({
156
166
  center: { flex: 1, alignItems: "center", justifyContent: "center" },
167
+ // Absolutely-positioned label column, pinned just under the centered stage: its top
168
+ // edge starts at the flow center (top: 50%) and the inline marginTop (STAGE_SIZE/2 +
169
+ // a gap) drops it below the stage. Out of flow → it never moves the stage, so the
170
+ // 300ms skeleton reveal grows DOWNWARD only and the icon stays centered.
171
+ labels: { position: "absolute", top: "50%", left: 0, right: 0, alignItems: "center" },
157
172
  stage: {
158
173
  width: STAGE_SIZE,
159
174
  height: STAGE_SIZE,
@@ -7,8 +7,9 @@
7
7
  * WHY a dedicated frame: those slots used to return a bare <LoadingBlock/>, whose flex:1 only
8
8
  * fills whatever the host happened to give it — with no safe area and, if the host didn't
9
9
  * bound it, no height at all (top-anchored). This wraps them in the same SafeAreaView the flow
10
- * uses, so the standalone loaders center exactly like the between-turns loader (which centers
11
- * in the full flow area via the scaffold's loaderChrome offset). One consistent centered look.
10
+ * uses, so the standalone loaders center exactly like the between-turns loader (both fill their
11
+ * parent flex:1 region and center in it — here the SafeAreaView, in the flow the scaffold body).
12
+ * One consistent centered look.
12
13
  */
13
14
  import React from "react";
14
15
  import { StyleSheet } from "react-native";
@@ -15,16 +15,13 @@
15
15
  * RN core SafeAreaView is deprecated and iOS-only, so the kit takes the standard
16
16
  * Expo/RN peer instead of shipping cropped layouts on Android + notched devices.
17
17
  */
18
- import React from "react";
18
+ import React, { useMemo } from "react";
19
19
  import { StyleSheet, Text, TouchableOpacity, View } from "react-native";
20
20
  import { SafeAreaView } from "react-native-safe-area-context";
21
21
  import { useOnboardingTheme } from "../theme/ThemeContext";
22
+ import type { OnboardingTheme } from "../theme/types";
22
23
  import { bodyStyle } from "../theme/typography";
23
24
  import { StepProgress } from "./StepProgress";
24
- import { LoaderChromeProvider } from "./loaderChrome";
25
-
26
- // The header's fixed StepProgress bar height (design bar = 6px; see StepProgress styles).
27
- const PROGRESS_TRACK = 6;
28
25
 
29
26
  export type OnboardingScaffoldProps = {
30
27
  /** 1-based index of the current step; drives the (countless) progress bar. */
@@ -53,15 +50,9 @@ const _OnboardingScaffold: React.FC<OnboardingScaffoldProps> = ({
53
50
  children,
54
51
  }) => {
55
52
  const t = useOnboardingTheme();
53
+ const styles = useMemo(() => makeStyles(t), [t]);
56
54
  const hit = { top: 12, bottom: 12, left: 12, right: 12 };
57
55
 
58
- // Top chrome above the body content = header (progress bar + its paddingTop) + the body's
59
- // own paddingTop. A loader that fills the body centers half that distance too low; it reads
60
- // this offset (via LoaderChromeProvider) and shifts UP by half the chrome to land at the true
61
- // screen center. Cards ignore it, so their layout — and the card↔loader handoff — is untouched.
62
- const topChrome = t.spacing.md + PROGRESS_TRACK + t.spacing.md;
63
- const loaderOffsetY = -Math.round(topChrome / 2);
64
-
65
56
  return (
66
57
  <SafeAreaView
67
58
  edges={["top", "bottom"]}
@@ -72,15 +63,19 @@ const _OnboardingScaffold: React.FC<OnboardingScaffoldProps> = ({
72
63
  <StepProgress step={step} complete={complete} approxScreens={approxScreens} />
73
64
  </View>
74
65
  {onSkip ? (
75
- <TouchableOpacity onPress={onSkip} hitSlop={hit} style={{ marginLeft: t.spacing.md }}>
66
+ <TouchableOpacity onPress={onSkip} hitSlop={hit} style={styles.skip}>
76
67
  <Text style={[bodyStyle(t.fonts), { color: t.colors.textMuted }]}>{skipLabel}</Text>
77
68
  </TouchableOpacity>
78
69
  ) : null}
79
70
  </View>
80
71
 
81
- {/* Full-screen body: the card (via CardLayout) fills this and pins its own CTA. */}
82
- <View style={[styles.body, { paddingHorizontal: t.spacing.md, paddingTop: t.spacing.md }]}>
83
- <LoaderChromeProvider value={{ offsetY: loaderOffsetY }}>{children}</LoaderChromeProvider>
72
+ {/* Full-screen body (flex:1): the card (via CardLayout) fills this and pins its own CTA,
73
+ and the between-turns LoadingBlock fills+centers in this SAME region — so the loader
74
+ lands at the content-area center, consistent with the cards, not the full-screen center. */}
75
+ <View
76
+ style={[styles.body, { paddingHorizontal: t.spacing.md, paddingTop: t.spacing.md }]}
77
+ >
78
+ {children}
84
79
  </View>
85
80
 
86
81
  {onBack ? (
@@ -96,10 +91,13 @@ const _OnboardingScaffold: React.FC<OnboardingScaffoldProps> = ({
96
91
 
97
92
  export const OnboardingScaffold = React.memo(_OnboardingScaffold);
98
93
 
99
- const styles = StyleSheet.create({
100
- flex: { flex: 1 },
101
- header: { flexDirection: "row", alignItems: "center" },
102
- progressWrap: { flex: 1 },
103
- body: { flex: 1 },
104
- footer: { flexDirection: "row", justifyContent: "flex-start" },
105
- });
94
+ function makeStyles(t: OnboardingTheme) {
95
+ return StyleSheet.create({
96
+ flex: { flex: 1 },
97
+ header: { flexDirection: "row", alignItems: "center" },
98
+ progressWrap: { flex: 1 },
99
+ body: { flex: 1 },
100
+ footer: { flexDirection: "row", justifyContent: "flex-start" },
101
+ skip: { marginLeft: t.spacing.md },
102
+ });
103
+ }
@@ -18,8 +18,13 @@ import { defaultWireFeatures } from "./defaults";
18
18
  import { useWireFeatures } from "./useWireFeatures";
19
19
  import type { WireFeatures, WireFeaturesConfig } from "./types";
20
20
 
21
- /** null = no provider mounted (the signal `useResolvedFeatures` uses to fall back). */
22
- const WireFeaturesContext = createContext<WireFeatures | null>(null);
21
+ const CONTEXT_SYMBOL = Symbol.for("wireai.features.context");
22
+ const globalObj = global as any;
23
+
24
+ if (!globalObj[CONTEXT_SYMBOL]) {
25
+ globalObj[CONTEXT_SYMBOL] = createContext<WireFeatures | null>(null);
26
+ }
27
+ const WireFeaturesContext = globalObj[CONTEXT_SYMBOL];
23
28
 
24
29
  export interface WireFeaturesProviderProps {
25
30
  /** Tenant creds (+ optional storage) to fetch the flags once for the whole tree. */
@@ -43,7 +43,7 @@ const mergeThemeOver = (
43
43
 
44
44
  type Phase = "form" | "thanks";
45
45
 
46
- export const QuestionnaireGate: React.FC<QuestionnaireGateProps> = ({
46
+ const _QuestionnaireGate: React.FC<QuestionnaireGateProps> = ({
47
47
  questionnaire,
48
48
  target,
49
49
  sessionId,
@@ -81,6 +81,14 @@ export const QuestionnaireGate: React.FC<QuestionnaireGateProps> = ({
81
81
  // eslint-disable-next-line react-hooks/exhaustive-deps
82
82
  }, []);
83
83
 
84
+ const resolveTimer = React.useRef<ReturnType<typeof setTimeout> | null>(null);
85
+
86
+ React.useEffect(() => {
87
+ return () => {
88
+ if (resolveTimer.current) clearTimeout(resolveTimer.current);
89
+ };
90
+ }, []);
91
+
84
92
  const resolve = useCallback(() => {
85
93
  onResolved?.();
86
94
  }, [onResolved]);
@@ -100,8 +108,11 @@ export const QuestionnaireGate: React.FC<QuestionnaireGateProps> = ({
100
108
  submitQuestionnaireResponse(target, id, submission);
101
109
  onEvent?.({ name: "questionnaire_submitted", id });
102
110
  setPhase("thanks");
103
- resolve();
104
- }, [canSend, opinion, improve, suggestions, sessionId, meta, target, id, onEvent, resolve]);
111
+ if (resolveTimer.current) clearTimeout(resolveTimer.current);
112
+ resolveTimer.current = setTimeout(() => {
113
+ modalRef.current?.close();
114
+ }, 1500);
115
+ }, [canSend, opinion, improve, suggestions, sessionId, meta, target, id, onEvent]);
105
116
 
106
117
  // The in-card "Not now"/"Skip" link plays the popup's exit animation (which resolves on
107
118
  // completion, the same path a backdrop tap takes). Emit the dismissed moment first.
@@ -225,6 +236,7 @@ export const QuestionnaireGate: React.FC<QuestionnaireGateProps> = ({
225
236
  );
226
237
  };
227
238
 
239
+ export const QuestionnaireGate = React.memo(_QuestionnaireGate);
228
240
  QuestionnaireGate.displayName = "QuestionnaireGate";
229
241
 
230
242
  const styles = StyleSheet.create({
@@ -52,7 +52,7 @@ const mergeThemeOver = (
52
52
 
53
53
  type Phase = "rating" | "feedback" | "thanks";
54
54
 
55
- export const ReviewGate: React.FC<ReviewGateProps> = ({
55
+ const _ReviewGate: React.FC<ReviewGateProps> = ({
56
56
  appName,
57
57
  store,
58
58
  target,
@@ -94,10 +94,29 @@ export const ReviewGate: React.FC<ReviewGateProps> = ({
94
94
  // eslint-disable-next-line react-hooks/exhaustive-deps
95
95
  }, []);
96
96
 
97
+ const resolveTimer = React.useRef<ReturnType<typeof setTimeout> | null>(null);
98
+
99
+ React.useEffect(() => {
100
+ return () => {
101
+ if (resolveTimer.current) clearTimeout(resolveTimer.current);
102
+ };
103
+ }, []);
104
+
97
105
  const resolve = useCallback(() => {
98
106
  onResolved?.();
99
107
  }, [onResolved]);
100
108
 
109
+ const resolveWithDelay = useCallback(() => {
110
+ if (resolveTimer.current) clearTimeout(resolveTimer.current);
111
+ resolveTimer.current = setTimeout(() => {
112
+ if (mode === "modal") {
113
+ modalRef.current?.close();
114
+ } else {
115
+ onResolved?.();
116
+ }
117
+ }, 1500);
118
+ }, [mode, onResolved]);
119
+
101
120
  const pickRating = useCallback(
102
121
  async (value: number) => {
103
122
  setStars(value);
@@ -106,12 +125,12 @@ export const ReviewGate: React.FC<ReviewGateProps> = ({
106
125
  onEvent?.({ name: "store_review_requested", id, stars: value });
107
126
  await requestStoreReview(store);
108
127
  setPhase("thanks");
109
- resolve();
128
+ resolveWithDelay();
110
129
  } else {
111
130
  setPhase("feedback");
112
131
  }
113
132
  },
114
- [onEvent, id, store, resolve],
133
+ [onEvent, id, store, resolveWithDelay],
115
134
  );
116
135
 
117
136
  const sendFeedback = useCallback(() => {
@@ -127,13 +146,13 @@ export const ReviewGate: React.FC<ReviewGateProps> = ({
127
146
  submitReview(target, body);
128
147
  onEvent?.({ name: "review_feedback_submitted", id, stars });
129
148
  setPhase("thanks");
130
- resolve();
131
- }, [stars, feedback, suggestion, sessionId, meta, target, onEvent, id, resolve]);
149
+ resolveWithDelay();
150
+ }, [stars, feedback, suggestion, sessionId, meta, target, onEvent, id, resolveWithDelay]);
132
151
 
133
152
  const dismiss = useCallback(() => {
134
153
  setPhase("thanks");
135
- resolve();
136
- }, [resolve]);
154
+ resolveWithDelay();
155
+ }, [resolveWithDelay]);
137
156
 
138
157
  // The in-card "Not now"/"Skip" link. In modal mode it plays the popup's exit animation
139
158
  // (which resolves on completion, the same path a backdrop tap takes); inline keeps the
@@ -303,6 +322,7 @@ export const ReviewGate: React.FC<ReviewGateProps> = ({
303
322
  return <View style={[styles.card, surface]}>{content}</View>;
304
323
  };
305
324
 
325
+ export const ReviewGate = React.memo(_ReviewGate);
306
326
  ReviewGate.displayName = "ReviewGate";
307
327
 
308
328
  const styles = StyleSheet.create({
@@ -5,6 +5,10 @@
5
5
  * questionnaire module can reuse the same shell without importing reviews/. This file keeps the
6
6
  * reviews public API (`ReviewModal`, `ReviewModalHandle`, `ReviewModalProps`) pointing at the
7
7
  * exact same runtime object, so the reviews canary and every existing import stay valid.
8
+ *
9
+ * The exported shell is already `React.memo`-wrapped at its source (`components/CenteredModal.tsx`
10
+ * wraps `_CenteredModal` in `React.memo`), so `ReviewModal` satisfies the shell-memoization rule
11
+ * without a second wrapper here. Re-wrapping would change the runtime identity the canary asserts.
8
12
  */
9
13
  export { CenteredModal as ReviewModal, default } from "../components/CenteredModal";
10
14
  export type {
@@ -48,7 +48,7 @@ const mergeThemeOver = (
48
48
  * When the gate says "seen", it renders nothing and calls `onDone` from an
49
49
  * effect (never during render).
50
50
  */
51
- export const FeatureShowcase: React.FC<FeatureShowcaseProps> = ({
51
+ const _FeatureShowcase: React.FC<FeatureShowcaseProps> = ({
52
52
  config,
53
53
  onDone,
54
54
  theme: themeOverride,
@@ -211,6 +211,7 @@ export const FeatureShowcase: React.FC<FeatureShowcaseProps> = ({
211
211
  );
212
212
  };
213
213
 
214
+ export const FeatureShowcase = React.memo(_FeatureShowcase);
214
215
  FeatureShowcase.displayName = "FeatureShowcase";
215
216
 
216
217
  const styles = StyleSheet.create({