@wireai/activation 0.3.0 → 0.7.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 (75) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +57 -0
  3. package/dist/analytics/index.d.mts +15 -2
  4. package/dist/analytics/index.d.ts +15 -2
  5. package/dist/analytics/index.js +224 -14
  6. package/dist/analytics/index.js.map +1 -1
  7. package/dist/analytics/index.mjs +222 -15
  8. package/dist/analytics/index.mjs.map +1 -1
  9. package/dist/coachmarks/index.js +6 -1
  10. package/dist/coachmarks/index.js.map +1 -1
  11. package/dist/coachmarks/index.mjs +6 -1
  12. package/dist/coachmarks/index.mjs.map +1 -1
  13. package/dist/{eventQueue-CA1d8Fmn.d.mts → currentSession-BJBB7i4-.d.mts} +143 -15
  14. package/dist/{eventQueue-CrNB9gzH.d.ts → currentSession-CxnP7gAa.d.ts} +143 -15
  15. package/dist/index.d.mts +40 -39
  16. package/dist/index.d.ts +40 -39
  17. package/dist/index.js +378 -154
  18. package/dist/index.js.map +1 -1
  19. package/dist/index.mjs +370 -156
  20. package/dist/index.mjs.map +1 -1
  21. package/dist/questionnaire/index.d.mts +1 -1
  22. package/dist/questionnaire/index.d.ts +1 -1
  23. package/dist/questionnaire/index.js +29 -5
  24. package/dist/questionnaire/index.js.map +1 -1
  25. package/dist/questionnaire/index.mjs +30 -6
  26. package/dist/questionnaire/index.mjs.map +1 -1
  27. package/dist/reviews/index.d.mts +1 -1
  28. package/dist/reviews/index.d.ts +1 -1
  29. package/dist/reviews/index.js +40 -9
  30. package/dist/reviews/index.js.map +1 -1
  31. package/dist/reviews/index.mjs +42 -11
  32. package/dist/reviews/index.mjs.map +1 -1
  33. package/dist/showcase/index.d.mts +1 -14
  34. package/dist/showcase/index.d.ts +1 -14
  35. package/dist/showcase/index.js +8 -2
  36. package/dist/showcase/index.js.map +1 -1
  37. package/dist/showcase/index.mjs +8 -2
  38. package/dist/showcase/index.mjs.map +1 -1
  39. package/package.json +3 -2
  40. package/src/OnboardingFlow.tsx +8 -4
  41. package/src/WireOnboarding.tsx +12 -6
  42. package/src/analytics/analyticsFacade.ts +86 -10
  43. package/src/analytics/contextEnvelope.ts +13 -7
  44. package/src/analytics/currentSession.ts +35 -0
  45. package/src/analytics/eventQueue.ts +5 -0
  46. package/src/analytics/index.ts +3 -0
  47. package/src/analytics/useAnalytics.ts +7 -1
  48. package/src/analytics/useScreenTracking.ts +23 -1
  49. package/src/cards/InterstitialCard.tsx +1 -1
  50. package/src/cards/NumberStepperCard.tsx +12 -7
  51. package/src/cards/StatusCard.tsx +13 -8
  52. package/src/cards/TextInputCard.tsx +10 -5
  53. package/src/components/AnimatedSparkle.tsx +20 -3
  54. package/src/components/Button.tsx +10 -1
  55. package/src/components/CardHandoff.tsx +2 -6
  56. package/src/components/CardLayout.tsx +16 -10
  57. package/src/components/Illustration.tsx +9 -5
  58. package/src/components/LoadingBlock.tsx +44 -29
  59. package/src/components/LoadingScreen.tsx +3 -2
  60. package/src/components/OnboardingScaffold.tsx +21 -23
  61. package/src/context/userContext.ts +210 -0
  62. package/src/device/appVersion.ts +103 -0
  63. package/src/device/deviceContext.ts +17 -5
  64. package/src/features/WireFeaturesProvider.tsx +7 -2
  65. package/src/identity/userIdentity.ts +5 -0
  66. package/src/index.ts +23 -0
  67. package/src/questionnaire/QuestionnaireGate.tsx +15 -3
  68. package/src/reviews/ReviewGate.tsx +27 -7
  69. package/src/reviews/ReviewModal.tsx +4 -0
  70. package/src/session-analytics/reportSessionStart.ts +7 -0
  71. package/src/session-analytics/useLifecycleEvents.ts +4 -2
  72. package/src/session-analytics/useSessionStart.ts +2 -1
  73. package/src/showcase/FeatureShowcase.tsx +2 -1
  74. package/src/types.ts +6 -5
  75. package/src/components/loaderChrome.ts +0 -28
