@wireai/activation 0.14.2 → 0.15.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 (67) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +144 -0
  3. package/README.md +23 -9
  4. package/dist/analytics/index.d.mts +5 -5
  5. package/dist/analytics/index.d.ts +5 -5
  6. package/dist/analytics/index.js.map +1 -1
  7. package/dist/analytics/index.mjs.map +1 -1
  8. package/dist/coachmarks/index.d.mts +13 -5
  9. package/dist/coachmarks/index.d.ts +13 -5
  10. package/dist/coachmarks/index.js +252 -58
  11. package/dist/coachmarks/index.js.map +1 -1
  12. package/dist/coachmarks/index.mjs +204 -9
  13. package/dist/coachmarks/index.mjs.map +1 -1
  14. package/dist/{currentSession-CW_5Mq4O.d.ts → currentSession-CFSRZ2wg.d.ts} +10 -4
  15. package/dist/{currentSession-CUvTOchb.d.mts → currentSession-orZy5p1e.d.mts} +10 -4
  16. package/dist/{decision-Bgo17oH7.d.mts → decision-3vWLuBlO.d.ts} +11 -2
  17. package/dist/{decision-Bkh_LigV.d.ts → decision-yBj2AyPW.d.mts} +11 -2
  18. package/dist/index.d.mts +2 -2
  19. package/dist/index.d.ts +2 -2
  20. package/dist/index.js.map +1 -1
  21. package/dist/index.mjs.map +1 -1
  22. package/dist/questionnaire/index.d.mts +3 -3
  23. package/dist/questionnaire/index.d.ts +3 -3
  24. package/dist/questionnaire/index.js +48 -3
  25. package/dist/questionnaire/index.js.map +1 -1
  26. package/dist/questionnaire/index.mjs +48 -3
  27. package/dist/questionnaire/index.mjs.map +1 -1
  28. package/dist/reviews/index.d.mts +10 -6
  29. package/dist/reviews/index.d.ts +10 -6
  30. package/dist/reviews/index.js +167 -18
  31. package/dist/reviews/index.js.map +1 -1
  32. package/dist/reviews/index.mjs +167 -18
  33. package/dist/reviews/index.mjs.map +1 -1
  34. package/dist/showcase/index.d.mts +1 -1
  35. package/dist/showcase/index.d.ts +1 -1
  36. package/dist/showcase/index.js +282 -48
  37. package/dist/showcase/index.js.map +1 -1
  38. package/dist/showcase/index.mjs +248 -12
  39. package/dist/showcase/index.mjs.map +1 -1
  40. package/dist/{transport-j5gFfJhK.d.mts → transport-s5QxA-ci.d.mts} +18 -13
  41. package/dist/{transport-B_0SgCBe.d.ts → transport-xqqSFqxs.d.ts} +18 -13
  42. package/dist/{types-Cju-1_jT.d.mts → types-Byx306Kv.d.mts} +25 -10
  43. package/dist/{types-BcmagF6K.d.mts → types-D_0B0yay.d.mts} +7 -2
  44. package/dist/{types-BcmagF6K.d.ts → types-D_0B0yay.d.ts} +7 -2
  45. package/dist/{types-h2BZvl1t.d.ts → types-tdATL5z0.d.ts} +25 -10
  46. package/metro/index.js +16 -5
  47. package/package.json +1 -1
  48. package/src/WireOnboarding.tsx +4 -1
  49. package/src/coachmarks/GestureHint.tsx +16 -7
  50. package/src/coachmarks/SpotlightOverlay.tsx +19 -13
  51. package/src/coachmarks/index.ts +7 -2
  52. package/src/coachmarks/reanimated.ts +342 -0
  53. package/src/coachmarks/runtime.ts +103 -2
  54. package/src/coachmarks/types.ts +7 -2
  55. package/src/context/deviceId.ts +32 -0
  56. package/src/reviews/ReviewGate.tsx +86 -37
  57. package/src/reviews/decision.ts +11 -1
  58. package/src/reviews/idempotency.ts +71 -0
  59. package/src/reviews/index.ts +4 -2
  60. package/src/reviews/runtime.ts +5 -3
  61. package/src/reviews/transport.ts +17 -12
  62. package/src/reviews/types.ts +7 -0
  63. package/src/showcase/FeatureShowcase.tsx +25 -9
  64. package/src/showcase/blazejOnboarding.ts +136 -0
  65. package/src/showcase/index.ts +8 -4
  66. package/src/types.ts +10 -4
  67. package/src/utils/submitResult.ts +17 -9
