@wireai/activation 0.8.0 → 0.9.1

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 (91) hide show
  1. package/AGENTS.md +29 -5
  2. package/CHANGELOG.md +202 -6
  3. package/README.md +105 -10
  4. package/dist/analytics/index.d.mts +6 -6
  5. package/dist/analytics/index.d.ts +6 -6
  6. package/dist/analytics/index.js +447 -49
  7. package/dist/analytics/index.js.map +1 -1
  8. package/dist/analytics/index.mjs +447 -49
  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-f7LWcdWG.d.ts → currentSession-61dcm3V-.d.ts} +10 -2
  15. package/dist/{currentSession-d9CrBxwe.d.mts → currentSession-Bs2JfTJ8.d.mts} +10 -2
  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 +558 -15
  19. package/dist/index.d.ts +558 -15
  20. package/dist/index.js +1144 -375
  21. package/dist/index.js.map +1 -1
  22. package/dist/index.mjs +969 -207
  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-B31G0Cib.d.ts +128 -0
  41. package/dist/transport-Bzb-bcB2.d.mts +128 -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/currentSession.ts +1 -1
  53. package/src/cards/CardGridSelectCard.tsx +253 -0
  54. package/src/cards/ChipSelectCard.tsx +1 -1
  55. package/src/cards/SelectionCard.tsx +15 -19
  56. package/src/cards/index.ts +13 -1
  57. package/src/cards/optionSchema.ts +65 -0
  58. package/src/coachmarks/types.ts +1 -1
  59. package/src/components/CardHandoff.tsx +39 -7
  60. package/src/components/DoneCheck.tsx +94 -0
  61. package/src/components/ModalPaneHost.tsx +141 -0
  62. package/src/components/StepDots.tsx +112 -0
  63. package/src/device/appVersion.ts +29 -8
  64. package/src/device/deviceModel.ts +21 -8
  65. package/src/features/cache.ts +1 -1
  66. package/src/haptics/expo-haptics.d.ts +31 -0
  67. package/src/haptics/haptics.ts +58 -0
  68. package/src/icons/IconRegistry.tsx +53 -0
  69. package/src/icons/WireIcon.tsx +85 -0
  70. package/src/icons/expoIcons.ts +165 -0
  71. package/src/icons/index.ts +18 -0
  72. package/src/icons/vocabulary.ts +154 -0
  73. package/src/index.ts +20 -0
  74. package/src/questionnaire/QuestionnaireGate.tsx +315 -164
  75. package/src/questionnaire/decision.ts +4 -2
  76. package/src/questionnaire/index.ts +3 -2
  77. package/src/questionnaire/steps.ts +261 -0
  78. package/src/questionnaire/types.ts +33 -12
  79. package/src/reviews/ReviewGate.tsx +250 -152
  80. package/src/reviews/index.ts +7 -3
  81. package/src/reviews/transport.ts +119 -3
  82. package/src/reviews/types.ts +13 -0
  83. package/src/session/persistedSession.ts +1 -1
  84. package/src/session-analytics/lifecycle.ts +1 -1
  85. package/src/session-analytics/useSessionStart.ts +2 -2
  86. package/src/showcase/FeatureShowcase.tsx +1 -1
  87. package/src/showcase/showcaseColors.ts +1 -1
  88. package/src/theme/mergeThemeOver.ts +27 -0
  89. package/src/types.ts +8 -0
  90. package/dist/transport-BeO_Brcu.d.mts +0 -40
  91. package/dist/transport-DLpd1v5_.d.ts +0 -40