@@ -27,8 +27,10 @@
27
27
  * FIRE-AND-FORGET: no method throws into the UI or blocks — the queue already guarantees that.
28
28
  */
29
29
  import { buildContextEnvelope, type ContextEnvelope } from "./contextEnvelope";
30
+ import { getCurrentSessionId } from "./currentSession";
30
31
  import { createEventQueue, type EventQueue, type EventQueueOptions } from "./eventQueue";
31
32
  import { makeSessionId, type ClientEvent } from "./reportClientEvent";
33
+ import { resolveUserContext, type WireUserContext } from "../context/userContext";
32
34
  import { sanitizeUserId } from "../identity/userIdentity";
33
35
 
34
36
  /** Arbitrary non-PII event properties. Serialized to the event's `meta` (a JSON string) on the wire. */
@@ -62,6 +64,13 @@ export type CreateAnalyticsConfig = {
62
64
  appBuild?: string;
63
65
  /** Host connectivity signal, e.g. "wifi" | "cellular" — read fresh per event via the provider. */
64
66
  networkType?: string;
67
+ /**
68
+ * The rich {@link WireUserContext} to stamp onto every event's `user_context` (device key, opaque
69
+ * user id, opt-in email, arbitrary `extra`). Passed ONCE here at init; updatable post-mount via
70
+ * {@link Analytics.setUserContext} (e.g. attach `userId`/`userEmail` at login). Optional — omit it
71
+ * and events carry only the device context, exactly as before.
72
+ */
73
+ userContext?: WireUserContext;
65
74
  };
66
75
 
67
76
  /** Optional queue tuning knobs, forwarded verbatim to {@link createEventQueue}. */