package/AGENTS.md CHANGED
@@ -81,7 +81,7 @@ subpaths are optional secondary feature modules; import one only if you use it.
81
81
  | `persistKey` | `string` | no | Override the storage key (default `wireai:session:<config.appId>`). Scope it per-user if one device runs onboarding for multiple accounts. Only meaningful with `storage`. |
82
82
  | `retainSessionOnComplete` | `boolean` | no | Opt IN: keep the persisted session seed alive ACROSS completion. A multi-stage or replay-after-complete re-entry within one signup (typically a `key=` remount) then resumes the SAME session instead of minting a fresh `metadata.sessionId`, which the backend adopts as a second `contextId` and double-counts as a `session_started`. Omitted or `false` clears the seed on completion, the legacy single-stage behavior, so leaving it unset changes nothing. When `true`, freshness comes from `sessionTtlMs` plus an explicit new-run signal (scope a new `persistKey`, e.g. a per-signup id). Only meaningful with `storage`. |
83
83
  | `userContext` | `Record<string, string \| number \| boolean>` | no | Host-injected, non-PII context (signup method, referral, plan tier, a hashed user id). Forwarded to the backend + client events for funnel segmentation. No raw PII. |
84
- | `autoJoinKey` | `boolean` | no | Opt OUT of the automatic join key. Default `true`. When `userContext` carries no `device_key` and `storage` is present and actually persists (a storage adapter that throws or rejects is treated exactly like no `storage`), the kit injects its own per-install key, the same id `createAnalytics` / `createWireActivation` mint and persist, so the `activated` funnel joins with no host wiring. Pass `autoJoinKey={false}` for a genuinely UNLINKED session: that restores the pre-0.12.2 behavior and the dev warning fires again. It never overrides a `device_key` you passed, and it cannot inject without `storage`. |
84
+ | `autoJoinKey` | `boolean` | no | Opt OUT of the automatic join key. Default `true`. When `userContext` carries no `device_key` and `storage` is present and actually persists (a storage adapter that throws or rejects is treated exactly like no `storage`), the kit injects its own per-install key, the same id `createAnalytics` / `createWireActivation` mint and persist, so the `activated` funnel joins with no host wiring. Pass `autoJoinKey={false}` for a genuinely UNLINKED session: that restores the pre-0.12.2 behavior and the dev warning fires again. It never overrides a `device_key` you actually supplied, and it cannot inject without `storage`. The kit counts yours as supplied only when it is a STRING WITH AT LEAST ONE NON-WHITESPACE CHARACTER (`resolveIdentity`); the `userContext` type also allows a number or a boolean, and a non-string or an all-whitespace `device_key` is not a usable join key, so it is treated as absent and the auto key is merged over it. |
85
85
  | `userId` | `string` | no | The host's OPAQUE PSEUDONYMOUS user id (not an email/name/phone), so onboarding sessions reconcile to real users. Late binding supported (mount, mid-session, or post-flow via `identifyOnboarding`). Capped at 128 chars. |
86
86
 
87
87
  ### `config` fields (`WireOnboardingConfig`)
package/CHANGELOG.md CHANGED
@@ -3,6 +3,150 @@
3
3
  All notable changes to `@wireai/activation` (formerly `wireai-onboarding`).
4
4
  Historical entries below the rename keep the old package name on purpose.
5
5
 
