@wireai/activation 0.2.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 (63) hide show
  1. package/dist/analytics/index.d.mts +69 -2
  2. package/dist/analytics/index.d.ts +69 -2
  3. package/dist/analytics/index.js +123 -2
  4. package/dist/analytics/index.js.map +1 -1
  5. package/dist/analytics/index.mjs +122 -3
  6. package/dist/analytics/index.mjs.map +1 -1
  7. package/dist/coachmarks/index.js +6 -1
  8. package/dist/coachmarks/index.js.map +1 -1
  9. package/dist/coachmarks/index.mjs +6 -1
  10. package/dist/coachmarks/index.mjs.map +1 -1
  11. package/dist/{eventQueue-CxKi7Qd5.d.mts → eventQueue-CA1d8Fmn.d.mts} +7 -3
  12. package/dist/{eventQueue-rV1dtJJR.d.ts → eventQueue-CrNB9gzH.d.ts} +7 -3
  13. package/dist/index.d.mts +5 -4
  14. package/dist/index.d.ts +5 -4
  15. package/dist/index.js +220 -151
  16. package/dist/index.js.map +1 -1
  17. package/dist/index.mjs +221 -152
  18. package/dist/index.mjs.map +1 -1
  19. package/dist/questionnaire/index.d.mts +1 -1
  20. package/dist/questionnaire/index.d.ts +1 -1
  21. package/dist/questionnaire/index.js +29 -5
  22. package/dist/questionnaire/index.js.map +1 -1
  23. package/dist/questionnaire/index.mjs +30 -6
  24. package/dist/questionnaire/index.mjs.map +1 -1
  25. package/dist/reviews/index.d.mts +1 -1
  26. package/dist/reviews/index.d.ts +1 -1
  27. package/dist/reviews/index.js +40 -9
  28. package/dist/reviews/index.js.map +1 -1
  29. package/dist/reviews/index.mjs +42 -11
  30. package/dist/reviews/index.mjs.map +1 -1
  31. package/dist/showcase/index.d.mts +1 -14
  32. package/dist/showcase/index.d.ts +1 -14
  33. package/dist/showcase/index.js +8 -2
  34. package/dist/showcase/index.js.map +1 -1
  35. package/dist/showcase/index.mjs +8 -2
  36. package/dist/showcase/index.mjs.map +1 -1
  37. package/package.json +3 -2
  38. package/src/OnboardingFlow.tsx +8 -4
  39. package/src/WireOnboarding.tsx +8 -3
  40. package/src/analytics/analyticsFacade.ts +184 -0
  41. package/src/analytics/eventQueue.ts +5 -0
  42. package/src/analytics/index.ts +12 -0
  43. package/src/analytics/reportClientEvent.ts +11 -2
  44. package/src/analytics/useAnalytics.ts +42 -0
  45. package/src/analytics/useScreenTracking.ts +23 -1
  46. package/src/cards/InterstitialCard.tsx +1 -1
  47. package/src/cards/NumberStepperCard.tsx +12 -7
  48. package/src/cards/StatusCard.tsx +13 -8
  49. package/src/cards/TextInputCard.tsx +10 -5
  50. package/src/components/AnimatedSparkle.tsx +20 -3
  51. package/src/components/Button.tsx +10 -1
  52. package/src/components/CardHandoff.tsx +2 -6
  53. package/src/components/CardLayout.tsx +16 -10
  54. package/src/components/Illustration.tsx +9 -5
  55. package/src/components/LoadingBlock.tsx +44 -29
  56. package/src/components/LoadingScreen.tsx +3 -2
  57. package/src/components/OnboardingScaffold.tsx +21 -23
  58. package/src/features/WireFeaturesProvider.tsx +7 -2
  59. package/src/questionnaire/QuestionnaireGate.tsx +15 -3
  60. package/src/reviews/ReviewGate.tsx +27 -7
  61. package/src/reviews/ReviewModal.tsx +4 -0
  62. package/src/showcase/FeatureShowcase.tsx +2 -1
  63. package/src/components/loaderChrome.ts +0 -28