@@ -0,0 +1,128 @@
1
+ import { k as ReviewTarget, g as ReviewDecisionResponse, j as ReviewSubmission } from './types-CNUqMK0D.mjs';
2
+
3
+ /**
4
+ * transport.ts — kit → Wire server requests for the review module, all fire-and-forget
5
+ * (analytics/reviews must never break the app). Mirrors analytics/reportClientEvent: a
6
+ * thin fetch wrapper, Bearer tenant key, swallow every error.
7
+ *
8
+ * • submitReview → POST {serverUrl}/v1/reviews (the review row)
9
+ * • fetchReviewDecision → GET {serverUrl}/v1/reviews/decision (best-effort, the AI seam)
10
+ * • reportAppEvent → POST {serverUrl}/v1/events (generic app.* namespace)
11
+ *
12
+ * `reportAppEvent` is the strategic extension: it lets a host report arbitrary in-app
13
+ * events through the SAME transport (stored server-side as event_type='app_event',
14
+ * question_key=<name>), which is what the backend review-firing rules evaluate on — and
15
+ * it seeds the broader app-analytics stream. Keep payloads minimal + non-PII.
16
+ */
17
+
18
+ /**
19
+ * POST a review (the 1-4 feedback path). Fire-and-forget: a missing target, a build error,
20
+ * a missing `fetch`, or a network failure is swallowed and the call returns immediately.
21
+ */
22
+ declare const submitReview: (target: ReviewTarget | undefined, review: ReviewSubmission) => void;
23
+ /**
24
+ * Options for the best-effort review decision fetch.
25
+ *
26
+ * ── WHAT THE DEPLOYED SERVER ACTUALLY READS (verified 2026-07-17) ────────────────────────
27
+ *
28
+ * `GET /v1/reviews/decision` declares exactly two query params — `session_id` and
29
+ * `device_key` — plus the `Authorization` header. That is the whole wire. Verified against
30
+ * the deployed OpenAPI schema, not against intent.
31
+ *
32
+ * This type is CLOSED on purpose. Hosts that hand-rolled this fetch invented `user_id` and
33
+ * `session_count` query params believing "the server ignores what it doesn't read, so passing
34
+ * it is always safe". Both are no-ops: the route declares neither. `session_count` is real, but
35
+ * only on the questionnaire POST body — which is precisely how it copy-pasted its way into a
36
+ * reviews call site and sat there doing nothing. They cost a wire lie — a call site that reads as though
37
+ * identity and a session counter reach the firing brain when neither does. So they are not
38
+ * offered here. Pass identity as `deviceKey`; the session count the server reasons about is the
39
+ * one IT derives from the event stream keyed by `deviceKey`, not one the client asserts.
40
+ *
41
+ * The client-side session floor is a LOCAL rule, not a wire param: use `ReviewConfig.minSessions`
42
+ * (evaluated by `useReviewGate` against the kit's own `wire_review_<id>_sessions` counter).
43
+ */
44
+ interface FetchReviewDecisionOptions {
45
+ /**
46
+ * The onboarding session id, when there IS one. OPTIONAL on purpose: the review gate lives on
47
+ * the home feed, where a user legitimately has no onboarding session. `deviceKey` is the real
48
+ * identity for this call. (The server relaxed `session_id` to optional on 2026-07-16 and the
49
+ * relaxation IS deployed — the route no longer 422s without it. Permissive wire: do NOT make
50
+ * this required here.)
51
+ */
52
+ sessionId?: string;
53
+ /**
54
+ * A stable, non-PII device id — THE identity for this call. The decision endpoint reads it for
55
+ * cooldown + min-sessions, and it is the key the server groups a device's events under. A host
56
+ * whose own identity is a user id passes that id here rather than reaching for a `user_id`
57
+ * param the route does not declare.
58
+ */
59
+ deviceKey?: string;
60
+ }
61
+ /**
62
+ * Best-effort fetch of the SERVER's review firing decision. The mirror of
63
+ * `fetchQuestionnaireDecision`, and the primitive whose absence caused a live incident.
64
+ *
65
+ * ── WHY THIS LIVES IN THE KIT (Malik, 2026-07-16) ────────────────────────────────────────
66
+ *
67
+ * The questionnaire module has always had its decision fetch; reviews never did. So a host
68
+ * hand-rolled one, and got it subtly wrong: its helper collapsed an explicit `{fire:false}`
69
+ * into `undefined`. `decideReview` is `(local, decision) => decision ?? local`, so `undefined`
70
+ * means "the server has no opinion, use the local rules" — and the local timer fired. The
71
+ * server could therefore only ever turn review prompts ON, never OFF. A real user was asked to
72
+ * rate the app ~3 minutes into their FIRST session, having seen nothing yet, and left 1 star.
73
+ *
74
+ * The bug was not that the host was careless. It was that the kit made every host invent this.
75
+ * So the primitive moves here and hosts keep a thin call site.
76
+ *
77
+ * ── THE CONTRACT THAT MATTERS ────────────────────────────────────────────────────────────
78
+ *
79
+ * • 2xx → the FULL `{fire, reason, arm}`, INCLUDING `fire:false`. Never collapse a false
80
+ * into null. That collapse IS the bug: a false must reach `decideReview` intact so
81
+ * it can override the local rules and keep the gate shut.
82
+ * • else → null, and ONLY then. Null means "the server genuinely has no opinion", which is
83
+ * the one case where falling back to local rules is correct.
84
+ *
85
+ * Never throws: unreachable, non-2xx, bad JSON, or a missing `fetch` all resolve to null. The
86
+ * kit does not call this internally; a host awaits it and passes the result straight to
87
+ * `useReviewGate({ decision })`.
88
+ *
89
+ * ── DON'T RACE THIS AGAINST A LOCAL TIMER ────────────────────────────────────────────────
90
+ *
91
+ * The return type is `ReviewDecisionResponse`, which carries `arm` alongside `{fire, reason}`.
92
+ * Hand the WHOLE object to the gate and echo `arm` into the submission's `meta.firing_arm`;
93
+ * narrowing it to `{fire, reason}` on the way through silently kills per-arm attribution
94
+ * across a reweighting of the experiment.
95
+ *
96
+ * A host that starts its own dwell timer in parallel with this fetch has built a race a slow
97
+ * server loses: the timer fires, the local rules show the prompt, and the `{fire:false}` still
98
+ * in flight arrives too late to stop it. Do not hand-roll that. `useReviewGate` already owns
99
+ * the wait — set `ReviewConfig.timeoutFallbackMs` and the local rules stay parked until either
100
+ * the decision lands or the window expires, whichever comes first.
101
+ *
102
+ * const decision = await fetchReviewDecision(target, { deviceKey });
103
+ * const gate = useReviewGate({
104
+ * config: { id: "home", minSessions: 2, timeoutFallbackMs: 3000 },
105
+ * decision: decision ?? undefined, // pass it whole — keep `arm`
106
+ * storage,
107
+ * });
108
+ */
109
+ declare const fetchReviewDecision: (target: ReviewTarget | undefined, options?: FetchReviewDecisionOptions) => Promise<ReviewDecisionResponse | null>;
110
+ /** Options for a reported app event. `deviceKey` groups a device's sessions server-side. */
111
+ interface ReportAppEventOptions {
112
+ /** The onboarding/session id to correlate with, when known. */
113
+ sessionId?: string;
114
+ /** A stable, non-PII device id — the review-decision endpoint reads it for min-sessions. */
115
+ deviceKey?: string;
116
+ /** Small non-PII extras. */
117
+ meta?: Record<string, unknown>;
118
+ }
119
+ /**
120
+ * Report a generic in-app event through the existing events transport. Stored server-side
121
+ * as `event_type='app_event'`, `question_key=<name>`. Fire-and-forget. Keep `name` a short
122
+ * stable identifier and `meta` small + non-PII.
123
+ *
124
+ * reportAppEvent(target, "content_share", { sessionId, deviceKey });
125
+ */
126
+ declare const reportAppEvent: (target: ReviewTarget | undefined, name: string, options?: ReportAppEventOptions) => void;
127
+
128
+ export { type FetchReviewDecisionOptions as F, type ReportAppEventOptions as R, fetchReviewDecision as f, reportAppEvent as r, submitReview as s };
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Shared types for the coachmark / guided-tour overlay engine.
3
3
  *