6
+ ## [0.15.0] - 2026-08-19
7
+
8
+ **MINOR, not patch, and only because of new API. Two exported surfaces each GAINED an optional
9
+ member; nothing was lost or renamed.** `ReviewSubmission` gains an OPTIONAL `idempotency_key` and
10
+ `buildReviewSubmission` gains an OPTIONAL `idempotencyKey` input. Both are additive and optional, so
11
+ every existing call site compiles unchanged and sends a byte-identical body unless it opts in. A
12
+ gained public member is still a MINOR under this changelog's own precedent: 0.14.0 was bumped MINOR
13
+ for exactly this shape, an optional field added to an exported interface. Every behaviour change
14
+ below is a bug fix.
15
+
16
+ ### Added
17
+
18
+ - **The review gate can now reach the server's idempotent upsert path, so a recovery post merges
19
+ into the row it may already have written instead of adding a second one.** `POST /v1/reviews`
20
+ accepts an optional client-minted `idempotency_key` and derives the row id from it; the kit now
21
+ mints one per gate IMPRESSION and holds it across every post of that impression. This is a
22
+ CAPABILITY, not a guarantee, and the condition is load-bearing: the server honours the key only
23
+ when the post also carries a unit it can scope it to, and ignores it outright otherwise.
24
+ - **Every review post now carries that unit.** The server resolves it as `meta.device_key`, else
25
+ `session_id`. The gate lives on the home feed, where a user legitimately has no onboarding
26
+ session, and the kit never put a `device_key` into a review `meta` — so the key alone would have
27
+ been discarded on the default wiring. The gate now fills in the kit's own per-install auto id,
28
+ and only when the host supplied none: **any non-empty string you pass in `meta.device_key` wins
29
+ verbatim.** The kit counts a value as host-supplied only when it is a string with at least one
30
+ non-whitespace character, so a non-string or an all-whitespace one is not a usable unit and is
31
+ replaced. **Overriding `meta` cannot remove the unit**: the injection tests the MERGED bucket, so
32
+ stripping `device_key` there only gets the auto id back. Exactly one post carries no unit at all,
33
+ the one where the kit cannot name an id honestly: two tenants registered in one process, which
34
+ leaves the per-install id space ambiguous and the auto id `undefined`. That post reaches the
35
+ server with no unit, the server ignores the idempotency key, and that path keeps the previous
36
+ behaviour.
37
+ - The injected id is the SAME per-install id the analytics surfaces use, adopted from the process
38
+ registry rather than minted fresh, so the gate never opens a second id space for one install.
39
+
40
+ ### Unchanged on purpose
41
+
42
+ - **The `rejected` verdict is still not retried.** Re-posting after a non-2xx is now safe wherever
43
+ the key is honoured, but it would also turn a genuine 4xx refusal into a retry needing its own
44
+ budget. That is a separate recovery decision and is deliberately not bundled here.
45
+ - Questionnaire submissions are untouched. The server has no idempotency path for them, so every
46
+ statement in `src/questionnaire/` about there being no key on the wire remains true.
47
+
48
+ ### Fixed
49
+
50
+ - **An ASYNC gate-storage adapter now fails CLOSED instead of misfiring the gates.** The sync gate
51
+ storage (`CoachmarkStorage`) silently accepted an async adapter — AsyncStorage handed straight to
52
+ `CoachmarkProvider` or a gate's `storage` prop. Its `getItem` returns a Promise, which is never
53
+ `=== "1"` and parses to `NaN`, so every seen-gate read "unseen" forever: coachmark tours and
54
+ showcases replayed on every open, and a review gate configured with `minSessions` 0/1 could
55
+ RE-FIRE its native prompt each open (the 2026-07-16 one-star incident class). A thenable probe on
56
+ first read (`validateGateStorage`, WeakSet-cached so each adapter pays for exactly one probe) now
57
+ substitutes a closed adapter: every gate reads "seen", nothing shows, nothing fires, and a
58
+ once-per-process dev warning names the fix (pass a sync adapter, e.g. a 3-line MMKV wrapper).
59
+ Never-fire is the only direction a once-per-user gate may err in. A rejecting probe promise is
60
+ detached, so a broken native store cannot surface as an unhandled rejection.
61
+ - Canary: `test/canary/asyncGateStorage.test.tsx` (6 tests, proven red with the probe neutered —
62
+ including the sync CONTROL that stays green) + the new
63
+ `@wireai/activation:asyncGateStorageWarned` latch registered in `globalSlotDiscipline`.
64
+
65
+ ### Docs
66
+
67
+ - **The "a `device_key` you passed is never overridden" line is now qualified everywhere it ships**,
68
+ on the ONBOARDING join-key path as it already was on the reviews path. The bar is the same on both
69
+ and it is `resolveIdentity` / `hasDeviceKey`: a string with at least one non-whitespace character.
70
+ `userContext` also accepts numbers and booleans, so a non-string or an all-whitespace `device_key`
71
+ was never a usable join key — it reads as absent and the auto key is merged over it, which the
72
+ unqualified sentence denied. Corrected in `AGENTS.md`, `README.md` (both the props table and the
73
+ join-key rules), and the shipped JSDoc in `src/types.ts` + `src/WireOnboarding.tsx`. No behaviour
74
+ change: the predicate is unchanged, only the sentence describing it.
75
+ - `README.md`'s tarball install line named `@wireai/activation-0.1.3.tgz`, a file `npm pack` has
76
+ never produced under any version (it emits `wireai-activation-<version>.tgz`, scope flattened), so
77
+ an agent following it hit `ENOENT`. Fixed there and in the two Distribution mentions.
78
+
79
+ ## [0.14.3] - 2026-08-18
80
+
81
+ **PATCH: no public surface gained, lost or renamed a member.** `SpotlightOverlay`, `GestureHint`
82
+ and `FeatureShowcase` keep their props and their exports; `react-native-reanimated` and
83
+ `@blazejkustra/react-native-onboarding` stay OPTIONAL peers. This finishes the class 0.14.2 started
84
+ with `expo-blur`: **no source under `src/` statically value-imports an optional peer any more.**
85
+
86
+ ### Fixed
87
+
88
+ - **The coachmarks subpath no longer force-imports its optional peer `react-native-reanimated`.**
89
+ `SpotlightOverlay` and `GestureHint` both did `import Animated, { … } from
90
+ "react-native-reanimated"` at module scope, and `coachmarks/index.ts` re-exports them with no
91
+ wildcard escape — so importing ANYTHING from `@wireai/activation/coachmarks`, even
92
+ `setCoachmarkStorage`, dragged the peer in, and `@wireai/activation/showcase` inherited the same
93
+ edge through `GestureHint`. A host that took "optional" at its word hit a Metro resolution
94
+ failure. The peer is now resolved through a guarded lazy require with a string-literal specifier
95
+ inside a try/catch (`src/coachmarks/reanimated.ts`, the same shape as `icons/expoIcons.ts` /
96
+ `coachmarks/expoBlur.ts`), so neither subpath carries a static top-level import of it.
97
+ - **Degradation, by design:** without the peer both surfaces render STATICALLY at their resting
98
+ frame. The spotlight keeps its ring, tooltip, copy, scrim/frost and gestures, with no fade-in
99
+ and no pulse; the gesture glyph keeps its capsules, ring, dot and motion track, at full opacity
100
+ and no travel. Nothing moves; everything renders. Reduce Motion is still honoured — the kit's
101
+ own `AccessibilityInfo`-backed hook stands in for the peer's `useReducedMotion`.
102
+ - The accessor re-exports the peer's own names (`useAnimatedStyle`, `useAnimatedProps`,
103
+ `withTiming`, …) and the call sites keep calling them unqualified, because reanimated's Babel
104
+ plugin decides what to workletize by the callee NAME. Hosts that HAVE the peer therefore get
105
+ byte-for-byte the same worklets, on the UI thread, as before.
106
+
107
+ - **The showcase subpath no longer force-imports its optional peer
108
+ `@blazejkustra/react-native-onboarding`.** `FeatureShowcase` imported the pager's default export
109
+ at module scope, so importing anything from `@wireai/activation/showcase` — even
110
+ `selectShowcaseSlides` — required a peer npm never installs. It is now resolved through a guarded
111
+ lazy require (`src/showcase/blazejOnboarding.ts`); only the TYPES are still imported statically,
112
+ which every toolchain erases.
113
+ - **Degradation, by design:** absent, the showcase renders null and calls `onDone` from an effect
114
+ — the SAME path the feature kill switch already takes — so the host's flow always advances
115
+ rather than stranding the user on a blank screen. The seen gate is deliberately NOT written, so
116
+ installing the peer later still plays the deck once.
117
+
118
+ - **A rule file prescribed the inverted optional-require contract, for the second time.**
119
+ `ai_rules/context_map.md` told the next author that the `expoIcons` callee "MUST be a local `req`,
120
+ never a literal `require`" — verbatim the `const req = require; req(name)` shape that
121
+ `icons/expoIcons.ts` autopsies as the 0.8.0 red-box bug (aliasing hides the call from Metro's
122
+ static collector, so the module never enters the bundle). 0.14.2 corrected this claim in
123
+ `frequent_rules.md`; this was its second copy, and it was still live. Rewritten to the one correct
124
+ contract, citing `src/icons/expoIcons.ts:98`.
125
+
126
+ ### Changed
127
+
128
+ - **`metro/index.js` `OPTIONAL_MODULES` now lists both modules**, and `tsup.config.ts` drops them
129
+ from its static-peer list (`external` is derived from the union, so they stay external, and the
130
+ CJS keep-literal-require hook now covers them). The helper's "a statically-imported module must NOT be
131
+ stubbed" note named exactly these two; it is rewritten, because the kit now has NO
132
+ statically-imported optional peer — everything it imports statically is a REQUIRED peer whose
133
+ absence must stay a hard build error.
134
+ - **Docs that promised the old behaviour are corrected in the same wave**, both directions: the
135
+ coachmarks and showcase barrel docs and the README said importing a subpath "pulls in" these
136
+ peers (no longer true), while the README/AGENTS peer tables already claimed every optional peer
137
+ "sits behind a guarded lazy require" (not true until now).
138
+
139
+ ### Added
140
+
141
+ - **A whole-class ratchet in `test/canary/metroResolution.test.tsx`:** every file under `src/` is
142
+ scanned for a static value-import of ANY module in `OPTIONAL_MODULES`, with a positive control
143
+ proving the scanner catches default, named, wrapped multi-line, bare and subpath imports while
144
+ ignoring `import type` and the guarded require itself. Each optional peer so far has arrived as
145
+ the same defect; a new one now inherits the check for free. Plus per-module collection tests
146
+ (Metro marks both new specifiers `isOptional`) and component-level canaries for both peers in
147
+ both directions, including the absent-reanimated/present-blur combination that pins the frost at
148
+ full intensity.
149
+
6
150
  ## [0.14.2] - 2026-08-18