@@ -0,0 +1,184 @@
1
+ /**
2
+ * analyticsFacade — a Segment/PostHog-shaped developer API (`track` / `screen` / `identify`)
3
+ * over the kit's OWN offline-first event queue. One package, one key, one line:
4
+ *
5
+ * const analytics = createAnalytics({ serverUrl, apiKey, storage });
6
+ * analytics.track("content_share", { source: "feed" });
7
+ * analytics.screen("Home", { tab: "explore" });
8
+ * analytics.identify("u_123", { plan: "pro" });
9
+ *
10
+ * WHY a façade: the primitives already exist (`createEventQueue` for durable offline-first
11
+ * transport, `buildContextEnvelope` for the non-PII device context, the `identify` client-event
12
+ * contract for user binding), but a host had to wire them together by hand. This composes them
13
+ * into the familiar analytics-SDK surface so a consumer gets the ergonomics with NO second
14
+ * install and NO second key — the same `{ serverUrl, apiKey }` creds as onboarding.
15
+ *
16
+ * ROUTING: every call builds a `ClientEvent` and `enqueue`s it. The queue stamps the context
17
+ * envelope (device + host scalars), persists offline, batches, retries, and dequeues on ack —
18
+ * so `track`/`screen`/`identify` inherit offline-first durability for free. `screen` routes
19
+ * through the SAME queue rather than the direct `reportAppEvent` POST (a deliberate upgrade:
20
+ * screen views survive being offline too).
21
+ *
22
+ * DEPENDENCY-FREE + TREE-SHAKEABLE: this module imports ONLY the queue, the envelope builder,
23
+ * the client-event types + session-id seed, and the identity sanitizer. It reaches NO
24
+ * onboarding / showcase / review UI, so an analytics-only consumer bundles none of it. React is
25
+ * absent here on purpose — the optional React glue is the thin `useAnalytics` hook.
26
+ *
27
+ * FIRE-AND-FORGET: no method throws into the UI or blocks — the queue already guarantees that.
28
+ */
29
+ import { buildContextEnvelope, type ContextEnvelope } from "./contextEnvelope";
30
+ import { createEventQueue, type EventQueue, type EventQueueOptions } from "./eventQueue";
31
+ import { makeSessionId, type ClientEvent } from "./reportClientEvent";
32
+ import { sanitizeUserId } from "../identity/userIdentity";
33
+
34
+ /** Arbitrary non-PII event properties. Serialized to the event's `meta` (a JSON string) on the wire. */
35
+ export type AnalyticsProps = Record<string, unknown>;
36
+
37
+ /**
38
+ * Tenant transport + context inputs for {@link createAnalytics}. `serverUrl`/`apiKey` are the
39
+ * SAME creds as onboarding (never a second key). The rest feed the context envelope + the queue's
40
+ * offline persistence — all optional.
41
+ */
42
+ export type CreateAnalyticsConfig = {
43
+ /** Base server URL (same as `WireOnboardingConfig.serverUrl`); `/v1/events` is appended. */
44
+ serverUrl: string;
45
+ /** Tenant API key; sent as `Authorization: Bearer`. */
46
+ apiKey: string;
47
+ /**
48
+ * Correlation id shared by every event from this instance (and the `identify` event). Defaults
49
+ * to a fresh `makeSessionId()` at creation so all events agree on one id per analytics instance.
50
+ */
51
+ sessionId?: string;
52
+ /** Tenant/app id used to namespace the queue's default storage key (`wireai:evtq:<appId>`). */
53
+ appId?: string;
54
+ /**
55
+ * Host persistence (AsyncStorage-compatible subset) for offline-first durability. When omitted,
56
+ * the queue runs in the documented in-memory mode (survives re-renders, not app kills).
57
+ */
58
+ storage?: EventQueueOptions["storage"];
59
+ /** Host app version, e.g. "1.4.2" (host-injected; stamped onto every event's context). */
60
+ appVersion?: string;
61
+ /** Host native build number, e.g. "412" (host-injected). */
62
+ appBuild?: string;
63
+ /** Host connectivity signal, e.g. "wifi" | "cellular" — read fresh per event via the provider. */
64
+ networkType?: string;
65
+ };
66
+
67
+ /** Optional queue tuning knobs, forwarded verbatim to {@link createEventQueue}. */
68
+ export type AnalyticsOptions = Partial<
69
+ Pick<EventQueueOptions, "maxSize" | "batchSize" | "baseBackoffMs" | "maxBackoffMs" | "maxRetries">
70
+ >;
71
+
72
+ /**
73
+ * The developer-facing analytics surface. `track`/`screen`/`identify` are the Segment/PostHog-shaped
74
+ * API; `flush`/`notifyOnline`/`size` expose the underlying queue so a host can drive reconnect
75
+ * draining (load-bearing for offline-first) and inspect the pending buffer.
76
+ */
77
+ export type Analytics = {
78
+ /** Record an in-app event: `event_type='app_event'`, `question_key=<event>`, `props`→`meta`. */
79
+ track(event: string, props?: AnalyticsProps): void;
80
+ /** Record a screen view: `event_type='app_event'`, `question_key='screen'`, `meta={ screen, ...props }`. */
81
+ screen(name: string, props?: AnalyticsProps): void;
82
+ /** Bind the host's opaque user id (per-session, in-memory) and emit an `identify` event. */
83
+ identify(userId: string, traits?: AnalyticsProps): void;
84
+ /** Attempt an immediate drain of the pending buffer. Fire-and-forget. */
85
+ flush(): void;
86
+ /** Host reconnect signal: reset backoff and drain now. Fire-and-forget. */
87
+ notifyOnline(): void;
88
+ /** Current pending (in-memory) count. */
89
+ size(): number;
90
+ };
91
+
92
+ /**
93
+ * Create a bound analytics instance. Seeds one correlation `sessionId`, builds an offline-first
94
+ * queue over the tenant transport, and passes a fresh-per-event context envelope PROVIDER so the
95
+ * connectivity type is read at enqueue time. The bound user id starts unset (see `identify`).
96
+ */
97
+ export const createAnalytics = (
98
+ config: CreateAnalyticsConfig,
99
+ options: AnalyticsOptions = {},
100
+ ): Analytics => {
101
+ const sessionId = config.sessionId ?? makeSessionId();
102
+
103
+ // A provider (not a fixed value) so `networkType` is evaluated fresh on every enqueue.
104
+ const envelope = (): ContextEnvelope =>
105
+ buildContextEnvelope({
106
+ sessionId,
107
+ appVersion: config.appVersion,
108
+ appBuild: config.appBuild,
109
+ networkType: config.networkType,
110
+ });
111
+
112
+ const queue: EventQueue = createEventQueue({
113
+ target: { serverUrl: config.serverUrl, apiKey: config.apiKey },
114
+ storage: config.storage,
115
+ appId: config.appId,
116
+ envelope,
117
+ ...options,
118
+ });
119
+
120
+ // Per-session, in-memory user binding. Persisted across launches when storage is provided.
121
+ let boundUserId: string | undefined;
122
+ const storageKey = `wireai:analytics:userId:${config.appId ?? "default"}`;
123
+
124
+ if (config.storage) {
125
+ void config.storage
126
+ .getItem(storageKey)
127
+ .then((saved) => {
128
+ if (saved) boundUserId = saved;
129
+ })
130
+ .catch(() => {});
131
+ }
132
+
133
+ const track = (event: string, props?: AnalyticsProps): void => {
134
+ if (!event) return;
135
+ const clientEvent: ClientEvent = {
136
+ event_type: "app_event",
137
+ session_id: sessionId,
138
+ question_key: event,
139
+ };
140
+ if (props && Object.keys(props).length > 0) clientEvent.meta = JSON.stringify(props);
141
+ if (boundUserId) clientEvent.user_id = boundUserId;
142
+ queue.enqueue(clientEvent);
143
+ };
144
+
145
+ const screen = (name: string, props?: AnalyticsProps): void => {
146
+ if (!name) return;
147
+ // Reuse the K6 screen event shape (question_key='screen'); route through the queue for offline-first.
148
+ const meta = { screen: name, ...(props ?? {}) };
149
+ const clientEvent: ClientEvent = {
150
+ event_type: "app_event",
151
+ session_id: sessionId,
152
+ question_key: "screen",
153
+ meta: JSON.stringify(meta),
154
+ };
155
+ if (boundUserId) clientEvent.user_id = boundUserId;
156
+ queue.enqueue(clientEvent);
157
+ };
158
+
159
+ const identify = (userId: string, traits?: AnalyticsProps): void => {
160
+ const clean = sanitizeUserId(userId);
161
+ // Blank / non-string → no binding, no event (sanitizeUserId returns undefined). >128 → truncated.
162
+ if (!clean) return;
163
+ boundUserId = clean;
164
+ if (config.storage) {
165
+ void config.storage.setItem(storageKey, clean).catch(() => {});
166
+ }
167
+ const clientEvent: ClientEvent = {
168
+ event_type: "identify",
169
+ session_id: sessionId,
170
+ user_id: clean,
171
+ };
172
+ if (traits && Object.keys(traits).length > 0) clientEvent.meta = JSON.stringify(traits);
173
+ queue.enqueue(clientEvent);
174
+ };
175
+
176
+ return {
177
+ track,
178
+ screen,
179
+ identify,
180
+ flush: queue.flush,
181
+ notifyOnline: queue.notifyOnline,
182
+ size: queue.size,
183
+ };
184
+ };
@@ -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
 
