@wireai/activation 0.13.0 → 0.13.3

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 (82) hide show
  1. package/AGENTS.md +7 -4
  2. package/CHANGELOG.md +341 -4
  3. package/README.md +98 -2
  4. package/dist/analytics/index.d.mts +4 -4
  5. package/dist/analytics/index.d.ts +4 -4
  6. package/dist/analytics/index.js +164 -471
  7. package/dist/analytics/index.js.map +1 -1
  8. package/dist/analytics/index.mjs +162 -469
  9. package/dist/analytics/index.mjs.map +1 -1
  10. package/dist/coachmarks/index.d.mts +14 -1
  11. package/dist/coachmarks/index.d.ts +14 -1
  12. package/dist/coachmarks/index.js +58 -14
  13. package/dist/coachmarks/index.js.map +1 -1
  14. package/dist/coachmarks/index.mjs +58 -14
  15. package/dist/coachmarks/index.mjs.map +1 -1
  16. package/dist/{currentSession-_GynvhzT.d.mts → currentSession-BoWtr3Jp.d.mts} +299 -14
  17. package/dist/{currentSession-D7zabMXK.d.ts → currentSession-CCOMlaQ2.d.ts} +299 -14
  18. package/dist/{decision-Bl_M2y3r.d.mts → decision-Cjw0jbEj.d.mts} +1 -1
  19. package/dist/{decision-Cau5KmP6.d.ts → decision-GfpXAsk_.d.ts} +1 -1
  20. package/dist/index.d.mts +218 -4
  21. package/dist/index.d.ts +218 -4
  22. package/dist/index.js +1104 -888
  23. package/dist/index.js.map +1 -1
  24. package/dist/index.mjs +887 -692
  25. package/dist/index.mjs.map +1 -1
  26. package/dist/questionnaire/index.d.mts +42 -10
  27. package/dist/questionnaire/index.d.ts +42 -10
  28. package/dist/questionnaire/index.js +45 -13
  29. package/dist/questionnaire/index.js.map +1 -1
  30. package/dist/questionnaire/index.mjs +45 -13
  31. package/dist/questionnaire/index.mjs.map +1 -1
  32. package/dist/reviews/index.d.mts +4 -4
  33. package/dist/reviews/index.d.ts +4 -4
  34. package/dist/reviews/index.js +39 -8
  35. package/dist/reviews/index.js.map +1 -1
  36. package/dist/reviews/index.mjs +39 -8
  37. package/dist/reviews/index.mjs.map +1 -1
  38. package/dist/showcase/index.js +7 -2
  39. package/dist/showcase/index.js.map +1 -1
  40. package/dist/showcase/index.mjs +7 -2
  41. package/dist/showcase/index.mjs.map +1 -1
  42. package/dist/{transport-DsRe4epC.d.ts → transport-CefdERPs.d.mts} +44 -5
  43. package/dist/{transport-CF_eHwzC.d.mts → transport-DFuPyCoJ.d.ts} +44 -5
  44. package/dist/{types-Buj9Lw9t.d.ts → types-UVYt9BJe.d.ts} +41 -1
  45. package/dist/{types-CNUqMK0D.d.mts → types-l2tfg23P.d.mts} +41 -1
  46. package/package.json +4 -1
  47. package/src/OnboardingFlow.tsx +175 -15
  48. package/src/WireOnboarding.tsx +70 -8
  49. package/src/activation/wireActivation.ts +3 -3
  50. package/src/analytics/analyticsEvent.ts +16 -1
  51. package/src/analytics/analyticsFacade.ts +2 -2
  52. package/src/analytics/eventQueue.ts +85 -20
  53. package/src/analytics/reportClientEvent.ts +68 -12
  54. package/src/cards/CardGridSelectCard.tsx +1 -1
  55. package/src/cards/ChipSelectCard.tsx +2 -2
  56. package/src/cards/PermissionCard.tsx +438 -0
  57. package/src/cards/SelectionCard.tsx +1 -1
  58. package/src/cards/index.ts +7 -0
  59. package/src/coachmarks/runtime.ts +62 -2
  60. package/src/coachmarks/useCoachmarkTour.ts +62 -14
  61. package/src/context/deviceId.ts +4 -4
  62. package/src/features/WireFeaturesProvider.tsx +4 -1
  63. package/src/identity/userIdentity.ts +1 -1
  64. package/src/illustrations/defaultIllustrations.tsx +44 -3
  65. package/src/index.ts +38 -0
  66. package/src/permissions/index.ts +64 -0
  67. package/src/permissions/permissionCopy.ts +87 -0
  68. package/src/permissions/permissionEvents.ts +76 -0
  69. package/src/permissions/permissionMemory.ts +88 -0
  70. package/src/permissions/placement.ts +88 -0
  71. package/src/permissions/types.ts +131 -0
  72. package/src/questionnaire/QuestionnaireGate.tsx +90 -10
  73. package/src/questionnaire/index.ts +9 -1
  74. package/src/questionnaire/transport.ts +46 -11
  75. package/src/reviews/ReviewGate.tsx +61 -6
  76. package/src/reviews/index.ts +10 -1
  77. package/src/reviews/transport.ts +58 -12
  78. package/src/session/persistedSession.ts +10 -3
  79. package/src/session-analytics/useLifecycleEvents.ts +1 -1
  80. package/src/theme/ThemeContext.tsx +42 -1
  81. package/src/types.ts +66 -4
  82. package/src/utils/submitResult.ts +39 -0
package/AGENTS.md CHANGED
@@ -32,7 +32,7 @@ Everything an integration needs lives on the ROOT import (`@wireai/activation`).
32
32
  subpaths are optional secondary feature modules; import one only if you use it.
33
33
 
34
34
  - `@wireai/activation` (root): `WireOnboarding`, the helpers/types below, and the device-only event reporters (`reportClientEvent`, `makeSessionId`, `WIRE_ONBOARDING_EVENTS`, see "App events" below).
35
- - `@wireai/activation/analytics`: the analytics FACADE and its queue: `createAnalytics`, `useAnalytics`, `createScreenTracker`, `reportAppEvent`, `createEventQueue`, `clearUserContext`. These are NOT root exports; importing them from the root does not compile.
35
+ - `@wireai/activation/analytics`: the analytics FACADE and its queue: `createAnalytics`, `useAnalytics`, `createScreenTracker`, `reportAppEvent`, `createEventQueue`, `clearUserContext`. Except for `clearUserContext`, these are NOT root exports; importing them from the root does not compile. `clearUserContext` IS also a root export (`import { clearUserContext } from "@wireai/activation"`), which is the form the README teaches for the sign-out purge.
36
36
  - `@wireai/activation/coachmarks`: in-app coachmark tour (`CoachmarkProvider`, `useCoachmarkTour`).
37
37
  - `@wireai/activation/showcase`: post-onboarding feature showcase (`FeatureShowcase`).
38
38
  - `@wireai/activation/reviews`: store-review gating (`ReviewGate`, `ReviewModal`, `useReviewGate`, `fetchReviewDecision`).