7
151
 
8
152
  **PATCH: no public surface gained, lost or renamed a member.** `SpotlightOverlay` keeps its props
package/README.md CHANGED
@@ -140,7 +140,7 @@ Install the package and wire Metro with the one-call helper:
140
140
  # from npm (public)
141
141
  npm install @wireai/activation
142
142
  # …or from a tarball (see Distribution below)
143
- npm install ./@wireai/activation-0.1.3.tgz
143
+ npm install ./wireai-activation-0.15.0.tgz
144
144
  # …or over SSH
145
145
  npm install git+ssh://git@github.com/<org>/@wireai/activation.git
146
146
  ```
@@ -204,9 +204,9 @@ returns the same config. `options.appRoot` defaults to `process.cwd()`.
204
204
  ## Distribution
205
205
 
206
206
  `npm run pack:tarball` runs the build (via `prepack`) and produces
207
- `@wireai/activation-x.y.z.tgz` containing `dist` + `src` + `metro` + README +
207
+ `wireai-activation-x.y.z.tgz` containing `dist` + `src` + `metro` + README +
208
208
  CHANGELOG. The primary channel is **npm** (`npm install @wireai/activation`); a
209
- tarball (`npm install ./@wireai/activation-x.y.z.tgz`) or a `git+ssh://…` install
209
+ tarball (`npm install ./wireai-activation-x.y.z.tgz`) or a `git+ssh://…` install
210
210
  work too for pre-publish or pinned builds.