4
- * Lifted from the Myelino app engine and made kit-generic: the app supplies the
4
+ * Lifted from a host app engine and made kit-generic: the app supplies the
5
5
  * anchors + catalog + copy, the kit owns the animation/measure/queue mechanics.
6
6
  * Dependency-free on purpose so the type surface can be imported anywhere.
7
7
  */
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Shared types for the coachmark / guided-tour overlay engine.
3
3
  *
4
- * Lifted from the Myelino app engine and made kit-generic: the app supplies the
4
+ * Lifted from a host app engine and made kit-generic: the app supplies the
5
5
  * anchors + catalog + copy, the kit owns the animation/measure/queue mechanics.
6
6
  * Dependency-free on purpose so the type surface can be imported anywhere.
7
7
  */
@@ -1,6 +1,6 @@
1
1
  import { O as OnboardingTheme } from './types-BKfpdZzX.js';
2
- import { C as CoachmarkStorage } from './types-GL_hQ0TN.js';
3
- import { W as WireFeatures, a as WireFeaturesConfig } from './types-CMuOexw0.js';
2
+ import { C as CoachmarkStorage } from './types-BcmagF6K.js';
3
+ import { W as WireFeatures, a as WireFeaturesConfig } from './types-CKFhyrMu.js';
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 };
@@ -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 = {
@@ -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.8.0",
3
+ "version": "0.9.1",
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
  );
@@ -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