@@ -48,6 +48,7 @@ subpaths are optional secondary feature modules; import one only if you use it.
48
48
  - `themeFromBrand({ primary })`: derive a full theme from one brand color.
49
49
  - `mergeTheme`, `defaultOnboardingTheme`, `OnboardingThemeProvider`, `useOnboardingTheme`.
50
50
  - `defaultIllustrations`: dependency-free fallback art; spread your own over it.
51
+ - `WIRE_PERMISSION_EVENTS`, `resolvePermissionCopy(...)`: the canonical permission-funnel names and the shipped rationale copy behind `permissionScreens` (see the prop below). The kit imports no native permission module; the host injects `request`.
51
52
  - `createRevenueCatBridge({ analytics, entitlementId })`: the RevenueCat purchase funnel (see "RevenueCat" below).
52
53
  - `activationJoinContext(deviceKey)`: builds the `userContext` value that joins an onboarding session to the app's later events. Every `<WireOnboarding>` needs it (see step 7).
53
54
  - `resolveAutoDeviceKey({ appId, storage })`: the kit's own persisted per-install `device_key`, for a host that owns none.
@@ -65,9 +66,10 @@ subpaths are optional secondary feature modules; import one only if you use it.
65
66
  | `illustrations` | `Record<string, ReactNode>` | no | Host artwork for `InterstitialCard`, keyed by name. `{ ...defaultIllustrations, ...myArt }`. |
66
67
  | `icons` | `Record<string, ReactNode>` | no | Host icon nodes keyed by the semantic vocabulary name the AI emits (`{ instagram: <BrandIg/> }`). Checked FIRST: use it to brand an icon, to add names of your own, or to supply icons without installing `@expo/vector-icons`. Unlisted names fall back to that optional peer, then to no icon. |
67
68
  | `validators` | `Record<string, StepValidator>` | no | Per base-question key (e.g. `username`). Blocks advance + inline error. |
69
+ | `permissionScreens` | `PermissionScreenConfig[]` | no | Priming screens injected mid-flow (notifications first). `{ permission, placement?, request, getStatus?, openSettings?, copy?, illustration?, onResult? }`. The OS dialog opens ONLY on the primary tap, never on mount; "Maybe later" advances without spending the one native prompt. `placement` is `"start"`, `{ afterCard: n }` or `"beforeEnd"` (default), and an `afterCard` past the end of the stream clamps to `"beforeEnd"`. Zero new dependencies: the host injects `request`, the kit imports no native permission module. Shown once per session and, with `storage`, across an app kill. Not a question: no `key`/`slot_id`, nothing in `answers`, completion never blocks on a grant. |
68
70
  | `onSkip` | `() => void` | no | Retained for back-compat. Per-question Skip is now internal (the kit advances one question on `skippable` screens), so this is no longer wired to that control. |
69
71
  | `onError` | `(err) => void` | no | Backend error/timeout after retries. Host recovery (e.g. static onboarding). `fallbackFlow` takes precedence over it. |
70
- | `onEvent` | `(e: OnboardingEvent) => void` | no | Lifecycle events for analytics (kit owns the loop). Variants: `started`, `resumed`, `turn`, `error`, `retry`, `fallback` (see `OnboardingEvent`). |
72
+ | `onEvent` | `(e: OnboardingEvent) => void` | no | Lifecycle events for analytics (kit owns the loop). Variants: `started`, `resumed`, `turn`, `error`, `retry`, `fallback`, `permission` (see `OnboardingEvent`). |
71
73
  | `copy` | `Partial<OnboardingCopy>` | no | Localize built-in English strings. |
72
74
  | `approxScreens` | `number` | no | Paces the bar; backend `progress.total` wins, so usually unneeded. |
73
75
  | `startMessage` | `string` | no | First backend message. Default `"start"`. |
@@ -98,7 +100,7 @@ FACADE (`createAnalytics` and friends) is a different surface and lives on `@wir
98
100
  - `WIRE_ONBOARDING_EVENTS` + `toAnalyticsEvent(event)`: canonical `wire_onboarding_*` funnel names; log via the app's own analytics.
99
101
  - `reportClientEvent(target, event)` / `reportClientEvents(target, events)`: report device-only funnel events. Contract: `POST {serverUrl}/v1/events`, header `Authorization: Bearer {apiKey}`, body `{ "events": [ ... ] }`. `target = { serverUrl, apiKey }` is derived from `WireOnboardingConfig`. Fire-and-forget: never throws into the UI.
100
102
  - `makeSessionId()`: a unique-per-onboarding session id (also seeds the A2A `contextId` so client and server events correlate).
101
- - `ClientEventType` is `"screen_skipped" | "dropped" | "client_fallback" | "identify"`. `<WireOnboarding>` auto-reports `dropped` (unmount without complete) and `client_fallback` (degrade to static). Hosts must NOT double-report fallback.
103
+ - `ClientEventType` is `"screen_skipped" | "dropped" | "client_fallback" | "identify" | "app_event"` (`app_event` is the type the whole `wire.track` / `reportAppEvent` surface rides on). `<WireOnboarding>` auto-reports `dropped` (unmount without complete) and `client_fallback` (degrade to static). Hosts must NOT double-report fallback.
102
104
 
103
105
  ## Integration steps (ordered)
104
106
 
@@ -115,7 +117,8 @@ FACADE (`createAnalytics` and friends) is a different surface and lives on `@wir
115
117
  ## Review firing (do not hand-roll the decision fetch)
116
118
 
117
119
  If you gate the review prompt on the server, call **`fetchReviewDecision(target, { deviceKey })`**
118
- from `@wireai/activation/reviews` and pass the result to `useReviewGate({ decision })`. Never
120
+ from `@wireai/activation/reviews` and pass the result to `useReviewGate({ config, decision })`.
121
+ `config` is REQUIRED (the hook reads `config.id`); `decision` alone does not compile. Never
119
122
  write your own fetch.
120
123
 
121
124
  - It returns the FULL `{ fire, reason, arm }` on 2xx, and `null` ONLY when the server is