211
211
 
212
212
  ## Quick start
@@ -252,7 +252,7 @@ import { WireOnboarding } from "@wireai/activation";
252
252
  | `persistKey` | `string` | Override the storage key (default `wireai:session:<appId>`). Scope per-user if one device can onboard multiple accounts mid-flow. With `storage` only. |
253
253
  | `retainSessionOnComplete` | `boolean` | Opt IN: keep the persisted session seed alive across completion. A multi-stage signup that re-enters onboarding (typically a `key=` remount) then resumes the SAME session instead of minting a fresh `metadata.sessionId`, which the backend adopts as a second `contextId` and double-counts as a `session_started`. Omitted or `false` clears the seed on completion, the legacy single-stage behavior, so leaving it unset changes nothing. With `true`, freshness comes from `sessionTtlMs` plus an explicit new-run signal (scope a new `persistKey`, e.g. a per-signup id). With `storage` only. |
254
254
  | `userContext` | `Record<string, string \| number \| boolean>` | **This is where the join key goes.** `user_context.device_key` is the only thing that joins this onboarding session to the app's later events, so pass `userContext={activationJoinContext(deviceKey)}`. If your app owns no device id, pass a working `storage` and leave this prop alone: the kit injects its own key, and only after it has confirmed the key actually persists. ⛔ Do not hand-build that key with `activationJoinContext(resolveAutoDeviceKey({ appId, storage }))`, because `resolveAutoDeviceKey` is synchronous by contract (it hands back a fresh mint and adopts the persisted id a storage read later) and it cannot tell you whether the id survives the launch at all. Since 0.12.2, omitting it no longer empties the funnel silently: with `storage`, the kit injects its own key (see [The join key](#the-join-key-device_key-never-session_id) and `autoJoinKey` below). Second job: any other non-PII context the app already knows (signup method, referral, plan, a HASHED user id), forwarded on session metadata + client events for funnel segmentation. **No PII on THIS prop** (no raw emails/names); primitives only; the server caps size/keys. See [Device & user context](#device--user-context). ⚠️ Distinct from the analytics/activation **`WireUserContext`** object (`createAnalytics` / `useWireActivation`), which additionally accepts an opt-in raw `userEmail` — see [Rich user context & PII](#rich-user-context--pii). |