@@ -45,3 +45,15 @@ export type { ContextEnvelope, ContextEnvelopeInput } from "./contextEnvelope";
45
45
  // ─── Offline-first, persistent, batched + retried event queue (dependency-free) ─
46
46
  export { createEventQueue } from "./eventQueue";
47
47
  export type { EventQueue, EventQueueOptions, EnvelopeSource } from "./eventQueue";
48
+
49
+ // ─── Developer-facing analytics façade (track / screen / identify) over the queue ─
50
+ export { createAnalytics } from "./analyticsFacade";
51
+ export type {
52
+ Analytics,
53
+ AnalyticsOptions,
54
+ AnalyticsProps,
55
+ CreateAnalyticsConfig,
56
+ } from "./analyticsFacade";
57
+
58
+ // ─── The thin optional React hook over the façade ─────────────────────────────
59
+ export { useAnalytics } from "./useAnalytics";
@@ -35,8 +35,17 @@ import type { DeviceContext } from "../device/deviceContext";
35
35
  * This is the SINGLE fallback signal — hosts must NOT also report their own.
36
36
  * `identify` binds the host's opaque `user_id` to this `session_id` (late binding — the user
37
37
  * registered during/after onboarding). It carries no funnel weight; the server maps the
38
- * session to the user and back-fills a `session_started` if it never saw the session. */
39
- export type ClientEventType = "screen_skipped" | "dropped" | "client_fallback" | "identify";
38
+ * session to the user and back-fills a `session_started` if it never saw the session.
39
+ * `app_event` is the host's own in-app event (the `createAnalytics` façade's `track`/`screen`
40
+ * route through the offline queue as first-class `ClientEvent`s). The server already ingests
41
+ * it — `reportAppEvent` (reviews/transport) has fired `event_type='app_event'` all along; this
42
+ * widening just lets the same shape flow through the durable queue instead of a blind POST. */
43
+ export type ClientEventType =
44
+ | "screen_skipped"
45
+ | "dropped"
46
+ | "client_fallback"
47
+ | "identify"
48
+ | "app_event";
40
49
 