package/CHANGELOG.md CHANGED
@@ -3,6 +3,340 @@
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.13.3] — 2026-08-02
7
+
8
+ The headline is a fix nobody could see from inside the kit: **on the DEFAULT configuration, a
9
+ backend that never answered left the user on the loading screen forever.** Everything else in this
10
+ release is the machinery that let a defect that severe stay invisible to a 638-test suite.
11
+
12
+ ### Fixed
13
+
14
+ - **A retry could be recorded as the user's FIRST ANSWER.** Caught pre-publish, and like the double
15
+ review row it was NEW in this release — the retry-budget fix below is what made it reachable. The
16
+ auto-start effect keyed the kickoff payload on `resumed`, a MOUNT-time prop, so a retry inside a
17
+ fresh run re-sent `startMessage`. The server records any non-empty message that arrives while a
18
+ question is pending as that question's answer (`if user_message and pending and not
19
+ _stop_requested(...)` → appends `{key, question, answer}` to `session["answers"]`). The trigger
20
+ is not an outage: the watchdog fires at `startTimeoutMs` (15s default) while a slow LLM is still
21
+ generating card 1, the server already has that card pending, and the retry lands as its answer.
22
+ The user's first onboarding answer silently becomes the literal kickoff string — and answer data
23
+ is the product. On 0.13.2 this was unreachable because no second kickoff could ever fire.
24
+
25
+ Only the first attempt of a fresh session now sends `startMessage`; a resumed mount and every
26
+ retry (auto, timeout-driven, and the "Try again" button) send the skip sentinel, which is safe in
27
+ both server states — with a pending question it skips one card recording nothing, with no pending
28
+ it is zeroed and behaves as a normal kickoff.
29
+
30
+ ⚠️ **The cost, stated rather than hidden: when the server DID hold card 1 pending, the retry now
31
+ SKIPS that card — the user loses one question.** That is deliberate. A skipped card emits
32
+ `screen_skipped` and is visible in the funnel; a kickoff string stored as an answer is invisible
33
+ and corrupts the data every downstream decision reads. Losing a question beats inventing one.
34
+
35
+ - **A lost ack wrote TWO review rows.** Caught pre-publish, and it was NEW in this release: reading
36
+ the submit response is what made it possible, so 0.13.2 could not double-post. `submitReview`
37
+ returned `false` for two materially different outcomes — *nothing reached the server* and *the
38
+ server answered non-2xx* — and `ReviewGate.postOnce` un-latches on `false`, so the abandonment net
39
+ re-posted the identical body. A gateway 502 that arrives AFTER the row commits therefore wrote a
40
+ second review row, double-counting `count` and corrupting `avg`: the exact corruption the one-row
41
+ latch exists to prevent, on the code path added to protect the data. There is no idempotency key
42
+ on the wire (`create_review` mints the row id; `CreateReviewRequest` has no id field), so the kit
43
+ cannot tell the two apart after the fact — it has to stop guessing.
44
+
45
+ `submitReview` now resolves **`ReviewSubmitResult`** — `"accepted"` / `"rejected"` / `"unsent"` —
46
+ instead of a boolean, and `postOnce` un-latches on `"unsent"` alone. A response of ANY status
47
+ proves the server was reached, and a row the server answered for is the server's problem. The
48
+ shape change is free: 0.13.3 was never published. Still non-throwing, still fired synchronously —
49
+ the caller never awaits.
50
+
51
+ ⚠️ **Honest residual: this is safer, not safe.** `"unsent"` is not proof the server never got the
52
+ row either — a connection dropped after the insert committed throws exactly like an offline
53
+ device, so the single in-process re-post can still duplicate in that narrow window. Closing it
54
+ needs a **client-minted idempotency key the server upserts on**, which is a server change and is
55
+ not something the kit can fake. Until then the kit prefers losing a row to inventing one: a lost
56
+ detractor is a gap in the data, a duplicated one is a lie in it.
57
+
58
+ - **The questionnaire had the same hole, and it was worse.** The review fix above landed and its
59
+ semantic twin got none of it. `submitQuestionnaireResponse` returned `void` over a body of
60
+ `void fetch(...).catch(() => {})`, so the response was never read at all, and
61
+ `QuestionnaireGate.finish` set `submittedRef`, fired `questionnaire_submitted` and showed the
62
+ thank-you unconditionally. `onResolved` → `markResolved` then writes the permanent
63
+ `wire_questionnaire_<id>_<ver>_seen` key. The questionnaire is the only surface that both burns a
64
+ permanent once-gate **and** had zero evidence of delivery: a user who answered offline lost their
65
+ answers and was never asked again. The CHANGELOG's own justification for the review fix applies
66
+ word for word.
67
+
68
+ `submitQuestionnaireResponse` now resolves **`QuestionnaireSubmitResult`** (the shared
69
+ `SubmitResult`: `"accepted"` / `"rejected"` / `"unsent"`), and the gate posts through a `postOnce`
70
+ latch that un-latches on `"unsent"` alone, with one in-process re-post when the gate unmounts.
71
+ Both surfaces now import the verdict from one place, so the retry rule cannot drift between them: a
72
+ server that ANSWERED, even non-2xx, is never retried, because the response id is server-minted and
73
+ the wire has no idempotency key. The signature change is free: 0.13.3 was never published. Still
74
+ non-throwing, still fired synchronously, the caller never awaits.
75
+
76
+ ⚠️ **Same honest ceiling as reviews:** recovery is in-process only. A run answered offline and
77
+ never recovered before the app is killed is still lost, and the host still writes the once-gate on
78
+ `onResolved`. Durable cross-launch retry needs new public API and is deliberately not in this
79
+ release.
80
+
81
+ - **The theme context was not shared across the built subpath bundles.** `OnboardingThemeContext`
82
+ was a plain `createContext(...)` and tsup inlines it into five bundles, so `dist/index.js`,
83
+ `dist/reviews/index.js`, `dist/questionnaire/index.js`, `dist/coachmarks/index.js` and
84
+ `dist/showcase/index.js` each carried their own instance. `OnboardingThemeProvider` is exported
85
+ only from the root barrel, so on any `dist` resolution path (plain RN CLI,
86
+ `unstable_enablePackageExports: false`, node, jest, SSR) a host that wrapped its tree in
87
+ `<OnboardingThemeProvider>` and rendered `<ReviewGate>` from `@wireai/activation/reviews` wrote
88
+ one context and read another. Nothing crashed: `useOnboardingTheme`'s `ctx ?? mergeTheme()`
89
+ fallback served the neutral default theme, so the tenant's brand silently did not apply. Metro
90
+ masked it on-device by collapsing every subpath back to one source file, the same bundler accident
91
+ behind the coachmark-runtime split and the optional-peers bug fixed above.
92
+
93
+ The context now lives on a `globalThis` slot keyed by
94
+ `Symbol.for("@wireai/activation:themeContext")`, exactly as `coachmarks/runtime`,
95
+ `analytics/currentSession`, `context/deviceId` and `features/WireFeaturesProvider` already did.
96
+ Measured in the built artifact, before and after: the root provider reached **0** of the four
97
+ subpath bundles' theme contexts before, and exactly **1 shared instance** after.
98
+ `test/canary/themeContextIdentity.test.tsx` loads the real bundles and pins it.
99
+
100
+ - **`WireFeaturesProvider` read `global` where the other nine symbol slots read `globalThis`.**
101
+ `global` does not exist in a plain browser, so that line was a `ReferenceError` waiting for the
102
+ first RN-web or browser-SSR host to mount the provider.
103
+
104
+ - **`maxRetries` never re-armed, so the fallback was unreachable on every host running the default.**
105
+ `maxRetries` defaults to `1`, and `1` and `2` behaved *identically*: exactly one retry fired, ever,
106
+ and no terminal state was ever reached. Measured before the fix — `0` → `{kickoffs:1, fallback:1}`,
107
+ `1` → `{kickoffs:1, fallback:0}`, `2` → `{kickoffs:1, fallback:0}`. Only `maxRetries={0}` reached
108
+ the fallback, which is the one value that trades the retry away entirely. A backend hung at start
109
+ therefore produced a 15s loader and then a permanent "Getting started…": no error, no fallback, no
110
+ way out. The "Try again" button was dead from the same cause, sending `1 → 1`.
111
+
112
+ Two causes, both required. `restartThread()` reset a **ref**, and a ref cannot re-trigger an
113
+ effect — the auto-start effect's deps held only stable values. And the startup watchdog keyed on
114
+ `lastCard`, which is still `undefined` after a retry, so no new timer ever armed and `timedOut`
115
+ could not become true a second time. Both effects now key on an incrementing `runId` held in
116
+ **state**, so a retry genuinely re-arms both; `started`/`resumed` deliberately do **not** re-fire,
117
+ so a retry cannot double-count a session in the host's funnel. After the fix, `maxRetries` means
118
+ what it says: `1` → `{kickoffs:2, fallback:1}`, `2` → `{kickoffs:3, fallback:1}`.
119
+
120
+ It survived a 638-test suite because the canary's `wireai-rn` mock hardcoded `error: undefined`,
121
+ leaving the entire error/retry/fallback state machine with **zero render coverage**. The mock
122
+ gained an error mode and the budget is now asserted for 0, 1 and 2 along with the timeout path
123
+ and the "Try again" button.
124
+
125
+ - **A slow cold-start storage read silently destroyed the persisted analytics backlog.** The
126
+ hydration read races a 1500 ms deadline, and the loser resolved to `undefined` — indistinguishable
127
+ from "the adapter answered, there is no backlog". Hydration took its empty early return, the
128
+ surrounding `catch` never ran, and the next `persist()` wrote the in-memory list over the blob
129
+ nobody had read; with nothing pending it deleted the blob outright. Up to `maxSize` = **200**
130
+ real events lost on nothing worse than a cold start where storage took two seconds, with no error,
131
+ no log and nothing to grep for. A timeout is now a distinct sentinel, persistence is suppressed
132
+ until the read settles, and the original read is still awaited — the late backlog is merged and
133
+ flushed rather than abandoned. No test injected a slow adapter; one does now.
134
+
135
+ - **A review rating was latched as posted before the request settled.** The once-gate was written
136
+ regardless, and the transport was a fire-and-forget `fetch` whose response was never read — so a
137
+ 1-4 star rating with mandatory free text travelled the kit's least durable path while
138
+ `analytics.track` got a persisted queue with backoff and six retries. A detractor who rated
139
+ offline was dropped *and* never asked again. `submitReview` now reports delivery, the latch is
140
+ released when the row did not land, and the undelivered body is re-posted by the existing
141
+ abandonment net. ⚠️ Recovery is in-process only: a rating taken offline and then killed with the
142
+ app is still lost. A persisted review outbox is deliberately left for a later release.
143
+
144
+ - **The coachmarks kill switch could leave a tour unable to finish.** The flag is a mutable global
145
+ read once with no subscription: the arm effect omitted it from its deps and the drive effect never
146
+ read it, so flipping it mid-tour made `show()` a silent no-op and left `finish()`/`onComplete`
147
+ unreachable. It is now subscribable and read through `useSyncExternalStore` in both effects, and a
148
+ mid-tour disable exits terminally **without** writing the once-gate, so the tour is not consumed.
149
+
150
+ - **Four of the six optional peers were missing from the published bundle entirely.** Measured with
151
+ Metro's own dependency collector, the 0.13.2 tarball's `dist/` collected **2 of 6**
152
+ `OPTIONAL_MODULES` while the same collector over `src/` collected **6 of 6** — so on every
153
+ resolution path that lands on `dist/` (plain RN CLI, `unstable_enablePackageExports: false`, node,
154
+ jest) `@expo/vector-icons`, `expo-constants`, `expo-application` and `expo-device` were not in the
155
+ bundle at all: `WireIcon` renders null and `by_model` / `by_app_version` stay empty, with the
156
+ guard's own `catch` hiding it. Source consumers (both monorepo apps, and anything else taking
157
+ the `react-native` condition) were never affected, which is why a green suite and two shipping
158
+ apps said nothing. Two independent build-config faults, both silent at exit 0 with zero warnings:
159
+ - `tsup.config.ts` `external` was a hand-maintained list covering 2 of the 6. The consequence
160
+ depended on the BUILD MACHINE: a module that happened to resolve in the kit's own
161
+ `node_modules` (`expo-constants`, pulled in transitively) was **inlined whole** into the bundle,
162
+ so the host's copy went unused; one that did not resolve was left as an orphan call. `external`
163
+ is now **derived from `metro/index.js`'s `OPTIONAL_MODULES`** — the same list the Metro helper
164
+ already treats as the source of truth — plus the statically-imported peers, with a build-time
165
+ throw if the two ever diverge. The output no longer depends on what is installed here.
166
+ - Marking them `external` was necessary and **not sufficient**. `treeshake: true` is not an
167
+ esbuild flag; tsup implements it as a Rollup pass whose ESM output is then re-derived into CJS,
168
+ and in that conversion esbuild renames `require("expo-device")` to `__require("expo-device")`,
169
+ external or not. Metro's collector matches only a callee literally named `require`, so a
170
+ renamed call is collected nowhere — the 0.8.0 bug, reintroduced by a build flag rather than by
171
+ source. The build now carries a small `renderChunk` hook that restores the literal `require`
172
+ for exactly the `OPTIONAL_MODULES` specifiers, and only in CJS output, where the rewrite is an
173
+ identity (esbuild defines `__require` as `typeof require !== "undefined" ? require : …`, so in
174
+ a CJS chunk it already *is* `require`). `treeshake` stays on and every other byte of the bundle
175
+ keeps its current shape — `dist/index.js` is 218.6 KB, down from 232.2 KB purely because
176
+ `expo-constants` is no longer inlined. `dist/*.mjs` keeps `__require`: ESM has no `require` to
177
+ preserve, and no React Native path reads it (`react-native` → `src/`, `main` / the `require`
178
+ condition → `dist/*.js`).
179
+
180
+ `test/canary/metroResolution.test.tsx` ran Metro's collector over `src/` only, which is how a
181
+ publish-surface defect stayed invisible to the test that owns exactly this property. It now runs
182
+ the same assertions over the BUILT `dist/` (build-if-stale, never skipped), with its own negative
183
+ control pinning that a `__require`-shaped call collects nothing. Verified against the pre-fix
184
+ config: the new assertion goes red and names all four missing modules.
185
+
186
+ - **The banned-name canary now actually gates a publish.** It was documented as a "build-time canary
187
+ that fails the pack" and it was neither: `prepack` was `npm run build` and ran no test at all, so a
188
+ planted banned name packed at **exit 0**, inside the tarball. The second half compounded it
189
+ — the canary derives its scan set from `package.json` `files`, which includes `dist`, but `dist` is
190
+ gitignored and sits in the scanner's `OPTIONAL_WHEN_ABSENT` list, so on an unbuilt tree the suite
191
+ was **6/6 green while covering none of the built output that ships**. Fixing either half alone
192
+ still ships a name under some ordering. The gate is now one script that closes both:
193
+ `prepublishOnly` = `npm run build && npm run test:canary`. It fires on `npm publish`, and it fires
194
+ *after* the build, so `dist/` is on disk and inside the scan. `test:canary` is a new script that
195
+ runs `test/canary/**` on its own, and `bannedNames.test.tsx` gained a test that asserts this wiring
196
+ from inside the suite, so deleting the gate goes red instead of silently reopening the hole.
197
+ ⚠️ `npm pack` on its own still does not run the canary — the honest verb is *publish*, not *pack*.
198
+
199
+ ### Changed
200
+
201
+ - **Test integrity.** `npm test` now builds first (`pretest`), because the new `dist/` assertions
202
+ shell out to a build with `clean: true` while other test files walk `dist/` in parallel — that
203
+ race could redden CI for a reason unrelated to any change. Separately, the pre-existing
204
+ `DE-DUP: re-enqueuing an identical pending event is collapsed` test was **flaky on `main`**: it
205
+ fires three synchronous enqueues against a live clock while the queue stamps `ts` into the de-dup
206
+ signature, so a millisecond boundary between them makes the third a genuine repeat by the queue's
207
+ own rules. Measured at 0.32% of runs on an untouched `main`, 0/6000 with the clock frozen; it is
208
+ now pinned exactly as its three sibling tests already were. And `mergePersisted`'s de-dup branch
209
+ had **zero coverage** — it could be deleted with the whole suite green — which matters precisely
210
+ because the backlog fix above newly merges a late read into a pending list that may already hold
211
+ the same event. It is covered now.
212
+
213
+ - **Four guards this release relies on had no test at all**, found by mutating the suite. Each one
214
+ was a promise made in writing and enforced by nothing, so each could be deleted with a fully green
215
+ run: (1) `started`/`resumed` not re-firing on a retry — stated right here as *"a retry cannot
216
+ double-count a session"*, and in tension with the `runId` re-arm by construction, so a host funnel
217
+ would have read one flaky cold start as three sessions; (2) `pretest` itself, whose removal
218
+ silently reopens the `clean: true` race described above; (3) both build-time throws in
219
+ `tsup.config.ts`, which are the only thing standing between a refactor of the `external`
220
+ derivation and a bundle that silently inlines or orphans every guarded optional peer at exit 0;
221
+ and (4) the dist half of `metroResolution`'s HARD-dependency check, which its own
222
+ `.filter((m) => !collected.has(m))` had rendered unfailable — a module collected optional in one
223
+ chunk excused a hard require of it in another, and Metro resolves per call site, not per package.
224
+
225
+ ## [0.13.2] — 2026-07-31
226
+
227
+ The headline is a fix: every `track()` / `screen()` `app_event` was being silently discarded by the
228
+ server behind an HTTP 200, on every kit version since 0.11.0. This release also ships the injectable
229
+ mid-flow permission screens below, and it is the FIRST version whose published tarball is clean of
230
+ the banned client name — the 0.13.0 publish-surface scrub rides a canary that fails the run if the
231
+ name ever reappears.
232
+
233
+ > ⚠️ **Corrected 2026-08-02.** This paragraph originally said the canary "fails the pack". It did
234
+ > not: at 0.13.2 nothing ran it on `npm pack` or `npm publish`, and on an unbuilt tree it did not
235
+ > scan `dist/` either. The 0.13.2 tarball is still clean — that was verified by hand — but it was
236
+ > clean without a gate holding it. See the 0.13.3 entry above for the fix.
237
+
238
+ ### Fixed
239
+
240
+ - **`ts` crosses the wire as ISO8601, so `app_event`s actually land.**
241
+ (`analytics/reportClientEvent.ts`) The server's event model declares `ts: str | None` and pydantic
242
+ v2 does not coerce a number into it, while the offline queue has stamped `ts = Date.now()` (epoch
243
+ ms) on every queued event since 0.11.0 — so `POST /v1/events` answered HTTP 200 with
244
+ `{written: 0, skipped: N, errors: [{field: "ts", reason: "validation_error"}]}` and 100% of
245
+ `track()` / `screen()` analytics evaporated behind a green response. `buildEventsRequest` — the
246
+ single choke point all four send paths share (offline queue, fire-and-forget, awaitable,
247
+ session-start) — now serializes a numeric `ts` to an ISO8601 UTC string on its way out. The queue
248
+ keeps its numeric stamp (the identical-JSON de-dup signature depends on it), a persisted 0.13.0
249
+ backlog is converted as it drains, a caller-set ISO string passes through untouched, and a
250
+ non-finite or out-of-range number drops the field from that one event instead of killing the batch.
251
+ - **The discard warning names the field the server refused.** (`readEventsAck`) The server's
252
+ `errors[]` entries carry a `field` alongside `reason`; the ack reader threw it away, so a dev build
253
+ warned `validation_error` with no address. It now prints `validation_error (field: ts)` — the
254
+ difference between a one-line fix and an investigation.
255
+
256
+ ### Added
257
+
258
+ Injectable mid-flow permission screens, starting with notifications.
259
+
260
+ Onboarding is where apps ask for notifications and where most of them lose the ask: iOS grants an
261
+ app exactly ONE native prompt for its entire lifetime, so firing it from a mount effect on screen
262
+ one spends it on a user who has been told nothing, and the only route back is a Settings trip almost
263
+ nobody makes. This release ships the priming pattern as a first-class part of the flow. You declare
264
+ a screen, it explains why in your words, and the OS dialog opens only on the user's primary tap.
265
+ "Maybe later" advances the flow with the prompt unspent.
266
+
267
+ Nothing changes for a host that configures no screen: the prop is optional and every path around it
268
+ is byte-identical.
269
+
270
+ - **`permissionScreens` on `<WireOnboarding>`** (`permissions/types.ts`, `cards/PermissionCard.tsx`) -
271
+ one or more priming screens injected into the server-driven card stream at a position you choose.
272
+ The screen carries customizable rationale copy, kit-quality defaults for notifications, optional
273
+ artwork through the existing illustrations registry, and an `onResult` callback per screen.
274
+ **DEPENDENCY-FREE, the RevenueCat-bridge idiom:** the kit imports no `expo-notifications`, no
275
+ `react-native-permissions`, nothing native. The host injects `request` and optionally `getStatus`
276
+ and `openSettings`, exactly the way it hands the RevenueCat bridge real RevenueCat objects. The
277
+ README shows the five-line wiring.
278
+ - **The priming rule, enforced in code and pinned by a canary.** There is no path from mount, from
279
+ an effect, from a timer or from render to the host's `request`; only the primary press handler
280
+ reaches it. The optional `getStatus` probe is a READ that never prompts, and all it decides is
281
+ which primary the screen offers: ask, open settings (status `blocked`, where an ask would show
282
+ nothing at all), or a plain continue (already granted, or a host that supplied no `request`, where
283
+ the kit declines rather than fabricating a button that does nothing).
284
+ - **A watchdog on the host's `request`, which fabricates nothing.** Asking sets a busy state that
285
+ disables both controls, which is right while an OS dialog may be open and fatal if the host's
286
+ `request` never settles at all (a swallowed native callback, a promise nobody resolves): the
287
+ screen would be a dead end with nothing tappable, and completion could never fire. A generous 90
288
+ second ceiling now hands the controls back so the user can retry or skip. On expiry it records NO
289
+ outcome and emits NO event, only a dev warning, because a pending request is not a denial and a
290
+ person can legitimately sit on a permission dialog for minutes. First settlement wins: if the
291
+ original request answers late, after the controls were handed back and possibly after a second
292
+ attempt already resolved, it advances nothing and emits nothing.
293
+ - **Placement semantics with a clamp** (`DEFAULT_PERMISSION_PLACEMENT`, `selectDuePermissionScreen`)
294
+ - `"start"`, `{ afterCard: n }` and `"beforeEnd"` (the default), resolved against the card about
295
+ to render, because the stream length is server-driven and varies per user. An `afterCard` the flow
296
+ never reaches degrades to `"beforeEnd"` instead of silently never showing, which is the failure
297
+ mode that raises no error anywhere and leaves the host looking at a funnel that reads zero.
298
+ - **`WIRE_PERMISSION_EVENTS`** plus `permissionEventName`, `permissionEventProps` and
299
+ `normalizePermissionStatus` (`permissions/permissionEvents.ts`) - the canonical funnel names
300
+ (`wire_permission_screen_shown`, `wire_permission_primer_accepted`, `wire_permission_granted`,
301
+ `wire_permission_denied`, `wire_permission_skipped`, `wire_permission_settings_opened`), mirroring
302
+ `WIRE_PURCHASE_EVENTS`. They land as `app_event` `question_key` values on the same `/v1/events`
303
+ path, carrying the same device snapshot and `user_context`, so the `device_key` join that makes
304
+ every other number real covers these too. Each moment is also surfaced on `onEvent` as
305
+ `{ type: "permission", ... }`.
306
+ - **`resolvePermissionCopy`, `NOTIFICATIONS_PERMISSION_COPY`, `GENERIC_PERMISSION_COPY`,
307
+ `DEFAULT_PERMISSION_COPY`** - the shipped English plus per-line overrides. An override that is
308
+ `undefined` or empty is IGNORED rather than allowed to blank a button, because hosts build this
309
+ object from an i18n layer where a missing translation resolves to `undefined`.
310
+ - **Once per session, kept true across an app KILL** (`permissionStorageKey`,
311
+ `loadSettledPermissions`, `saveSettledPermissions`, `readSettledPermissions`,
312
+ `clearSettledPermissions`). The kit resumes a killed onboarding into the SAME backend session, so
313
+ without a persisted record the resumed mount would re-show a screen the user already answered,
314
+ which on the ask path means a second attempt at the one prompt iOS grants. The record is scoped to
315
+ the session id, one entry, so a genuinely new onboarding still starts clean with nothing having to
316
+ expire it. Same host-injected `storage`, same read ceiling, same best-effort discipline: a
317
+ rejecting adapter costs a re-ask on resume, never a broken or gated flow.
318
+ - **`PermissionCard` and `PERMISSION_CARD_NAME`** - the screen registered as an SDK component, so a
319
+ later server-emitted placement (AI-chosen timing) adopts this exact screen with no rewrite.
320
+ Deliberately NOT part of `onboardingComponents`: that array is what the device advertises as
321
+ renderable, and a backend told it may emit a permission screen could emit one into a host that
322
+ injected no `request`.
323
+ - **A `notifications` entry in `defaultIllustrations`** so a notification screen is never a blank
324
+ box with no host wiring. Register your own under the same name to override it.
325
+
326
+ ### Changed
327
+
328
+ - **`OnboardingEvent` gains a `permission` variant** and `toAnalyticsEvent` maps it to its own
329
+ canonical `wire_permission_*` name rather than folding it into an onboarding one. Additive: a host
330
+ that ignores the new variant is unaffected, and every existing variant maps exactly as before.
331
+ - **A permission screen is NOT a question, structurally.** It sends nothing to the backend, appends
332
+ nothing to the thread, mints no `key` and no `slot_id`, and never touches the progress step, so
333
+ `deriveAnswers`, `readProgress` and the completion semantics cannot tell it happened. Completion
334
+ never blocks on a grant: grant, deny, skip and blocked all continue the flow, and a host callback
335
+ that throws (a native module blowing up, a failed scheduling call) is caught rather than allowed
336
+ to strand the user on the screen.
337
+ - **Notification SCHEDULING is deliberately out of scope.** `onResult` is the seam: schedule your
338
+ first local reminder there, with your own call, the moment a grant lands.
339
+
6
340
  ## [0.13.0] — 2026-07-27
7
341
 
8
342
  The identity-provenance release. Every id-layer defect fixed here is one omission wearing five faces:
@@ -35,8 +369,11 @@ direction: the kit now declines and says so, where it used to fabricate and retu
35
369
  `props.progress` by `readProgress` and preferred over `key` by `deriveAnswers`. `key` is authored from
36
370
  the question's prompt text (tenant flows slugify it and cut at 32 chars), so re-wording a question mints
37
371
  a NEW key and a host reading `answers.interests` silently starts reading `undefined`.
38
- ⚠️ **INERT UNTIL THE SERVER EMITS IT.** No deployed server sends `slot_id` today. Against a server that
39
- never sends it, every path degrades to byte-identical 0.12.2 behaviour. Every fallback is decided
372
+ ⚠️ ~~**INERT UNTIL THE SERVER EMITS IT.** No deployed server sends `slot_id` today.~~
373
+ **CORRECTED 2026-07-28: this is LIVE.** The deployed server sends `slot_id` on
374
+ `progress` for every AI-generated question, as `adaptive_<n>`, and for a configured question
375
+ whenever the tenant set one. Where no slot is configured the field is absent and
376
+ every path degrades to byte-identical 0.12.2 behaviour. Every fallback is decided
40
377
  PER CARD, never cached as a per-session "this backend supports slots" verdict, so a thread that mixes
41
378
  slotted and unslotted cards — the real shape during a rollout — keys each card correctly.
42
379
  - **`allowAppSessionFallback` on `identifyOnboarding`** — the opt-in switch for the tier-3 behaviour
@@ -280,7 +617,7 @@ or a client-side default.
280
617
 
281
618
  ## [0.12.0] — 2026-07-27
282
619
 
283
- The release Myelino 2.2.0 pins. Ships the RevenueCat path (#52), the identity/counting audit,
620
+ The release the first production consumer pins. Ships the RevenueCat path (#52), the identity/counting audit,
284
621
  the session-id fallback contract, and the transport/config fixes behind a silently dead lifecycle
285
622
  stream (a consumer's `first_open` read 6 all-time while the emitting code was deployed).
286
623
 
@@ -386,7 +723,7 @@ Architecture-audit fixes (#50/#51). One BEHAVIOR CHANGE, called out below.
386
723
  store-declaration implications). The blanket "No PII / nothing identifies a device" claims are
387
724
  scoped to the onboarding `userContext` prop + the `collectDeviceContext()` snapshot. `llms.txt`
388
725
  now lists the `/analytics`, `/reviews`, `/questionnaire` subpath surfaces.
389
- - Added the `session/sessionIdentity.integration.test.ts` dist-canary (PR #47/#48).
726
+ - Added the `session/sessionIdentity.integration.test.ts` dist-canary.
390
727
 
391
728
  ## [0.10.0] - 2026-07-20
392
729
 
package/README.md CHANGED
@@ -235,8 +235,11 @@ import { WireOnboarding } from "@wireai/activation";
235
235
  | `theme` | `Partial<OnboardingTheme>` | Brand colors/fonts/radius/spacing, deep-merged over a neutral default. |
236
236
  | `illustrations` | `Record<string, ReactNode>` | App artwork for `InterstitialCard`, keyed by name. |
237
237
  | `validators` | `Record<string, StepValidator>` | Per-step, keyed by base-question key (e.g. `username`). Blocks advance + shows an inline error. When the backend sends a `progress.slot_id` for a screen, the kit looks up that slot FIRST and falls back to the question key, so a validator map written against today's keys keeps working. |
238
+ | `permissionScreens` | `PermissionScreenConfig[]` | Priming screens injected mid-flow, starting with notifications. The screen explains why, and the OS dialog opens **only** on the primary tap, never on mount. Zero new dependencies: you inject `request`. See [Permission screens](#permission-screens-the-priming-pattern). |
238
239
  | `onSkip` | `() => void` | User skipped. |
239
- | `onError` | `(err) => void` | Backend error/timeout host owns recovery (e.g. route to a static flow). Without it, the kit shows an inline retry. |
240
+ | `onError` | `(err) => void` | Backend error or first-card timeout, fired **after retries are exhausted**, not on the first failure. When `fallbackFlow` is not supplied, the host owns recovery (e.g. route to a static flow). With neither, the kit shows an inline retry. `fallbackFlow` takes precedence over this. |
241
+ | `fallbackFlow` | `ReactNode` | Your predefined STATIC onboarding, rendered in place once the AI flow fails and retries are exhausted. **Takes precedence over `onError`.** This is the "it can never break your onboarding" guarantee: pass the same flow you shipped before adding Wire AI and the user always keeps moving. |
242
+ | `maxRetries` | `number` | Consecutive failures to auto-retry before degrading to `fallbackFlow`/`onError`. Default `1` (one silent retry, then degrade). `0` degrades on the first failure. Budget it deliberately: each retry restarts the flow, so with `startTimeoutMs` at its `15000` default the default `maxRetries` puts 30s of loader in front of a user before they see the fallback. A retry also sends a skip sentinel, so the user loses one question and a retry is never recorded as their first answer. |
240
243
  | `onEvent` | `(e: OnboardingEvent) => void` | `started` / `turn` / `error` — recover per-turn analytics since the kit owns the loop. |
241
244
  | `copy` | `Partial<OnboardingCopy>` | Localize the kit's built-in English loader/completion strings. |
242
245
  | `approxScreens` | `number` | Approx total screens — paces the bar as `step/total` (capped, never shown). A backend `progress.total` (the screen budget, sent every turn incl. init) wins, so usually unnecessary. |
@@ -608,6 +611,87 @@ icons={{
608
611
  Icons are decorative: the label carries the meaning, so an icon stays hidden from screen readers
609
612
  and never becomes an option's accessible name.
610
613
 
614
+ ## Permission screens (the priming pattern)
615
+
616
+ Onboarding is where apps ask for notifications, and it is where most of them lose the ask. iOS gives an app **one** native notification prompt for its entire lifetime. Fire it from a mount effect on screen one and you have spent it on a user who has been told nothing, and the only route back is a trip through the Settings app that almost nobody makes.
617
+
618
+ So the kit ships the priming pattern. You inject a screen into the flow, it explains why in your words, and the OS dialog opens **only** when the user taps the primary button. "Maybe later" advances the flow with the prompt still unspent, so you can ask again in a better moment.
619
+
620
+ ```tsx
621
+ import * as Notifications from "expo-notifications";
622
+ import { WireOnboarding } from "@wireai/activation";
623
+
624
+ <WireOnboarding
625
+ config={config}
626
+ onComplete={persist}
627
+ permissionScreens={[
628
+ {
629
+ permission: "notifications",
630
+ placement: "beforeEnd",
631
+ request: async () => {
632
+ const { status, canAskAgain } = await Notifications.requestPermissionsAsync();
633
+ return status === "granted" ? "granted" : canAskAgain ? "denied" : "blocked";
634
+ },
635
+ getStatus: async () => {
636
+ const { status, canAskAgain } = await Notifications.getPermissionsAsync();
637
+ return status === "granted" ? "granted" : canAskAgain ? "denied" : "blocked";
638
+ },
639
+ onResult: (_permission, outcome) => {
640
+ if (outcome === "granted") scheduleFirstReminder();
641
+ },
642
+ },
643
+ ]}
644
+ />
645
+ ```
646
+
647
+ That is the whole integration, and **the kit adds no dependency for it**. It imports no `expo-notifications`, no `react-native-permissions`, nothing native, the same way the RevenueCat bridge takes real RevenueCat objects without depending on `react-native-purchases`. You own the native call; the kit owns the screen, the timing, and the funnel.
648
+
649
+ ### Where the screen lands
650
+
651
+ The stream is server-driven, so its length changes per user. A placement is therefore resolved against the card about to render, not against a fixed index:
652
+
653
+ | `placement` | Where it shows |
654
+ |---|---|
655
+ | `"beforeEnd"` (default) | Right before the terminal recap, once the user has invested in the flow. |
656
+ | `"start"` | Before the first question. |
657
+ | `{ afterCard: 2 }` | After two questions, so between card 2 and card 3. |
658
+
659
+ An `afterCard` the flow never reaches **clamps to `"beforeEnd"`** instead of silently never showing. That matters more than it sounds: a screen that never appears raises no error anywhere, and the only evidence is a permission funnel that reads zero forever.
660
+
661
+ ### What it guarantees
662
+
663
+ - **The dialog is only ever reached from the primary tap.** No mount effect, no timer, no auto-fire. `getStatus` is a read and never prompts; it only decides which primary the screen offers.
664
+ - **Already blocked?** The primary becomes "Open settings", because an `ask` there would open nothing at all.
665
+ - **Once per session, and it survives an app kill.** With `storage`, a resumed session does not re-ask (the record is keyed to the session id, so a genuinely new onboarding still starts clean).
666
+ - **Completion never blocks on a grant.** Grant, deny, skip and blocked all continue the flow.
667
+ - **It is not a question.** Nothing is sent to the backend, nothing enters the thread, no `key` or `slot_id` is minted, and `onComplete`'s `answers` are identical to the same flow with no screen configured.
668
+
669
+ ### Copy and artwork
670
+
671
+ The kit ships English good enough to ship. Override any single line and the rest of the default stays:
672
+
673
+ ```tsx
674
+ copy: { title: "Never miss a session", primaryLabel: "Turn on reminders" }
675
+ ```
676
+
677
+ Artwork comes from the same `illustrations` registry the cards use, keyed by the permission name, so `illustrations={{ notifications: <MyBell /> }}` is enough. The kit's dependency-free default is used when you register nothing.
678
+
679
+ ### The numbers
680
+
681
+ Each screen reports through the same `/v1/events` path and the same `device_key` join as the rest of the funnel, and every moment is also surfaced on `onEvent` as `{ type: "permission", ... }`:
682
+
683
+ | Event | Fires when |
684
+ |---|---|
685
+ | `wire_permission_screen_shown` | The primer became visible. The denominator. |
686
+ | `wire_permission_primer_accepted` | The primary was tapped, so the OS dialog is about to open. |
687
+ | `wire_permission_granted` / `wire_permission_denied` | What the OS answered (a permanently blocked answer reports as denied with `status: "blocked"`). |
688
+ | `wire_permission_skipped` | "Maybe later". The one native prompt was not spent. |
689
+ | `wire_permission_settings_opened` | A blocked user was sent to Settings. |
690
+
691
+ The gap between `wire_permission_screen_shown` and `wire_permission_primer_accepted` is the number worth watching: it tells you whether your rationale copy works, and it costs nothing to be wrong about, because a user who does not tap has not burned anything.
692
+
693
+ **Scheduling notifications is deliberately out of scope.** `onResult` is the seam: schedule your first local reminder there, with your own `expo-notifications` call, the moment a grant lands.
694
+
611
695
  ## Backend coupling
612
696
 
613
697
  The backend (`wire-rn/examples/dynamic-onboarding/server`) is the source of truth
@@ -832,6 +916,18 @@ the "personalize from context" thesis. The once-gate
832
916
  It reads the active `OnboardingThemeProvider` theme; pass `theme` to override just
833
917
  the showcase, or `storage` / `isTesting` to gate independently of the provider.
834
918
 
919
+ Until 0.13.3 that sentence only held on a phone. `OnboardingThemeProvider` ships from
920
+ the root barrel, every subpath builds as its own bundle, and each of those bundles
921
+ carried a private copy of the theme context. Metro hid it: its `react-native` export
922
+ condition collapses all five subpaths back to one source file, so on-device the copies
923
+ were the same object by accident. Everywhere else (plain RN CLI,
924
+ `unstable_enablePackageExports: false`, node, jest, SSR) the provider wrote one context
925
+ while the gate read another, and `useOnboardingTheme` quietly falls back to the neutral
926
+ default theme instead of crashing. No error, no warning, wrong brand colors. The context
927
+ now lives in one `Symbol.for` slot on `globalThis`, next to the feature flags and the
928
+ coachmark runtime that already sat there, so a single provider themes every surface on
929
+ every resolution path.
930
+
835
931
  **v2 server seam (future, not built).** The onboarding server's final turn will
836
932
  later return an ordered `showcase: string[]` slide-key list chosen server-side from
837
933
  the same answers; the host passes it straight in as the `selection` argument.
@@ -1211,7 +1307,7 @@ Least privilege: for the read-only loop, hand the agent just `WIREAI_SERVER_URL`
1211
1307
  A pure-Markdown Claude skill for *using* Wire AI easily: install the kit + wire the
1212
1308
  `WireOnboarding` provider, read your insights (via the MCP or `/v1/insights.md`), and run
1213
1309
  the improve loop (read insights, then adjust the app description / first question /
1214
- generation context). It also carries the `BOILERPLATE20` credit note. For the full
1310
+ generation context). For the full
1215
1311
  scaffold-the-screen integration job, the [`wire-rn-integration`](./INTEGRATION_PROMPT.md)
1216
1312
  skill drives it end to end.
1217
1313
 
@@ -1,7 +1,7 @@
1
- export { R as ReportAppEventOptions, r as reportAppEvent } from '../transport-CF_eHwzC.mjs';
2
- import { W as WireUserContext, E as EventQueueOptions } from '../currentSession-_GynvhzT.mjs';
3
- export { A as AUTO_DEVICE_ID_PREFIX, a as AnalyticsEvent, C as ClearUserContextOptions, b as ClientEvent, c as ClientEventTarget, 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-_GynvhzT.mjs';
4
- import '../types-CNUqMK0D.mjs';
1
+ export { R as ReportAppEventOptions, r as reportAppEvent } from '../transport-CefdERPs.mjs';
2
+ import { W as WireUserContext, E as EventQueueOptions } from '../currentSession-BoWtr3Jp.mjs';
3
+ export { A as AUTO_DEVICE_ID_PREFIX, a as AnalyticsEvent, C as ClearUserContextOptions, b as ClientEvent, c as ClientEventTarget, 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-BoWtr3Jp.mjs';
4
+ import '../types-l2tfg23P.mjs';
5
5
  import '../types-BKfpdZzX.mjs';
6
6
  import '../types-BcmagF6K.mjs';
7
7
  import '../types-CKFhyrMu.mjs';
@@ -1,7 +1,7 @@
1
- export { R as ReportAppEventOptions, r as reportAppEvent } from '../transport-DsRe4epC.js';
2
- import { W as WireUserContext, E as EventQueueOptions } from '../currentSession-D7zabMXK.js';
3
- export { A as AUTO_DEVICE_ID_PREFIX, a as AnalyticsEvent, C as ClearUserContextOptions, b as ClientEvent, c as ClientEventTarget, 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-D7zabMXK.js';
4
- import '../types-Buj9Lw9t.js';
1
+ export { R as ReportAppEventOptions, r as reportAppEvent } from '../transport-DFuPyCoJ.js';
2
+ import { W as WireUserContext, E as EventQueueOptions } from '../currentSession-CCOMlaQ2.js';
3
+ export { A as AUTO_DEVICE_ID_PREFIX, a as AnalyticsEvent, C as ClearUserContextOptions, b as ClientEvent, c as ClientEventTarget, 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-CCOMlaQ2.js';
4
+ import '../types-UVYt9BJe.js';
5
5
  import '../types-BKfpdZzX.js';
6
6
  import '../types-BcmagF6K.js';
7
7
  import '../types-CKFhyrMu.js';