255
- | `autoJoinKey` | `boolean` | Opt OUT of the automatic join key. Default `true`. When this prop is left alone, `storage` is present **and actually persists** (0.13.0: a `storage` adapter that rejects or throws is treated exactly like no `storage` — the kit declines and says so, because a key it cannot persist is a different key on every launch, which corrupts `min_sessions` rather than merely leaving the join empty), and `userContext` carries no `device_key`, the kit injects its own per-install key (the same id `createAnalytics` / `createWireActivation` mint and persist), so the `activated` funnel joins with no host wiring. Pass `autoJoinKey={false}` if you genuinely want an UNLINKED onboarding session: that restores the pre-0.12.2 behavior exactly, and the dev warning fires again. It never overrides a `device_key` you passed, and it cannot inject without `storage` (see [The join key](#the-join-key-device_key-never-session_id)). |
255
+ | `autoJoinKey` | `boolean` | Opt OUT of the automatic join key. Default `true`. When this prop is left alone, `storage` is present **and actually persists** (0.13.0: a `storage` adapter that rejects or throws is treated exactly like no `storage` — the kit declines and says so, because a key it cannot persist is a different key on every launch, which corrupts `min_sessions` rather than merely leaving the join empty), and `userContext` carries no `device_key`, the kit injects its own per-install key (the same id `createAnalytics` / `createWireActivation` mint and persist), so the `activated` funnel joins with no host wiring. Pass `autoJoinKey={false}` if you genuinely want an UNLINKED onboarding session: that restores the pre-0.12.2 behavior exactly, and the dev warning fires again. It never overrides a `device_key` you actually supplied, and it cannot inject without `storage` (see [The join key](#the-join-key-device_key-never-session_id)). The kit counts yours as supplied only when it is a string with at least one non-whitespace character, so a non-string or an all-whitespace `device_key` is treated as absent and the auto key is merged over it. |
256
256
  | `userId` | `string` | Your own OPAQUE user id, so onboarding sessions reconcile to real users later (console sessions to your user table / GA4 users). Optional and supports **late binding**: present at mount it rides the session-start metadata; if it changes mid-session (the user just registered) the kit emits an `identify` event; available only after the flow, use `identifyOnboarding(...)`. **No PII** (not an email/name/phone); trimmed and capped at 128 chars. See [User identity](#user-identity). |
257
257
 
258
258
  ## Helpers (the reusable substrate)
@@ -378,6 +378,8 @@ await clearUserContext({ storage, appId }); // the wire/activation path (then re
378
378
 
379
379
  **3. The auto `device_key` is a per-install identifier.** When you supply no `deviceKey`, the analytics and activation surfaces auto-mint a stable per-install `device_key`, persist it via `storage` (`wireai:analytics:deviceKey:<appId>`), and stamp it on every event so the server's review/questionnaire gating and A/B stickiness work out of the box. It carries no hardware id, no IDFA/GAID, and cannot be joined across apps (the privacy category of a first-party cookie), but it IS a persistent per-install id. If you adopt these surfaces, declare it in your App Privacy / Data Safety accordingly. Supply your own `deviceKey` to override it.
380
380
 
381
+ **The review gate is on that list too, even if it is the only surface you mount.** Since 0.15.0 `<ReviewGate>` stamps `meta.device_key` onto its `POST /v1/reviews` body when you passed none. That id is the unit the server scopes the review's idempotency key to, and a post without it gets the key discarded. The gate adopts whatever per-install id the analytics and activation surfaces already registered in this process, so it never opens a second id space. On a reviews-only host, where nothing else registered one, it mints its own instead. `<ReviewGate>` takes no `storage` prop, so that minted id is process-scoped: a fresh value on each launch, never persisted, rather than a stable per-install one. Either way it leaves the device inside a review payload. Declare it. Pass your own non-blank `meta.device_key` and it is forwarded verbatim instead.
382
+
381
383
  ### Do not pass `sessionId` to `createAnalytics`
382
384
 
383
385
  `CreateAnalyticsConfig.sessionId` is an opt-out knob, not a default. Set it and **every** event that
@@ -801,8 +803,12 @@ consuming app, since the kit has no local TS install).
801
803
 
802
804
  The kit ships a performance-first guided-tour engine on a **subpath** so the main
803
805
  barrel stays dependency-free — import it from `@wireai/activation/coachmarks`. It
804
- pulls in two **optional** peers only when you use it: `react-native-reanimated`
805
- (UI-thread gesture + ring animation) and `expo-blur` (the frosted spotlight).
806
+ can USE two **optional** peers, and needs neither: `react-native-reanimated`
807
+ (UI-thread gesture + ring animation) and `expo-blur` (the frosted spotlight). Both
808
+ sit behind a guarded lazy require, so importing the subpath never pulls them into
809
+ your bundle. Without reanimated the tour renders statically (the ring, tooltip,
810
+ copy and gestures are unchanged — nothing animates); without expo-blur the frost
811
+ becomes a plain dimmed scrim.
806
812
  Source consumers get it through the existing `@wireai/activation/*` tsconfig path
807
813
  mapping; installed apps resolve the subpath export.
808
814
 
@@ -814,8 +820,10 @@ below) — nothing to rewire.
814
820
  **1. Mount the provider once, around your `NavigationContainer`.** It renders the
815
821
  overlay host as a root sibling so a ring can paint _above_ the bottom tab bar
816
822
  (which react-navigation draws over screen components). It takes a **synchronous**
817
- storage adapter — gate reads must not be async or a ring flashes before the
818
- "already seen" check resolves. An MMKV wrapper is three lines:
823
+ storage adapter — gate reads resolve during render. An async adapter (e.g.
824
+ AsyncStorage handed straight in) is caught by a probe and the gates FAIL CLOSED:
825
+ coachmarks, showcases and the review/questionnaire gates all stay silent, and a
826
+ dev-build warning names the fix. An MMKV wrapper is three lines:
819
827
 
820
828
  ```tsx
821
829
  import { CoachmarkProvider } from "@wireai/activation/coachmarks";
@@ -922,6 +930,12 @@ theme colors, and gates once through the same storage as the tours
922
930
  (`wire_showcase_<id>_seen`; `isTestingCoachmark` bypasses it). If already seen it
923
931
  renders nothing and calls `onDone` from an effect.
924
932
 
933
+ The pager is a guarded lazy require, so importing `@wireai/activation/showcase`
934
+ does not pull it into your bundle. If you never install it the showcase renders
935
+ nothing and calls `onDone` — your flow advances exactly as if the deck had been
936
+ seen — and the seen gate is NOT written, so installing the peer later still plays
937
+ the deck once.
938
+
925
939
  **Recommended placement (v0.3.0+): AFTER `onComplete`, as a personalized value
926
940
  bridge.** The user just told you what they want in `result.answers`, so the
927
941
  terminal recap is the highest-value slot to show the 2-3 slides that answer it,
@@ -1235,7 +1249,7 @@ Skip the onboarding half and the purchase events are still valid on their own, t
1235
1249
 
1236
1250
  Three rules govern it:
1237
1251
 
1238
- - **Your key always wins.** Any non-empty `device_key` you pass is forwarded verbatim and nothing is merged over it. Existing integrations see a byte-identical payload.
1252
+ - **Your key always wins.** A `device_key` you pass is forwarded verbatim, untrimmed, and nothing is merged over it — as long as it is a string with at least one non-whitespace character, which is the kit's entire test for "the host supplied one" (the trim is used only to decide presence). A non-string (the `userContext` type also allows numbers and booleans) or an all-whitespace value is not a usable join key: it is treated as absent and the auto key is merged over it. Existing integrations that pass a real key see a byte-identical payload.
1239
1253
  - **It needs `storage`.** Without persistence the kit's id is minted fresh on every launch, and a per-launch key is worse than no key: the server counts `min_sessions` by distinct app-opens grouped on `device_key`, so it corrupts the counter instead of leaving the join empty. With no `storage` the kit declines to inject and warns in dev.
1240
1254
  - **You can opt out.** `autoJoinKey={false}` restores the pre-0.12.2 behavior for a host that genuinely wants an unlinked session. The dev warning fires again there, naming the opt-out as the reason nothing was injected.
1241
1255
 
@@ -1,10 +1,10 @@
1
- export { R as ReportAppEventOptions, r as reportAppEvent } from '../transport-j5gFfJhK.mjs';
2
- import { C as ClientEventTarget, W as WireUserContext, E as EventQueueOptions } from '../currentSession-CUvTOchb.mjs';
3
- export { A as AUTO_DEVICE_ID_PREFIX, a as AnalyticsEvent, b as ClearUserContextOptions, c as ClientEvent, d as ClientEventType, e as ContextEnvelope, f as ContextEnvelopeInput, D as DeviceKeyStorage, g as EnvelopeSource, h as EventQueue, R as ResolveAutoDeviceKeyOptions, i as WIRE_ONBOARDING_EVENTS, j as WireOnboardingEventName, k as analyticsUserIdStorageKey, l as buildContextEnvelope, m as clearPiiFromContext, n as clearUserContext, o as createEventQueue, p as deviceIdStorageKey, q as ensureCurrentSessionId, r as getCurrentSessionId, s as looksLikeEmail, t as makeSessionId, u as reportClientEvent, v as reportClientEventAwait, w as reportClientEvents, x as reportClientEventsAwait, y as resetAutoDeviceKeys, z as resetCurrentSessionId, B as resetEventQueueKeys, F as resolveAutoDeviceKey, G as setCurrentSessionId, H as toAnalyticsEvent } from '../currentSession-CUvTOchb.mjs';
1
+ export { R as ReportAppEventOptions, r as reportAppEvent } from '../transport-s5QxA-ci.mjs';
2
+ import { C as ClientEventTarget, W as WireUserContext, E as EventQueueOptions } from '../currentSession-orZy5p1e.mjs';
3
+ export { A as AUTO_DEVICE_ID_PREFIX, a as AnalyticsEvent, b as ClearUserContextOptions, c as ClientEvent, d as ClientEventType, e as ContextEnvelope, f as ContextEnvelopeInput, D as DeviceKeyStorage, g as EnvelopeSource, h as EventQueue, R as ResolveAutoDeviceKeyOptions, i as WIRE_ONBOARDING_EVENTS, j as WireOnboardingEventName, k as analyticsUserIdStorageKey, l as buildContextEnvelope, m as clearPiiFromContext, n as clearUserContext, o as createEventQueue, p as deviceIdStorageKey, q as ensureCurrentSessionId, r as getCurrentSessionId, s as looksLikeEmail, t as makeSessionId, u as reportClientEvent, v as reportClientEventAwait, w as reportClientEvents, x as reportClientEventsAwait, y as resetAutoDeviceKeys, z as resetCurrentSessionId, B as resetEventQueueKeys, F as resolveAutoDeviceKey, G as setCurrentSessionId, H as toAnalyticsEvent } from '../currentSession-orZy5p1e.mjs';
4
4
  import { W as WireOnboardingStorage } from '../types-BpwiRpA8.mjs';
5
- import '../types-Cju-1_jT.mjs';
5
+ import '../types-Byx306Kv.mjs';
6
6
  import '../types-BKfpdZzX.mjs';
7
- import '../types-BcmagF6K.mjs';
7
+ import '../types-D_0B0yay.mjs';
8
8
  import 'react';
9
9
  import 'wireai-rn';
10
10
  import 'react-native';
@@ -1,10 +1,10 @@
1
- export { R as ReportAppEventOptions, r as reportAppEvent } from '../transport-B_0SgCBe.js';
2
- import { C as ClientEventTarget, W as WireUserContext, E as EventQueueOptions } from '../currentSession-CW_5Mq4O.js';
3
- export { A as AUTO_DEVICE_ID_PREFIX, a as AnalyticsEvent, b as ClearUserContextOptions, c as ClientEvent, d as ClientEventType, e as ContextEnvelope, f as ContextEnvelopeInput, D as DeviceKeyStorage, g as EnvelopeSource, h as EventQueue, R as ResolveAutoDeviceKeyOptions, i as WIRE_ONBOARDING_EVENTS, j as WireOnboardingEventName, k as analyticsUserIdStorageKey, l as buildContextEnvelope, m as clearPiiFromContext, n as clearUserContext, o as createEventQueue, p as deviceIdStorageKey, q as ensureCurrentSessionId, r as getCurrentSessionId, s as looksLikeEmail, t as makeSessionId, u as reportClientEvent, v as reportClientEventAwait, w as reportClientEvents, x as reportClientEventsAwait, y as resetAutoDeviceKeys, z as resetCurrentSessionId, B as resetEventQueueKeys, F as resolveAutoDeviceKey, G as setCurrentSessionId, H as toAnalyticsEvent } from '../currentSession-CW_5Mq4O.js';
1
+ export { R as ReportAppEventOptions, r as reportAppEvent } from '../transport-xqqSFqxs.js';
2
+ import { C as ClientEventTarget, W as WireUserContext, E as EventQueueOptions } from '../currentSession-CFSRZ2wg.js';
3
+ export { A as AUTO_DEVICE_ID_PREFIX, a as AnalyticsEvent, b as ClearUserContextOptions, c as ClientEvent, d as ClientEventType, e as ContextEnvelope, f as ContextEnvelopeInput, D as DeviceKeyStorage, g as EnvelopeSource, h as EventQueue, R as ResolveAutoDeviceKeyOptions, i as WIRE_ONBOARDING_EVENTS, j as WireOnboardingEventName, k as analyticsUserIdStorageKey, l as buildContextEnvelope, m as clearPiiFromContext, n as clearUserContext, o as createEventQueue, p as deviceIdStorageKey, q as ensureCurrentSessionId, r as getCurrentSessionId, s as looksLikeEmail, t as makeSessionId, u as reportClientEvent, v as reportClientEventAwait, w as reportClientEvents, x as reportClientEventsAwait, y as resetAutoDeviceKeys, z as resetCurrentSessionId, B as resetEventQueueKeys, F as resolveAutoDeviceKey, G as setCurrentSessionId, H as toAnalyticsEvent } from '../currentSession-CFSRZ2wg.js';
4
4
  import { W as WireOnboardingStorage } from '../types-BpwiRpA8.js';
5
- import '../types-h2BZvl1t.js';
5
+ import '../types-tdATL5z0.js';
6
6
  import '../types-BKfpdZzX.js';
7
- import '../types-BcmagF6K.js';
7
+ import '../types-D_0B0yay.js';
8
8
  import 'react';
9
9
  import 'wireai-rn';
10
10
  import 'react-native';