41
50
  /** One client-reported event. Mirrors the server's `OnboardingEvent` (client-settable fields). */
42
51
  export type ClientEvent = {
@@ -0,0 +1,42 @@
1
+ /**
2
+ * useAnalytics — a THIN optional React hook over the pure {@link createAnalytics} factory.
3
+ *
4
+ * It builds ONE analytics instance for the component's lifetime and returns it, so re-renders
5
+ * never rebuild the queue or lose the in-memory user binding. React is a REQUIRED peer of the
6
+ * kit, so importing it here is allowed; the hook adds NO other dependency. Mirrors the existing
7
+ * `createScreenTracker` / `useScreenTracking` split — the factory stays React-free, this is the
8
+ * glue.
9
+ *
10
+ * const analytics = useAnalytics({ serverUrl, apiKey, storage, appId });
11
+ * analytics.track("content_share", { source: "feed" });
12
+ * // ...on reconnect: analytics.notifyOnline();
13
+ */
14
+ import { useRef } from "react";
15
+
16
+ import {
17
+ createAnalytics,
18
+ type Analytics,
19
+ type AnalyticsOptions,
20
+ type CreateAnalyticsConfig,
21
+ } from "./analyticsFacade";
22
+
23
+ /**
24
+ * Build a per-mount analytics instance. `config`/`options` are read once at first render (the
25
+ * instance is stable for the component's lifetime, held in a ref). Returns the {@link Analytics}
26
+ * surface so the component can `track` / `screen` / `identify` and drive `notifyOnline` on reconnect.
27
+ */
28
+ export const useAnalytics = (
29
+ config: CreateAnalyticsConfig,
30
+ options: AnalyticsOptions = {},
31
+ ): Analytics => {
32
+ // One instance per mount; kept in a ref so re-renders never rebuild the queue or drop the binding.
33
+ const ref = useRef<Analytics | undefined>(undefined);
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
+ }
41
+ return ref.current;
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
+ }
@@ -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 => {