@wireai/activation 0.7.0 → 0.9.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 (94) hide show
  1. package/AGENTS.md +29 -5
  2. package/CHANGELOG.md +198 -6
  3. package/README.md +105 -10
  4. package/dist/analytics/index.d.mts +12 -8
  5. package/dist/analytics/index.d.ts +12 -8
  6. package/dist/analytics/index.js +525 -55
  7. package/dist/analytics/index.js.map +1 -1
  8. package/dist/analytics/index.mjs +525 -55
  9. package/dist/analytics/index.mjs.map +1 -1
  10. package/dist/coachmarks/index.d.mts +2 -2
  11. package/dist/coachmarks/index.d.ts +2 -2
  12. package/dist/coachmarks/index.js.map +1 -1
  13. package/dist/coachmarks/index.mjs.map +1 -1
  14. package/dist/{currentSession-CxnP7gAa.d.ts → currentSession-61dcm3V-.d.ts} +19 -3
  15. package/dist/{currentSession-BJBB7i4-.d.mts → currentSession-Bs2JfTJ8.d.mts} +19 -3
  16. package/dist/{decision-BzbiKwk3.d.mts → decision-Bl_M2y3r.d.mts} +1 -1
  17. package/dist/{decision-plDEOCkt.d.ts → decision-Cau5KmP6.d.ts} +1 -1
  18. package/dist/index.d.mts +620 -13
  19. package/dist/index.d.ts +620 -13
  20. package/dist/index.js +1194 -369
  21. package/dist/index.js.map +1 -1
  22. package/dist/index.mjs +1015 -201
  23. package/dist/index.mjs.map +1 -1
  24. package/dist/questionnaire/index.d.mts +69 -23
  25. package/dist/questionnaire/index.d.ts +69 -23
  26. package/dist/questionnaire/index.js +643 -172
  27. package/dist/questionnaire/index.js.map +1 -1
  28. package/dist/questionnaire/index.mjs +608 -137
  29. package/dist/questionnaire/index.mjs.map +1 -1
  30. package/dist/reviews/index.d.mts +24 -12
  31. package/dist/reviews/index.d.ts +24 -12
  32. package/dist/reviews/index.js +507 -173
  33. package/dist/reviews/index.js.map +1 -1
  34. package/dist/reviews/index.mjs +462 -129
  35. package/dist/reviews/index.mjs.map +1 -1
  36. package/dist/showcase/index.d.mts +2 -2
  37. package/dist/showcase/index.d.ts +2 -2
  38. package/dist/showcase/index.js.map +1 -1
  39. package/dist/showcase/index.mjs.map +1 -1
  40. package/dist/transport-DzU-TqZz.d.ts +86 -0
  41. package/dist/transport-f5VVB5hH.d.mts +86 -0
  42. package/dist/{types-GL_hQ0TN.d.mts → types-BcmagF6K.d.mts} +1 -1
  43. package/dist/{types-GL_hQ0TN.d.ts → types-BcmagF6K.d.ts} +1 -1
  44. package/dist/{types-BhpXJGlg.d.ts → types-Buj9Lw9t.d.ts} +15 -3
  45. package/dist/{types-CMuOexw0.d.mts → types-CKFhyrMu.d.mts} +1 -1
  46. package/dist/{types-CMuOexw0.d.ts → types-CKFhyrMu.d.ts} +1 -1
  47. package/dist/{types-A6pTxIZV.d.mts → types-CNUqMK0D.d.mts} +15 -3
  48. package/metro/index.d.ts +18 -1
  49. package/metro/index.js +80 -0
  50. package/package.json +9 -1
  51. package/src/WireOnboarding.tsx +4 -0
  52. package/src/analytics/analyticsFacade.ts +55 -10
  53. package/src/analytics/currentSession.ts +1 -1
  54. package/src/cards/CardGridSelectCard.tsx +253 -0
  55. package/src/cards/ChipSelectCard.tsx +1 -1
  56. package/src/cards/SelectionCard.tsx +15 -19
  57. package/src/cards/index.ts +13 -1
  58. package/src/cards/optionSchema.ts +65 -0
  59. package/src/coachmarks/types.ts +1 -1
  60. package/src/components/CardHandoff.tsx +39 -7
  61. package/src/components/DoneCheck.tsx +94 -0
  62. package/src/components/ModalPaneHost.tsx +141 -0
  63. package/src/components/StepDots.tsx +112 -0
  64. package/src/context/deviceId.ts +43 -0
  65. package/src/device/appVersion.ts +29 -8
  66. package/src/device/deviceContext.ts +14 -1
  67. package/src/device/deviceModel.ts +106 -0
  68. package/src/features/cache.ts +1 -1
  69. package/src/haptics/expo-haptics.d.ts +31 -0
  70. package/src/haptics/haptics.ts +58 -0
  71. package/src/icons/IconRegistry.tsx +53 -0
  72. package/src/icons/WireIcon.tsx +85 -0
  73. package/src/icons/expoIcons.ts +165 -0
  74. package/src/icons/index.ts +18 -0
  75. package/src/icons/vocabulary.ts +154 -0
  76. package/src/index.ts +22 -0
  77. package/src/questionnaire/QuestionnaireGate.tsx +315 -164
  78. package/src/questionnaire/decision.ts +4 -2
  79. package/src/questionnaire/index.ts +3 -2
  80. package/src/questionnaire/steps.ts +261 -0
  81. package/src/questionnaire/types.ts +33 -12
  82. package/src/reviews/ReviewGate.tsx +250 -152
  83. package/src/reviews/index.ts +7 -3
  84. package/src/reviews/transport.ts +77 -3
  85. package/src/reviews/types.ts +13 -0
  86. package/src/session/persistedSession.ts +1 -1
  87. package/src/session-analytics/lifecycle.ts +1 -1
  88. package/src/session-analytics/useSessionStart.ts +2 -2
  89. package/src/showcase/FeatureShowcase.tsx +1 -1
  90. package/src/showcase/showcaseColors.ts +1 -1
  91. package/src/theme/mergeThemeOver.ts +27 -0
  92. package/src/types.ts +8 -0
  93. package/dist/transport-BeO_Brcu.d.mts +0 -40
  94. package/dist/transport-DLpd1v5_.d.ts +0 -40