@@ -81,6 +90,12 @@ export type Analytics = {
81
90
  screen(name: string, props?: AnalyticsProps): void;
82
91
  /** Bind the host's opaque user id (per-session, in-memory) and emit an `identify` event. */
83
92
  identify(userId: string, traits?: AnalyticsProps): void;
93
+ /**
94
+ * Update the {@link WireUserContext} after init (e.g. attach `userId`/`userEmail` at login). Shallow
95
+ * merges the partial over the current context (`extra` is deep-merged); a supplied `userId` also
96
+ * binds like {@link identify}. Takes effect on subsequent events. Fire-and-forget.
97
+ */
98
+ setUserContext(partial: Partial<WireUserContext>): void;
84
99
  /** Attempt an immediate drain of the pending buffer. Fire-and-forget. */
85
100
  flush(): void;
86
101
  /** Host reconnect signal: reset backoff and drain now. Fire-and-forget. */
@@ -98,12 +113,22 @@ export const createAnalytics = (
98
113
  config: CreateAnalyticsConfig,
99
114
  options: AnalyticsOptions = {},
100
115
  ): Analytics => {
101
- const sessionId = config.sessionId ?? makeSessionId();
116
+ // A STABLE per-instance fallback id, used only when no explicit `config.sessionId` was given AND
117
+ // no per-open session has been registered yet (see `resolveSessionId`).
118
+ const instanceSessionId = config.sessionId ?? makeSessionId();
119
+
120
+ // The session id every event correlates to. Precedence: an explicit `config.sessionId` freezes the
121
+ // id (opt-out of the reuse); otherwise reuse the LIVE per-open session the server saw (via
122
+ // `app.session_started`) so `identify`/app-events don't mint a fresh id the server back-fills into a
123
+ // phantom session; finally fall back to the stable per-instance id when no open is registered yet.
124
+ const resolveSessionId = (): string =>
125
+ config.sessionId ?? getCurrentSessionId() ?? instanceSessionId;
102
126
 
103
- // A provider (not a fixed value) so `networkType` is evaluated fresh on every enqueue.
127
+ // A provider (not a fixed value) so `networkType` + the current session id are evaluated fresh
128
+ // on every enqueue.
104
129
  const envelope = (): ContextEnvelope =>
105
130
  buildContextEnvelope({
106
- sessionId,
131
+ sessionId: resolveSessionId(),
107
132
  appVersion: config.appVersion,
108
133
  appBuild: config.appBuild,
109
134
  networkType: config.networkType,
@@ -117,18 +142,62 @@ export const createAnalytics = (
117
142
  ...options,
118
143
  });
119
144
 
120
- // Per-session, in-memory user binding. Not persisted across launches (that is a follow-up).
121
- let boundUserId: string | undefined;
145
+ // The mutable rich user-context: seeded at init, updated via `setUserContext`. Resolved fresh on
146
+ // every event so a post-mount update (login) takes effect immediately.
147
+ let userContext: WireUserContext = { ...(config.userContext ?? {}) };
148
+
149
+ // Per-session, in-memory user binding. Seeded from the init context, then persisted across
150
+ // launches when storage is provided.
151
+ let boundUserId: string | undefined = sanitizeUserId(config.userContext?.userId);
152
+ const storageKey = `wireai:analytics:userId:${config.appId ?? "default"}`;
153
+
154
+ if (config.storage) {
155
+ void config.storage
156
+ .getItem(storageKey)
157
+ .then((saved) => {
158
+ // Don't clobber an explicit init-context user id with a stale persisted one.
159
+ if (saved && !boundUserId) boundUserId = saved;
160
+ })
161
+ .catch(() => {});
162
+ }
163
+
164
+ // Stamp the resolved rich context onto an event: the `user_context` bucket (device_key, app_version,
165
+ // opt-in user_email, namespaced `custom.*`) and the top-level opaque `user_id`. Never overwrites a
166
+ // key the caller already set (so `identify`'s explicit `user_id` and any caller `user_context` win).
167
+ const applyContext = (event: ClientEvent): void => {
168
+ const resolved = resolveUserContext(userContext, { autoAppVersion: config.appVersion });
169
+ if (resolved.userContext) {
170
+ event.user_context = { ...resolved.userContext, ...(event.user_context ?? {}) };
171
+ }
172
+ if (boundUserId && !event.user_id) event.user_id = boundUserId;
173
+ };
174
+
175
+ const setUserContext = (partial: Partial<WireUserContext>): void => {
176
+ if (!partial || typeof partial !== "object") return;
177
+ // Deep-merge `extra` so a partial update adds keys instead of replacing the whole map.
178
+ const mergedExtra =
179
+ partial.extra || userContext.extra
180
+ ? { ...(userContext.extra ?? {}), ...(partial.extra ?? {}) }
181
+ : undefined;
182
+ userContext = { ...userContext, ...partial };
183
+ if (mergedExtra) userContext.extra = mergedExtra;
184
+ // A user id supplied here binds like `identify` so subsequent events carry `user_id`.
185
+ const uid = sanitizeUserId(partial.userId);
186
+ if (uid) {
187
+ boundUserId = uid;
188
+ if (config.storage) void config.storage.setItem(storageKey, uid).catch(() => {});
189
+ }
190
+ };
122
191
 
123
192
  const track = (event: string, props?: AnalyticsProps): void => {
124
193
  if (!event) return;
125
194
  const clientEvent: ClientEvent = {
126
195
  event_type: "app_event",
127
- session_id: sessionId,
196
+ session_id: resolveSessionId(),
128
197
  question_key: event,
129
198
  };
130
199
  if (props && Object.keys(props).length > 0) clientEvent.meta = JSON.stringify(props);
131
- if (boundUserId) clientEvent.user_id = boundUserId;
200
+ applyContext(clientEvent);
132
201
  queue.enqueue(clientEvent);
133
202
  };
134
203
 
@@ -138,11 +207,11 @@ export const createAnalytics = (
138
207
  const meta = { screen: name, ...(props ?? {}) };
139
208
  const clientEvent: ClientEvent = {
140
209
  event_type: "app_event",
141
- session_id: sessionId,
210
+ session_id: resolveSessionId(),
142
211
  question_key: "screen",
143
212
  meta: JSON.stringify(meta),
144
213
  };
145
- if (boundUserId) clientEvent.user_id = boundUserId;
214
+ applyContext(clientEvent);
146
215
  queue.enqueue(clientEvent);
147
216
  };
148
217
 
@@ -151,12 +220,18 @@ export const createAnalytics = (
151
220
  // Blank / non-string → no binding, no event (sanitizeUserId returns undefined). >128 → truncated.
152
221
  if (!clean) return;
153
222
  boundUserId = clean;
223
+ if (config.storage) {
224
+ void config.storage.setItem(storageKey, clean).catch(() => {});
225
+ }
154
226
  const clientEvent: ClientEvent = {
155
227
  event_type: "identify",
156
- session_id: sessionId,
228
+ // Reuse the LIVE per-open session id (see `resolveSessionId`) so the server binds identity to
229
+ // the session it already saw instead of back-filling a phantom `session_started`.
230
+ session_id: resolveSessionId(),
157
231
  user_id: clean,
158
232
  };
159
233
  if (traits && Object.keys(traits).length > 0) clientEvent.meta = JSON.stringify(traits);
234
+ applyContext(clientEvent);
160
235
  queue.enqueue(clientEvent);
161
236
  };
162
237
 
@@ -164,6 +239,7 @@ export const createAnalytics = (
164
239
  track,
165
240
  screen,
166
241
  identify,
242
+ setUserContext,
167
243
  flush: queue.flush,
168
244
  notifyOnline: queue.notifyOnline,
169
245
  size: queue.size,
@@ -30,7 +30,7 @@ export type ContextEnvelope = {
30
30
  device: DeviceContext;
31
31
  /** Correlation id for this app-open / flow (caller-supplied). */
32
32
  sessionId?: string;
33
- /** Host app version, e.g. "1.4.2" (mirrors `device.appVersion`; host-injected). */
33
+ /** App version, e.g. "1.4.2" (mirrors `device.appVersion`; host-injected, else auto-detected). */
34
34
  appVersion?: string;
35
35
  /** Host native build number, e.g. "412" (from `expo-constants` `nativeBuildVersion`). */
36
36
  appBuild?: string;
@@ -47,9 +47,10 @@ export type ContextEnvelopeInput = {
47
47
  };
48
48
 
49
49
  /**
50
- * Build a fresh context envelope. Reuses `collectDeviceContext()` for the device block and layers
51
- * the host-injected scalars on top. `appVersion` is additionally merged onto `device.appVersion`
52
- * when the device block lacks it (mirroring how `useSessionStart` back-fills the host version).
50
+ * Build a fresh context envelope. Reuses `collectDeviceContext()` for the device block (which
51
+ * already carries a best-effort auto-detected `appVersion`) and layers the host-injected scalars
52
+ * on top. An explicit `input.appVersion` overrides the auto-detected `device.appVersion`, and the
53
+ * outer `appVersion` scalar mirrors whichever version is effective.
53
54
  *
54
55
  * Returns a NEW object on every call (no shared mutable reference), so a caller can hold or mutate
55
56
  * the result without leaking into the next envelope. Never throws — `collectDeviceContext` is
@@ -59,12 +60,17 @@ export const buildContextEnvelope = (input: ContextEnvelopeInput = {}): ContextE
59
60
  // Fresh copy so the returned envelope never aliases a cached device snapshot.
60
61
  const device: DeviceContext = { ...collectDeviceContext() };
61
62
 
62
- // Mirror useSessionStart: fill the host app version onto the device block when it lacks one.
63
- if (input.appVersion && !device.appVersion) device.appVersion = input.appVersion;
63
+ // `device.appVersion` is auto-detected best-effort by `collectDeviceContext`; an explicit
64
+ // host `input.appVersion` always wins.
65
+ if (input.appVersion) device.appVersion = input.appVersion;
66
+
67
+ // The outer scalar mirrors the effective version (host-supplied, else auto-detected) so the
68
+ // queue can stamp `user_context.app_version` even when the host never passed one.
69
+ const effectiveAppVersion = input.appVersion ?? device.appVersion;
64
70
 
65
71
  const envelope: ContextEnvelope = { device };
66
72
  if (input.sessionId) envelope.sessionId = input.sessionId;
67
- if (input.appVersion) envelope.appVersion = input.appVersion;
73
+ if (effectiveAppVersion) envelope.appVersion = effectiveAppVersion;
68
74
  if (input.appBuild) envelope.appBuild = input.appBuild;
69
75
  if (input.networkType) envelope.networkType = input.networkType;
70
76
 
@@ -0,0 +1,35 @@
1
+ /**
2
+ * currentSession — a tiny module-level registry of the CURRENT per-open `session_id`.
3
+ *
4
+ * WHY it exists (kills the phantom-session): the per-open emitters (`reportSessionStart` and the
5
+ * `useSessionStart` / `useLifecycleEvents` hooks) mint a fresh `session_id` for each app-open and
6
+ * post `app.session_started` with it — so the SERVER knows that id. But other client paths
7
+ * (`identify`, host `app_event`s through the analytics façade) used to reference a DIFFERENT id
8
+ * (a frozen per-instance id), which the server had never seen, so it back-filled a synthetic
9
+ * `session_started` — inflating session counts (the Morrow/Myelino "phantom-session" bug).
10
+ *
11
+ * This registry is the single seam that lets those paths reuse the LIVE per-open session id the
12
+ * server already ingested. `reportSessionStart` writes the current id here on every open; the façade
13
+ * reads it so `identify`/app-events correlate to the real session instead of minting a phantom.
14
+ *
15
+ * DEPENDENCY-FREE + PROCESS-LOCAL: a plain module variable. It is intentionally NOT persisted — it
16
+ * tracks the CURRENT process's open, and a fresh open always overwrites it. No cross-launch state.
17
+ */
18
+
19
+ let _currentSessionId: string | undefined;
20
+
21
+ /**
22
+ * Record the current per-open `session_id`. Called by `reportSessionStart` when it emits an
23
+ * app-open. A blank / non-string id is ignored (the previous id stays current). Idempotent.
24
+ */
25
+ export const setCurrentSessionId = (id: string | undefined): void => {
26
+ if (typeof id === "string" && id.length > 0) _currentSessionId = id;
27
+ };
28
+
29
+ /** The current per-open `session_id`, or `undefined` when no app-open has been registered yet. */
30
+ export const getCurrentSessionId = (): string | undefined => _currentSessionId;
31
+
32
+ /** Test-only: forget the current session id so a unit test starts from a clean registry. */
33
+ export const resetCurrentSessionId = (): void => {
34
+ _currentSessionId = undefined;
35
+ };
@@ -228,6 +228,8 @@ export const createEventQueue = (options: EventQueueOptions): EventQueue => {
228
228
  // fetch, a rejecting network, or a JSON error resolves to `false` (batch stays, retry schedules).
229
229
  const postBatch = async (events: ClientEvent[]): Promise<boolean> => {
230
230
  if (!target?.serverUrl || events.length === 0) return false;
231
+ const controller = typeof AbortController !== "undefined" ? new AbortController() : undefined;
232
+ const timer = setTimeout(() => controller?.abort(), 15_000);
231
233
  try {
232
234
  const url = `${target.serverUrl.replace(/\/$/, "")}/v1/events`;
233
235
  const headers: Record<string, string> = { "Content-Type": "application/json" };
@@ -236,10 +238,13 @@ export const createEventQueue = (options: EventQueueOptions): EventQueue => {
236
238
  method: "POST",
237
239
  headers,
238
240
  body: JSON.stringify({ events }),
241
+ signal: controller?.signal,
239
242
  });
240
243
  return !!(res && (res as { ok?: boolean }).ok);
241
244
  } catch {
242
245
  return false;
246
+ } finally {
247
+ clearTimeout(timer);
243
248
  }
244
249
  };
245
250
 
@@ -57,3 +57,6 @@ export type {
57
57
 
58
58
  // ─── The thin optional React hook over the façade ─────────────────────────────
59
59
  export { useAnalytics } from "./useAnalytics";
60
+
61
+ // ─── Current per-open session registry (identify/app-events reuse the live session) ───
62
+ export { getCurrentSessionId, setCurrentSessionId, resetCurrentSessionId } from "./currentSession";
@@ -31,6 +31,12 @@ export const useAnalytics = (
31
31
  ): Analytics => {
32
32
  // One instance per mount; kept in a ref so re-renders never rebuild the queue or drop the binding.
33
33
  const ref = useRef<Analytics | undefined>(undefined);
34
- if (!ref.current) ref.current = createAnalytics(config, options);
34
+ const prevKeys = useRef<string>("");
35
+
36
+ const currentKeys = `${config.serverUrl}|${config.apiKey}|${config.appId}`;
37
+ if (!ref.current || prevKeys.current !== currentKeys) {
38
+ prevKeys.current = currentKeys;
39
+ ref.current = createAnalytics(config, options);
40
+ }
35
41
  return ref.current;
36
42
  };
@@ -32,9 +32,31 @@ export const useScreenTracking = (
32
32
  navigationRef: NavigationRefLike | undefined,
33
33
  options: ScreenTrackerOptions = {},
34
34
  ): void => {
35
+ const optionsRef = useRef(options);
36
+ optionsRef.current = options;
37
+
35
38
  // One tracker per mount; kept in a ref so re-renders never rebuild the de-dup memory.
36
39
  const trackerRef = useRef<ScreenTracker | undefined>(undefined);
37
- if (!trackerRef.current) trackerRef.current = createScreenTracker(options);
40
+ if (!trackerRef.current) {
41
+ const dynamicOptions: ScreenTrackerOptions = {
42
+ get target() {
43
+ return optionsRef.current.target;
44
+ },
45
+ get sessionId() {
46
+ return optionsRef.current.sessionId;
47
+ },
48
+ get deviceKey() {
49
+ return optionsRef.current.deviceKey;
50
+ },
51
+ get onScreen() {
52
+ return optionsRef.current.onScreen;
53
+ },
54
+ get shouldTrack() {
55
+ return optionsRef.current.shouldTrack;
56
+ },
57
+ };
58
+ trackerRef.current = createScreenTracker(dynamicOptions);
59
+ }
38
60
 
39
61
  useEffect(() => {
40
62
  const tracker = trackerRef.current;
@@ -264,7 +264,7 @@ const _InterstitialCard: React.FC<Props> = ({
264
264
  {items?.length ? (
265
265
  <View style={[styles.items, { gap: t.spacing.md, marginTop: t.spacing.sm }]}>
266
266
  {items.map((item, i) => (
267
- <WorkItem key={item} text={item} index={i} reduced={reduced} />
267
+ <WorkItem key={`${item}-${i}`} text={item} index={i} reduced={reduced} />
268
268
  ))}
269
269
  </View>
270
270
  ) : body ? (
@@ -4,11 +4,12 @@
4
4
  * Keeps the SDK's defensive numeric coercion so a malformed LLM payload can never
5
5
  * feed NaN/Infinity into layout math.
6
6
  */
7
- import React, { useCallback, useState } from "react";
7
+ import React, { useCallback, useMemo, useState } from "react";
8
8
  import { Pressable, StyleSheet, Text, View } from "react-native";
9
9
  import { z } from "zod";
10
10
  import type { InjectedProps, WireAIComponent } from "wireai-rn";
11
11
  import { useOnboardingTheme } from "../theme/ThemeContext";
12
+ import type { OnboardingTheme } from "../theme/types";
12
13
  import { captionStyle, headingStyle } from "../theme/typography";
13
14
  import { Button } from "../components/Button";
14
15
  import { CardLayout } from "../components/CardLayout";
@@ -42,6 +43,7 @@ const _NumberStepperCard: React.FC<Props> = ({
42
43
  onSubmit,
43
44
  }) => {
44
45
  const t = useOnboardingTheme();
46
+ const styles = useMemo(() => makeStyles(t), [t]);
45
47
 
46
48
  const safeMin = safeNum(min, 1);
47
49
  const safeMaxRaw = safeNum(max, 30);
@@ -84,7 +86,7 @@ const _NumberStepperCard: React.FC<Props> = ({
84
86
  <Button title={submitLabel} onPress={handleSubmit} variant="primary" disabled={submitted} />
85
87
  }
86
88
  >
87
- <View style={{ gap: t.spacing.md, alignItems: "center" }}>
89
+ <View style={styles.wrap}>
88
90
  <View style={[styles.stepper, { gap: t.spacing.lg }]}>
89
91
  <Pressable onPress={atMin || submitted ? undefined : handleDecrement} disabled={atMin || submitted} style={stepBtnStyle(atMin)}>
90
92
  <Text style={[headingStyle(t.fonts), { color: atMin ? t.colors.textMuted : t.colors.primary }]}>{"−"}</Text>
@@ -114,8 +116,11 @@ export const NumberStepperCard: WireAIComponent = {
114
116
  defaultProps: { min: 1, max: 30, defaultValue: 1, step: 1, submitLabel: "Confirm" },
115
117
  };
116
118
 
117
- const styles = StyleSheet.create({
118
- stepper: { flexDirection: "row", alignItems: "center", justifyContent: "center" },
119
- stepBtn: { width: 44, height: 44, borderWidth: 2, alignItems: "center", justifyContent: "center" },
120
- valueBox: { alignItems: "center", minWidth: 80 },
121
- });
119
+ function makeStyles(t: OnboardingTheme) {
120
+ return StyleSheet.create({
121
+ wrap: { gap: t.spacing.md, alignItems: "center" },
122
+ stepper: { flexDirection: "row", alignItems: "center", justifyContent: "center" },
123
+ stepBtn: { width: 44, height: 44, borderWidth: 2, alignItems: "center", justifyContent: "center" },
124
+ valueBox: { alignItems: "center", minWidth: 80 },
125
+ });
126
+ }
@@ -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
+ }