@@ -13,7 +13,7 @@ type WireOnboardingStorage = {
13
13
  * this can't be resumed server-side anyway, so the client mints fresh past it.
14
14
  */
15
15
  declare const DEFAULT_SESSION_TTL_MS = 3600000;
16
- /** Storage key for an app's cached session, e.g. `wireai:session:myelino`. */
16
+ /** Storage key for an app's cached session, e.g. `wireai:session:acme`. */
17
17
  declare const sessionStorageKey: (appId: string) => string;
18
18
  /** Resolved session for a mount: the id to use + whether it was restored from storage. */
19
19
  type LoadedSession = {
@@ -1,6 +1,6 @@
1
1
  import { O as OnboardingTheme } from './types-BKfpdZzX.mjs';
2
- import { C as CoachmarkStorage } from './types-GL_hQ0TN.mjs';
3
- import { W as WireFeatures, a as WireFeaturesConfig } from './types-CMuOexw0.mjs';
2
+ import { C as CoachmarkStorage } from './types-BcmagF6K.mjs';
3
+ import { W as WireFeatures, a as WireFeaturesConfig } from './types-CKFhyrMu.mjs';
4
4
 
5
5
  /**
6
6
  * The firing verdict — the SAME contract on both sides of the seam. `decideReview`
@@ -11,6 +11,18 @@ interface ReviewDecision {
11
11
  fire: boolean;
12
12
  reason?: string;
13
13
  }
14
+ /**
15
+ * The `GET /v1/reviews/decision` response: `ReviewDecision` plus the additive `arm`.
16
+ *
17
+ * `arm` is the firing experiment's sticky assignment for this device/session. Echo it back in
18
+ * the submission's `meta.firing_arm` and the server keeps it verbatim (`_stamp_firing_arm`:
19
+ * "an arm the kit already echoed is left as-is"); omit it and the server recomputes from the
20
+ * sticky hash. Echoing is what makes per-arm attribution survive a later reweighting of the
21
+ * experiment, so a host running one should pass it through.
22
+ */
23
+ interface ReviewDecisionResponse extends ReviewDecision {
24
+ arm?: string | null;
25
+ }
14
26
  /** Where to route a rating: 5 stars → the store, 1-4 → the feedback form. */
15
27
  type RatingRoute = "store" | "feedback";
16
28
  /**
@@ -149,4 +161,4 @@ interface ReviewGateController {
149
161
  markResolved: () => void;
150
162
  }
151
163
 
152
- export type { ReviewGateProps as R, UseReviewGateOptions as U, ReviewPresentation as a, ReviewGateController as b, ReviewDecision as c, ReviewStoreConfig as d, RatingRoute as e, ReviewConfig as f, ReviewGateEvent as g, ReviewGateEventName as h, ReviewSubmission as i, ReviewTarget as j };
164
+ export type { ReviewGateProps as R, UseReviewGateOptions as U, ReviewPresentation as a, ReviewGateController as b, ReviewDecision as c, ReviewStoreConfig as d, RatingRoute as e, ReviewConfig as f, ReviewDecisionResponse as g, ReviewGateEvent as h, ReviewGateEventName as i, ReviewSubmission as j, ReviewTarget as k };
package/metro/index.d.ts CHANGED
@@ -16,12 +16,20 @@ export interface WithWireOnboardingOptions {
16
16
  * When omitted, the kit resolves from the installed package.
17
17
  */
18
18
  source?: string;
19
+ /**
20
+ * Extra specifiers to treat as optional, i.e. resolve to an EMPTY module instead of
21
+ * failing the build when they are not installed. Added to the kit's own
22
+ * `OPTIONAL_MODULES` list, never replacing it.
23
+ */
24
+ optionalModules?: string[];
19
25
  }
20
26
 
21
27
  /**
22
28
  * Non-destructively wire the @wireai/activation kit into an existing Metro config:
23
29
  * pins single-instance deps to the app's copy, optionally maps the kit specifier
24
- * to source + watches it. Mutates and returns the same config object.
30
+ * to source + watches it, and makes the kit's optional modules genuinely optional
31
+ * (an absent one resolves to an empty module instead of breaking the build).
32
+ * Mutates and returns the same config object.
25
33
  */
26
34
  export function withWireOnboarding<TConfig extends Record<string, any>>(
27
35
  config: TConfig,
@@ -30,3 +38,12 @@ export function withWireOnboarding<TConfig extends Record<string, any>>(
30
38
 
31
39
  /** The deps pinned to the app's single copy. */
32
40
  export const SINGLE_INSTANCE_DEPS: readonly ['react', 'react-native', 'wireai-rn', 'zod'];
41
+
42
+ /**
43
+ * The modules the kit reads through a guarded require and can live without. When one of
44
+ * these does not resolve, `withWireOnboarding` maps it to an empty module so the host
45
+ * still bundles and the feature degrades (icon → null, analytics field omitted, native
46
+ * review → store-URL fallback). Modules the kit imports statically are deliberately NOT
47
+ * in this list — stubbing those would hide a real build error.
48
+ */
49
+ export const OPTIONAL_MODULES: readonly string[];
package/metro/index.js CHANGED
@@ -26,6 +26,8 @@
26
26
  * @param {string} [options.source] Absolute path to the kit's `src/` dir. When
27
27
  * set, the kit specifier maps here (source consumption) and it's added to
28
28
  * watchFolders; when omitted, the kit resolves from the installed package.
29
+ * @param {string[]} [options.optionalModules] Extra specifiers to treat as
30
+ * optional (see OPTIONAL_MODULES). Added to the kit's list, never replacing it.
29
31
  * @returns {object} The same config object, mutated in place and returned.
30
32
  */
31
33
  'use strict';
@@ -35,6 +37,60 @@ const path = require('path');
35
37
  /** Deps that MUST resolve to the app's single copy (a 2nd React instance crashes RN). */
36
38
  const SINGLE_INSTANCE_DEPS = ['react', 'react-native', 'wireai-rn', 'zod'];
37
39
 
40
+ /**
41
+ * Modules the kit reads through a GUARDED require/import and is designed to live without.
42
+ *
43
+ * ── HOW OPTIONAL DEPS ACTUALLY WORK UNDER METRO (verified, not folklore) ────────────────
44
+ *
45
+ * Metro has NO runtime module resolution: every specifier is resolved when the bundle is BUILT.
46
+ * But it DOES have a first-class optional-dependency mechanism, and it needs two things:
47
+ *
48
+ * 1. a STRING-LITERAL specifier — Metro's collector only matches `require("literal")` /
49
+ * `import("literal")`. A variable specifier collects NOTHING, so the module never enters
50
+ * the bundle at all (that was the 0.8.0 bug; see src/icons/expoIcons.ts), and
51
+ * 2. the call sitting inside a TRY/CATCH — that is literally how Metro decides a dependency
52
+ * is optional (`isOptional`), and it only counts when the transformer has
53
+ * `allowOptionalDependencies` enabled. Expo's `getDefaultConfig` enables it; plain
54
+ * `metro-config` defaults it to FALSE.
55
+ *
56
+ * When both hold and the module is absent, Metro puts `null` in the module's dependencyMap, and
57
+ * `metroRequire(null, "name")` throws "Cannot find module" — synchronously, from metroRequire
58
+ * itself, so the caller's own catch swallows it. Verified in a real bundle: haptics' dependencyMap
59
+ * is `[355,null,1300]` with `expo-haptics` uninstalled. So DO NOT "simplify" a try/catch away from
60
+ * around a guarded require: the try/catch is load-bearing, and removing it turns an absent peer
61
+ * into a hard build failure.
62
+ *
63
+ * ── WHY THIS LIST STILL EXISTS ─────────────────────────────────────────────────────────
64
+ *
65
+ * Mechanism (2) is off by default in bare Metro (`allowOptionalDependencies: false`), and a host
66
+ * can turn it off. There, an absent optional module fails the BUILD with "Unable to resolve
67
+ * module". So `resolveRequest` below maps an absent module on this list to an EMPTY module, which
68
+ * lands on each caller's existing "peer absent" degradation path (no exports on the namespace →
69
+ * undefined → icon renders null / analytics field omitted / native review → store-URL fallback).
70
+ * It is a safety net that makes optionality independent of the host's Metro settings — not the
71
+ * only thing holding it up.
72
+ *
73
+ * ONLY modules read through a guarded require/import belong here. A module the kit imports
74
+ * STATICALLY at module scope (react-native-reanimated, expo-blur,
75
+ * @blazejkustra/react-native-onboarding) must NOT be stubbed: an empty module would turn a loud
76
+ * build-time "Unable to resolve module" into a silent `undefined` component that crashes at
77
+ * render, which is strictly worse. `test/canary/metroResolution.test.tsx` keeps this list in sync
78
+ * with the source by asking Metro which deps it marks optional.
79
+ */
80
+ const OPTIONAL_MODULES = [
81
+ // src/icons/expoIcons.ts — icon families; absent → WireIcon renders null.
82
+ '@expo/vector-icons',
83
+ // src/device/appVersion.ts — analytics `by_app_version`; absent → field omitted.
84
+ 'expo-constants',
85
+ 'expo-application',
86
+ // src/device/deviceModel.ts — analytics iOS `by_model`; absent → field omitted.
87
+ 'expo-device',
88
+ // src/reviews/storeReview.ts — native review prompt; absent → store-URL fallback.
89
+ 'expo-store-review',
90
+ // src/haptics/haptics.ts — haptic feedback; absent → silent no-op.
91
+ 'expo-haptics',
92
+ ];
93
+
38
94
  function withWireOnboarding(config, options) {
39
95
  const opts = options || {};
40
96
  const appRoot = opts.appRoot || process.cwd();
@@ -67,6 +123,29 @@ function withWireOnboarding(config, options) {
67
123
  ...existingExtra,
68
124
  };
69
125
 
126
+ // ── resolveRequest: make the optional modules genuinely optional ────────────
127
+ // Non-destructive by construction: the app's own resolveRequest (or Metro's default) is asked
128
+ // FIRST and keeps full authority. We only act on the failure of an OPTIONAL specifier, turning
129
+ // "Unable to resolve module" into an empty module so a host that never installed the peer still
130
+ // bundles. A non-optional module's failure is rethrown untouched — a missing `react` must stay
131
+ // a hard, loud build error.
132
+ const optional = new Set([...OPTIONAL_MODULES, ...(opts.optionalModules || [])]);
133
+ const upstream = resolver.resolveRequest;
134
+
135
+ resolver.resolveRequest = (context, moduleName, platform) => {
136
+ const resolve = upstream || context.resolveRequest;
137
+ try {
138
+ return resolve(context, moduleName, platform);
139
+ } catch (error) {
140
+ // Match the package root and its subpaths ('@expo/vector-icons/build/Icons').
141
+ const isOptional = [...optional].some(
142
+ (name) => moduleName === name || moduleName.startsWith(name + '/'),
143
+ );
144
+ if (!isOptional) throw error;
145
+ return { type: 'empty' };
146
+ }
147
+ };
148
+
70
149
  // ── watchFolders: add the kit source so Metro reads + HMRs it ───────────────
71
150
  if (source) {
72
151
  const existingWatch = Array.isArray(config.watchFolders) ? config.watchFolders : [];
@@ -81,3 +160,4 @@ function withWireOnboarding(config, options) {
81
160
  module.exports = { withWireOnboarding };
82
161
  module.exports.withWireOnboarding = withWireOnboarding;
83
162
  module.exports.SINGLE_INSTANCE_DEPS = SINGLE_INSTANCE_DEPS;
163
+ module.exports.OPTIONAL_MODULES = OPTIONAL_MODULES;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wireai/activation",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "private": false,
5
5
  "description": "Premium, fully-themable drop-in AI onboarding kit for React Native / Expo, on top of the open-source wireai-rn SDK.",
6
6
  "author": "Malik Chohra <malik@getwireai.com>",
@@ -143,7 +143,9 @@
143
143
  },
144
144
  "peerDependencies": {
145
145
  "@blazejkustra/react-native-onboarding": ">=1",
146
+ "@expo/vector-icons": ">=14",
146
147
  "expo-blur": ">=13",
148
+ "expo-haptics": ">=13",
147
149
  "expo-store-review": ">=6",
148
150
  "react": ">=18.0.0",
149
151
  "react-native": ">=0.73.0",
@@ -156,12 +158,18 @@
156
158
  "zod": {
157
159
  "optional": false
158
160
  },
161
+ "@expo/vector-icons": {
162
+ "optional": true
163
+ },
159
164
  "react-native-reanimated": {
160
165
  "optional": true
161
166
  },
162
167
  "expo-blur": {
163
168
  "optional": true
164
169
  },
170
+ "expo-haptics": {
171
+ "optional": true
172
+ },
165
173
  "expo-store-review": {
166
174
  "optional": true
167
175
  },
@@ -16,6 +16,7 @@ import { WireAIProvider, type LocalLLMConfig } from "wireai-rn";
16
16
  import { OnboardingThemeProvider } from "./theme/ThemeContext";
17
17
  import { IllustrationProvider } from "./components/Illustration";
18
18
  import { defaultIllustrations } from "./illustrations/defaultIllustrations";
19
+ import { IconRegistryProvider } from "./icons/IconRegistry";
19
20
  import { LoadingScreen } from "./components/LoadingScreen";
20
21
  import { OnboardingFlow, DEFAULT_COPY } from "./OnboardingFlow";
21
22
  import { onboardingComponents } from "./cards";
@@ -36,6 +37,7 @@ export const WireOnboarding: React.FC<WireOnboardingProps> = ({
36
37
  theme,
37
38
  components,
38
39
  illustrations,
40
+ icons,
39
41
  validators,
40
42
  onComplete,
41
43
  onSkip,
@@ -229,6 +231,7 @@ export const WireOnboarding: React.FC<WireOnboardingProps> = ({
229
231
  return (
230
232
  <OnboardingThemeProvider theme={theme}>
231
233
  <IllustrationProvider registry={{ ...defaultIllustrations, ...illustrations }}>
234
+ <IconRegistryProvider registry={icons}>
232
235
  <WireAIProvider llm={llm} components={cards}>
233
236
  <OnboardingFlow
234
237
  validators={validators}
@@ -248,6 +251,7 @@ export const WireOnboarding: React.FC<WireOnboardingProps> = ({
248
251
  clientContext={clientContext}
249
252
  />
250
253
  </WireAIProvider>
254
+ </IconRegistryProvider>
251
255
  </IllustrationProvider>
252
256
  </OnboardingThemeProvider>
253
257
  );
@@ -31,6 +31,7 @@ import { getCurrentSessionId } from "./currentSession";
31
31
  import { createEventQueue, type EventQueue, type EventQueueOptions } from "./eventQueue";
32
32
  import { makeSessionId, type ClientEvent } from "./reportClientEvent";
33
33
  import { resolveUserContext, type WireUserContext } from "../context/userContext";
34
+ import { mintDeviceId, deviceIdStorageKey } from "../context/deviceId";
34
35
  import { sanitizeUserId } from "../identity/userIdentity";
35
36
 
36
37
  /** Arbitrary non-PII event properties. Serialized to the event's `meta` (a JSON string) on the wire. */
@@ -67,8 +68,12 @@ export type CreateAnalyticsConfig = {
67
68
  /**
68
69
  * The rich {@link WireUserContext} to stamp onto every event's `user_context` (device key, opaque
69
70
  * 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.
71
+ * {@link Analytics.setUserContext} (e.g. attach `userId`/`userEmail` at login). Optional.
72
+ *
73
+ * NOTE on `deviceKey`: you do NOT need to supply one. When omitted, the kit auto-mints a stable,
74
+ * non-PII per-install `device_key`, persists it via `storage`, and reuses it every open (in-memory
75
+ * fallback without storage) — so `user_context.device_key` is ALWAYS present for the server's
76
+ * review/questionnaire gating + A/B stickiness. Supply `deviceKey` only to use your OWN id (it wins).
72
77
  */
73
78
  userContext?: WireUserContext;
74
79
  };
@@ -124,12 +129,47 @@ export const createAnalytics = (
124
129
  const resolveSessionId = (): string =>
125
130
  config.sessionId ?? getCurrentSessionId() ?? instanceSessionId;
126
131
 
127
- // A provider (not a fixed value) so `networkType` + the current session id are evaluated fresh
128
- // on every enqueue.
132
+ // The mutable rich user-context: seeded at init, updated via `setUserContext`. Resolved fresh on
133
+ // every event so a post-mount update (login) takes effect immediately. Declared before the envelope
134
+ // provider so the provider can read the current `userContext.appVersion` (see below).
135
+ let userContext: WireUserContext = { ...(config.userContext ?? {}) };
136
+
137
+ // Auto device id (the headline: "device" fully automatic). When the host supplies NO `deviceKey`,
138
+ // the kit mints ONE stable, non-PII per-install id, PERSISTS it via the host `storage`, and reuses it
139
+ // on every subsequent open — so `user_context.device_key` is ALWAYS present (the server's
140
+ // review/questionnaire gating + A/B stickiness both key on it) with zero host wiring. A host-supplied
141
+ // `deviceKey` still wins (see `applyContext`). Falls back to an in-memory id (stable for this
142
+ // instance) when no storage is available.
143
+ const hostDeviceKeyAtInit =
144
+ typeof config.userContext?.deviceKey === "string" && config.userContext.deviceKey.trim()
145
+ ? config.userContext.deviceKey.trim()
146
+ : undefined;
147
+ // Minted synchronously so `device_key` is never missing, even before the async storage read resolves.
148
+ let autoDeviceKey = mintDeviceId();
149
+ if (config.storage && !hostDeviceKeyAtInit) {
150
+ const storage = config.storage;
151
+ const deviceKey = deviceIdStorageKey(config.appId);
152
+ void storage
153
+ .getItem(deviceKey)
154
+ .then((saved) => {
155
+ const persisted = typeof saved === "string" && saved.trim() ? saved.trim() : undefined;
156
+ // Reuse the persisted per-install id across opens; on first run persist the freshly minted one.
157
+ if (persisted) autoDeviceKey = persisted;
158
+ else void storage.setItem(deviceKey, autoDeviceKey).catch(() => {});
159
+ })
160
+ .catch(() => {});
161
+ }
162
+
163
+ // A provider (not a fixed value) so `networkType`, the current session id, AND the effective app
164
+ // version are evaluated fresh on every enqueue. An explicit `WireUserContext.appVersion` (a host that
165
+ // set the version ONLY inside `userContext`) now flows into `device.appVersion` too — not just
166
+ // `user_context.app_version` — so the server's `by_app_version` breakdown (which reads
167
+ // `device.appVersion`) agrees. Explicit wins over the auto-detected device version;
168
+ // `buildContextEnvelope` keeps the auto value when neither is set.
129
169
  const envelope = (): ContextEnvelope =>
130
170
  buildContextEnvelope({
131
171
  sessionId: resolveSessionId(),
132
- appVersion: config.appVersion,
172
+ appVersion: userContext.appVersion ?? config.appVersion,
133
173
  appBuild: config.appBuild,
134
174
  networkType: config.networkType,
135
175
  });
@@ -142,10 +182,6 @@ export const createAnalytics = (
142
182
  ...options,
143
183
  });
144
184
 
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
185
  // Per-session, in-memory user binding. Seeded from the init context, then persisted across
150
186
  // launches when storage is provided.
151
187
  let boundUserId: string | undefined = sanitizeUserId(config.userContext?.userId);
@@ -165,7 +201,16 @@ export const createAnalytics = (
165
201
  // opt-in user_email, namespaced `custom.*`) and the top-level opaque `user_id`. Never overwrites a
166
202
  // key the caller already set (so `identify`'s explicit `user_id` and any caller `user_context` win).
167
203
  const applyContext = (event: ClientEvent): void => {
168
- const resolved = resolveUserContext(userContext, { autoAppVersion: config.appVersion });
204
+ // Host `deviceKey` wins; otherwise the auto-minted/persisted per-install id fills it in so
205
+ // `user_context.device_key` is always present.
206
+ const hostDeviceKey =
207
+ typeof userContext.deviceKey === "string" && userContext.deviceKey.trim()
208
+ ? userContext.deviceKey
209
+ : undefined;
210
+ const resolved = resolveUserContext(
211
+ { ...userContext, deviceKey: hostDeviceKey ?? autoDeviceKey },
212
+ { autoAppVersion: config.appVersion },
213
+ );
169
214
  if (resolved.userContext) {
170
215
  event.user_context = { ...resolved.userContext, ...(event.user_context ?? {}) };
171
216
  }
@@ -6,7 +6,7 @@
6
6
  * post `app.session_started` with it — so the SERVER knows that id. But other client paths
7
7
  * (`identify`, host `app_event`s through the analytics façade) used to reference a DIFFERENT id
8
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).
9
+ * `session_started` — inflating session counts (the "phantom-session" bug).
10
10
  *
11
11
  * This registry is the single seam that lets those paths reuse the LIVE per-open session id the
12
12
  * server already ingested. `reportSessionStart` writes the current id here on every open; the façade
@@ -0,0 +1,253 @@
1
+ /**
2
+ * CardGridSelectCard — icon-led choices as a two-column grid of small cards.
3
+ *
4
+ * The shape Malik asked for (2026-07-16): "the multi select needs to be a small card like this
5
+ * one, with an icon on top, it is much better." Icon top-left, short label beneath, the whole
6
+ * card is the tap target, selection lives on the card itself.
7
+ *
8
+ * ┌──────────────┐ ┌──────────────┐
9
+ * │ ◇ │ │ ▣ │ ← icon on top
10
+ * │ Wedding day │ │ Birthday │ ← label beneath
11
+ * └──────────────┘ └──────────────┘
12
+ *
13
+ * WHY A NEW CARD RATHER THAN A `display` MODE ON SelectionCard: the card's `description` IS the
14
+ * router — it is the only thing the model reads when choosing (frequent_rules #4). SelectionCard
15
+ * already carries two modes behind one description; a third ("a list, but sometimes a grid, and
16
+ * then the icons matter") cannot be stated honestly in one string, and a muddy description makes
17
+ * the model pick the wrong card. The kit already has the precedent: ChipSelectCard exists ONLY
18
+ * because "compact vs. descriptive" is a presentation difference worth its own name, over the
19
+ * very same pick-from-options job. Back-compat costs nothing here — `supportedComponents`
20
+ * (WireOnboarding.tsx) means an already-shipped client never advertises this name, so the server
21
+ * intersects it away and can never emit it to the apps already in the stores. See the CHANGELOG.
22
+ *
23
+ * Layout is percentage flex, never a measured width: `flexBasis` ~47% + `flexGrow` makes exactly
24
+ * two per row that fill the width, with no Dimensions read, no onLayout, and no re-render on
25
+ * rotation. An odd last card stretches the row — deliberate, and the same thing every native
26
+ * grid does.
27
+ *
28
+ * Motion mirrors SelectionCard's OptionRow exactly (press tick spring + a native-driver opacity
29
+ * crossfade of the selected overlay), so the two cards feel like one system.
30
+ */
31
+ import React, { useCallback, useEffect, useRef, useState } from "react";
32
+ import { Animated, Easing, Pressable, StyleSheet, Text, View } from "react-native";
33
+ import { z } from "zod";
34
+ import type { InjectedProps, WireAIComponent } from "wireai-rn";
35
+ import { useOnboardingTheme } from "../theme/ThemeContext";
36
+ import { labelStyle } from "../theme/typography";
37
+ import { Button } from "../components/Button";
38
+ import { CardLayout } from "../components/CardLayout";
39
+ import { WireIcon } from "../icons/WireIcon";
40
+ import { normalizeOptions, optionsField, type CardOption } from "./optionSchema";
41
+ import {
42
+ PRESS_SPRING,
43
+ ROW_PRESS_SCALE,
44
+ SELECT_FILL_MS,
45
+ WIRE_BEZIER,
46
+ scaledMs,
47
+ stateChangeDuration,
48
+ } from "../motion/motionSpec";
49
+ import { useReducedMotion } from "../motion/useReducedMotion";
50
+
51
+ const easeWire = Easing.bezier(...WIRE_BEZIER);
52
+
53
+ const schema = z.object({
54
+ title: z.string().describe("Question or prompt for the user, e.g. 'What brings you here?'"),
55
+ options: optionsField(
56
+ "The choices, 2-6 of them. Give EVERY option an `icon` from the vocabulary — the icon is " +
57
+ "the point of this card, and an option without one renders as a bare label next to " +
58
+ "icon-bearing neighbours. Keep labels short (1-3 words) so they fit a half-width card.",
59
+ ),
60
+ submitLabel: z.string().optional().describe("Submit button label, default: Continue"),
61
+ multiSelect: z
62
+ .boolean()
63
+ .optional()
64
+ .describe("Allow multiple cards selected at once, default true"),
65
+ });
66
+
67
+ type Props = Omit<z.infer<typeof schema>, "options"> &
68
+ InjectedProps & {
69
+ /** Bare strings stay valid (normalized on the way in) — see optionSchema.ts. */
70
+ options: ReadonlyArray<string | CardOption>;
71
+ onSelect?: (selected: string | string[]) => void;
72
+ /** Fires as the live selection changes (before Continue), so the flow can prefetch the
73
+ * next turn. Same value shape as onSelect. Optional — injected by OnboardingFlow. */
74
+ onSelectionChange?: (selected: string | string[]) => void;
75
+ };
76
+
77
+ type OptionCardProps = {
78
+ opt: CardOption;
79
+ isSelected: boolean;
80
+ multiSelect: boolean;
81
+ onToggle: (value: string) => void;
82
+ };
83
+
84
+ const _OptionCard: React.FC<OptionCardProps> = ({ opt, isSelected, multiSelect, onToggle }) => {
85
+ const t = useOnboardingTheme();
86
+ const reduced = useReducedMotion();
87
+ const handlePress = useCallback(() => onToggle(opt.value), [opt.value, onToggle]);
88
+
89
+ // Press tick: scale 1→0.975, spring, native driver. Skipped under reduce motion.
90
+ const pressScale = useRef(new Animated.Value(1)).current;
91
+ const pressTo = useCallback(
92
+ (to: number) => {
93
+ if (reduced) return;
94
+ Animated.spring(pressScale, {
95
+ toValue: to,
96
+ friction: PRESS_SPRING.friction,
97
+ tension: PRESS_SPRING.tension,
98
+ useNativeDriver: true,
99
+ }).start();
100
+ },
101
+ [pressScale, reduced],
102
+ );
103
+ const handlePressIn = useCallback(() => pressTo(ROW_PRESS_SCALE), [pressTo]);
104
+ const handlePressOut = useCallback(() => pressTo(1), [pressTo]);
105
+
106
+ // Selected fill: accent border + tinted wash on an absolute overlay whose opacity crossfades
107
+ // (native driver — see EXTRACTED-SPEC.md D7). Reduce motion: snap.
108
+ const selectT = useRef(new Animated.Value(isSelected ? 1 : 0)).current;
109
+ useEffect(() => {
110
+ const anim = Animated.timing(selectT, {
111
+ toValue: isSelected ? 1 : 0,
112
+ duration: scaledMs(stateChangeDuration(reduced, SELECT_FILL_MS)),
113
+ easing: easeWire,
114
+ useNativeDriver: true,
115
+ });
116
+ anim.start();
117
+ return () => anim.stop();
118
+ }, [isSelected, reduced, selectT]);
119
+
120
+ return (
121
+ <Pressable
122
+ onPress={handlePress}
123
+ onPressIn={handlePressIn}
124
+ onPressOut={handlePressOut}
125
+ accessibilityRole={multiSelect ? "checkbox" : "radio"}
126
+ accessibilityState={multiSelect ? { checked: isSelected } : { selected: isSelected }}
127
+ accessibilityLabel={opt.label}
128
+ style={styles.cell}
129
+ >
130
+ <Animated.View
131
+ style={[
132
+ styles.card,
133
+ {
134
+ padding: t.spacing.md,
135
+ borderRadius: t.radius.lg,
136
+ gap: t.spacing.sm,
137
+ borderColor: t.colors.border,
138
+ backgroundColor: t.colors.surface,
139
+ transform: [{ scale: pressScale }],
140
+ },
141
+ ]}
142
+ >
143
+ <Animated.View
144
+ pointerEvents="none"
145
+ style={[
146
+ StyleSheet.absoluteFill,
147
+ styles.selectedOverlay,
148
+ {
149
+ borderRadius: t.radius.lg,
150
+ borderColor: t.colors.primary,
151
+ backgroundColor: t.colors.primarySoft,
152
+ opacity: selectT,
153
+ },
154
+ ]}
155
+ />
156
+ {/* Accent discipline (same rule as SelectionCard's rows): label AND icon keep the text
157
+ color in both states; the accent lives on the border and fill only. An unresolvable
158
+ icon renders null, so a card with a bad name lays out like one with no icon. */}
159
+ <WireIcon name={opt.icon} size={24} color={t.colors.text} />
160
+ <Text style={[labelStyle(t.fonts), { color: t.colors.text }]}>{opt.label}</Text>
161
+ </Animated.View>
162
+ </Pressable>
163
+ );
164
+ };
165
+ const OptionCard = React.memo(_OptionCard);
166
+
167
+ const _CardGridSelectCard: React.FC<Props> = ({
168
+ title,
169
+ options,
170
+ submitLabel = "Continue",
171
+ multiSelect = true,
172
+ onSelect,
173
+ onSelectionChange,
174
+ }) => {
175
+ const t = useOnboardingTheme();
176
+ const opts = React.useMemo(() => normalizeOptions(options), [options]);
177
+ const [selected, setSelected] = useState<string[]>([]);
178
+ const [submitted, setSubmitted] = useState(false);
179
+
180
+ // Report the settled selection upward (before Continue) so the flow can prefetch the next
181
+ // turn. Debouncing lives in the flow; here we just emit on every change once something's picked.
182
+ useEffect(() => {
183
+ if (!submitted && selected.length) onSelectionChange?.(multiSelect ? selected : selected[0]!);
184
+ }, [selected, submitted, multiSelect, onSelectionChange]);
185
+
186
+ const toggle = useCallback(
187
+ (value: string) => {
188
+ if (submitted) return;
189
+ if (multiSelect) {
190
+ setSelected((prev) =>
191
+ prev.includes(value) ? prev.filter((v) => v !== value) : [...prev, value],
192
+ );
193
+ } else {
194
+ setSelected([value]);
195
+ }
196
+ },
197
+ [submitted, multiSelect],
198
+ );
199
+
200
+ const handleSubmit = useCallback(() => {
201
+ if (!selected.length || !onSelect || submitted) return;
202
+ setSubmitted(true);
203
+ onSelect(multiSelect ? selected : selected[0]!);
204
+ }, [selected, onSelect, submitted, multiSelect]);
205
+
206
+ return (
207
+ <CardLayout
208
+ title={title}
209
+ footer={
210
+ <Button
211
+ title={submitLabel}
212
+ onPress={handleSubmit}
213
+ variant="primary"
214
+ disabled={selected.length === 0 || submitted}
215
+ />
216
+ }
217
+ >
218
+ <View style={[styles.grid, { gap: t.spacing.sm }]}>
219
+ {opts.map((opt) => (
220
+ <OptionCard
221
+ key={opt.value}
222
+ opt={opt}
223
+ isSelected={selected.includes(opt.value)}
224
+ multiSelect={multiSelect}
225
+ onToggle={toggle}
226
+ />
227
+ ))}
228
+ </View>
229
+ </CardLayout>
230
+ );
231
+ };
232
+
233
+ export const CardGridSelectCard: WireAIComponent = {
234
+ name: "CardGridSelectCard",
235
+ description:
236
+ "Use for VISUAL, icon-led choices shown as a 2-column grid of tappable cards (icon on top, " +
237
+ "short label beneath). Ideal for 2–6 concrete options that each have an obvious icon — " +
238
+ "occasions, channels, goals, categories — e.g. 'What brings you here?' or 'Where did you " +
239
+ "hear about us?'. Multi-select by default. Every option should carry an `icon`. Use " +
240
+ "SelectionCard instead when options need descriptions or have no natural icon; use " +
241
+ "ChipSelectCard for many short word-like tags.",
242
+ component: React.memo(_CardGridSelectCard) as WireAIComponent["component"],
243
+ propsSchema: schema,
244
+ defaultProps: { submitLabel: "Continue", multiSelect: true },
245
+ };
246
+
247
+ const styles = StyleSheet.create({
248
+ grid: { flexDirection: "row", flexWrap: "wrap" },
249
+ // Two per row: ~47% basis leaves room for the gap, flexGrow fills the remainder exactly.
250
+ cell: { flexBasis: "47%", flexGrow: 1 },
251
+ card: { borderWidth: 1.5, alignItems: "flex-start", overflow: "hidden" },
252
+ selectedOverlay: { borderWidth: 1.5 },
253
+ });
@@ -250,7 +250,7 @@ const _ChipSelectCard: React.FC<Props> = ({
250
250
  export const ChipSelectCard: WireAIComponent = {
251
251
  name: "ChipSelectCard",
252
252
  description:
253
- "Use for compact short labels: seasons, activities, travel styles, moods, categories. Ideal for 4–12 single-word or two-word options. Use SelectionCard instead when options need descriptions.",
253
+ "Use for compact short labels: seasons, activities, travel styles, moods, categories. Ideal for 4–12 single-word or two-word options. Use SelectionCard instead when options need descriptions; use CardGridSelectCard for a smaller set (2–6) of visual choices that each deserve an icon.",
254
254
  component: React.memo(_ChipSelectCard) as WireAIComponent["component"],
255
255
  propsSchema: schema,
256
256
  defaultProps: { multiSelect: true, submitLabel: "Continue" },