@wireai/activation 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/AGENTS.md +29 -5
  2. package/CHANGELOG.md +163 -6
  3. package/README.md +105 -10
  4. package/dist/analytics/index.d.mts +6 -6
  5. package/dist/analytics/index.d.ts +6 -6
  6. package/dist/analytics/index.js +447 -49
  7. package/dist/analytics/index.js.map +1 -1
  8. package/dist/analytics/index.mjs +447 -49
  9. package/dist/analytics/index.mjs.map +1 -1
  10. package/dist/coachmarks/index.d.mts +2 -2
  11. package/dist/coachmarks/index.d.ts +2 -2
  12. package/dist/coachmarks/index.js.map +1 -1
  13. package/dist/coachmarks/index.mjs.map +1 -1
  14. package/dist/{currentSession-f7LWcdWG.d.ts → currentSession-61dcm3V-.d.ts} +10 -2
  15. package/dist/{currentSession-d9CrBxwe.d.mts → currentSession-Bs2JfTJ8.d.mts} +10 -2
  16. package/dist/{decision-BzbiKwk3.d.mts → decision-Bl_M2y3r.d.mts} +1 -1
  17. package/dist/{decision-plDEOCkt.d.ts → decision-Cau5KmP6.d.ts} +1 -1
  18. package/dist/index.d.mts +558 -15
  19. package/dist/index.d.ts +558 -15
  20. package/dist/index.js +1144 -375
  21. package/dist/index.js.map +1 -1
  22. package/dist/index.mjs +969 -207
  23. package/dist/index.mjs.map +1 -1
  24. package/dist/questionnaire/index.d.mts +69 -23
  25. package/dist/questionnaire/index.d.ts +69 -23
  26. package/dist/questionnaire/index.js +643 -172
  27. package/dist/questionnaire/index.js.map +1 -1
  28. package/dist/questionnaire/index.mjs +608 -137
  29. package/dist/questionnaire/index.mjs.map +1 -1
  30. package/dist/reviews/index.d.mts +24 -12
  31. package/dist/reviews/index.d.ts +24 -12
  32. package/dist/reviews/index.js +507 -173
  33. package/dist/reviews/index.js.map +1 -1
  34. package/dist/reviews/index.mjs +462 -129
  35. package/dist/reviews/index.mjs.map +1 -1
  36. package/dist/showcase/index.d.mts +2 -2
  37. package/dist/showcase/index.d.ts +2 -2
  38. package/dist/showcase/index.js.map +1 -1
  39. package/dist/showcase/index.mjs.map +1 -1
  40. package/dist/transport-DzU-TqZz.d.ts +86 -0
  41. package/dist/transport-f5VVB5hH.d.mts +86 -0
  42. package/dist/{types-GL_hQ0TN.d.mts → types-BcmagF6K.d.mts} +1 -1
  43. package/dist/{types-GL_hQ0TN.d.ts → types-BcmagF6K.d.ts} +1 -1
  44. package/dist/{types-BhpXJGlg.d.ts → types-Buj9Lw9t.d.ts} +15 -3
  45. package/dist/{types-CMuOexw0.d.mts → types-CKFhyrMu.d.mts} +1 -1
  46. package/dist/{types-CMuOexw0.d.ts → types-CKFhyrMu.d.ts} +1 -1
  47. package/dist/{types-A6pTxIZV.d.mts → types-CNUqMK0D.d.mts} +15 -3
  48. package/metro/index.d.ts +18 -1
  49. package/metro/index.js +80 -0
  50. package/package.json +9 -1
  51. package/src/WireOnboarding.tsx +4 -0
  52. package/src/analytics/currentSession.ts +1 -1
  53. package/src/cards/CardGridSelectCard.tsx +253 -0
  54. package/src/cards/ChipSelectCard.tsx +1 -1
  55. package/src/cards/SelectionCard.tsx +15 -19
  56. package/src/cards/index.ts +13 -1
  57. package/src/cards/optionSchema.ts +65 -0
  58. package/src/coachmarks/types.ts +1 -1
  59. package/src/components/CardHandoff.tsx +39 -7
  60. package/src/components/DoneCheck.tsx +94 -0
  61. package/src/components/ModalPaneHost.tsx +141 -0
  62. package/src/components/StepDots.tsx +112 -0
  63. package/src/device/appVersion.ts +29 -8
  64. package/src/device/deviceModel.ts +21 -8
  65. package/src/features/cache.ts +1 -1
  66. package/src/haptics/expo-haptics.d.ts +31 -0
  67. package/src/haptics/haptics.ts +58 -0
  68. package/src/icons/IconRegistry.tsx +53 -0
  69. package/src/icons/WireIcon.tsx +85 -0
  70. package/src/icons/expoIcons.ts +165 -0
  71. package/src/icons/index.ts +18 -0
  72. package/src/icons/vocabulary.ts +154 -0
  73. package/src/index.ts +20 -0
  74. package/src/questionnaire/QuestionnaireGate.tsx +315 -164
  75. package/src/questionnaire/decision.ts +4 -2
  76. package/src/questionnaire/index.ts +3 -2
  77. package/src/questionnaire/steps.ts +261 -0
  78. package/src/questionnaire/types.ts +33 -12
  79. package/src/reviews/ReviewGate.tsx +250 -152
  80. package/src/reviews/index.ts +7 -3
  81. package/src/reviews/transport.ts +77 -3
  82. package/src/reviews/types.ts +13 -0
  83. package/src/session/persistedSession.ts +1 -1
  84. package/src/session-analytics/lifecycle.ts +1 -1
  85. package/src/session-analytics/useSessionStart.ts +2 -2
  86. package/src/showcase/FeatureShowcase.tsx +1 -1
  87. package/src/showcase/showcaseColors.ts +1 -1
  88. package/src/theme/mergeThemeOver.ts +27 -0
  89. package/src/types.ts +8 -0
  90. package/dist/transport-BeO_Brcu.d.mts +0 -40
  91. package/dist/transport-DLpd1v5_.d.ts +0 -40
package/AGENTS.md CHANGED
@@ -12,10 +12,17 @@ A **drop-in mobile-app onboarding kit** over the OSS `wireai-rn` SDK. One compon
12
12
  themed native cards (chosen by an LLM, one per turn over A2A), a progress bar, a mid-flow
13
13
  value screen, per-step validation, and a completion recap, ending with the user's
14
14
  structured answers for the app to persist. The host supplies a tenant config, a theme,
15
- and brand artwork; the kit owns the flow. **Nearly pure JS (no reanimated, no SVG/icon
16
- deps).** Peers: `react`, `react-native`, `wireai-rn`, `zod`, and `react-native-safe-area-context`
17
- (>=4.0.0, for safe-area insets; the host must render a `SafeAreaProvider` ancestor, which
18
- every Expo/RN app already ships).
15
+ and brand artwork; the kit owns the flow. **Nearly pure JS: no REQUIRED reanimated, SVG, or
16
+ icon dependency.** Required peers: `react`, `react-native`, `wireai-rn`, `zod`, and
17
+ `react-native-safe-area-context` (>=4.0.0, for safe-area insets; the host must render a
18
+ `SafeAreaProvider` ancestor, which every Expo/RN app already ships).
19
+
20
+ **Optional peers** are never installed for you and never trigger a native rebuild. Each is
21
+ lazily resolved and degrades to a defined no-op when absent: `@expo/vector-icons` (>=14, real
22
+ icons on the choice cards; absent → those cards simply render without icons),
23
+ `expo-store-review`, `expo-haptics`, `expo-blur`, `react-native-reanimated`,
24
+ `@blazejkustra/react-native-onboarding`. You can also skip `@expo/vector-icons` entirely and
25
+ pass your own brand nodes via the `icons` prop.
19
26
 
20
27
  Use it whenever someone wants "AI / dynamic / personalized onboarding" in a React Native / Expo app.
21
28
 
@@ -27,7 +34,7 @@ subpaths are optional secondary feature modules; import one only if you use it.
27
34
  - `@wireai/activation` (root): `WireOnboarding`, the helpers/types below, and the app-event/analytics reporters.
28
35
  - `@wireai/activation/coachmarks`: in-app coachmark tour (`CoachmarkProvider`, `useCoachmarkTour`).
29
36
  - `@wireai/activation/showcase`: post-onboarding feature showcase (`FeatureShowcase`).
30
- - `@wireai/activation/reviews`: store-review gating (`ReviewGate`, `ReviewModal`).
37
+ - `@wireai/activation/reviews`: store-review gating (`ReviewGate`, `ReviewModal`, `useReviewGate`, `fetchReviewDecision`).
31
38
  - `@wireai/activation/questionnaire`: in-app questionnaire gating (`QuestionnaireGate`).
32
39
  - `@wireai/activation/metro`: the `withWireOnboarding` Metro helper (see step 3).
33
40
 
@@ -54,6 +61,7 @@ There is no `analytics` subpath: the app-event / analytics API is exported from
54
61
  | `theme` | `Partial<OnboardingTheme>` | no | Colors + font family names + radius/spacing, deep-merged over a neutral default. For dark/light, pass a different theme per app theme-state. |
55
62
  | `components` | `WireAIComponent[]` | no | Override registered cards (default `onboardingComponents`). |
56
63
  | `illustrations` | `Record<string, ReactNode>` | no | Host artwork for `InterstitialCard`, keyed by name. `{ ...defaultIllustrations, ...myArt }`. |
64
+ | `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. |
57
65
  | `validators` | `Record<string, StepValidator>` | no | Per base-question key (e.g. `username`). Blocks advance + inline error. |
58
66
  | `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. |
59
67
  | `onError` | `(err) => void` | no | Backend error/timeout after retries. Host recovery (e.g. static onboarding). `fallbackFlow` takes precedence over it. |
@@ -100,6 +108,22 @@ The app-event / analytics API is exported from the ROOT (`@wireai/activation`),
100
108
  7. **Render + continuation**: drop `<WireOnboarding>` into the signup flow; `onComplete` → persist + navigate, `onSkip` → navigate.
101
109
  8. **Gate**: wrap behind a flag (env for dev, remote config for prod) **AND** `config != null`; fall through to existing onboarding when off/unconfigured. Decide who sees it (e.g. new signups only) in host nav logic.
102
110
 
111
+ ## Review firing (do not hand-roll the decision fetch)
112
+
113
+ If you gate the review prompt on the server, call **`fetchReviewDecision(target, { deviceKey })`**
114
+ from `@wireai/activation/reviews` and pass the result to `useReviewGate({ decision })`. Never
115
+ write your own fetch.
116
+
117
+ - It returns the FULL `{ fire, reason, arm }` on 2xx, and `null` ONLY when the server is
118
+ unreachable / non-2xx / sends bad JSON. It never throws.
119
+ - **Never collapse `{fire:false}` into null/undefined.** The seam is `decision ?? local`, so a
120
+ collapsed `false` reads as "no server opinion" and your LOCAL timer fires. A host did exactly
121
+ this and prompted a real user for a review 3 minutes into their first session (1 star).
122
+ - `sessionId` is OPTIONAL: the gate usually lives on the home feed, where there is no onboarding
123
+ session. `deviceKey` is the identity that matters. Pass `sessionId` only when you have one.
124
+ - Running a firing experiment? Echo the returned `arm` back as `meta.firing_arm` on the review
125
+ submission so per-arm attribution survives a reweighting.
126
+
103
127
  ## Gotchas (do not miss)
104
128
 
105
129
  - **EAS / cloud builds:** install via **git URL or registry**, never a local `file:` path *outside the app repo* (EAS won't resolve it).
package/CHANGELOG.md CHANGED
@@ -20,6 +20,163 @@ Historical entries below the rename keep the old package name on purpose.
20
20
 
21
21
  ## [Unreleased]
22
22
 
23
+ ### Added: `fetchReviewDecision` — the kit now owns the review firing decision (live user harm)
24
+
25
+ - **A real user was prompted for a review ~3 minutes into their FIRST session**, having seen
26
+ nothing, and left 1 star. It is still happening in prod; only an app release stops it.
27
+ - **Root cause was an asymmetry in this kit.** `src/questionnaire/transport.ts` has
28
+ `fetchQuestionnaireDecision`; `src/reviews/transport.ts` had **no decision fetch at all**. So
29
+ the host hand-rolled one, and its helper collapsed an explicit `{fire:false}` into `undefined`.
30
+ The seam is `decideReview = (local, decision) => decision ?? local`, so `undefined` means "the
31
+ server has no opinion, use local rules" and the local 30s timer fired. **The server could turn
32
+ review prompts ON but never OFF.**
33
+ - **The fix lives in the kit, not the host** (Malik: *"the idea is not to comment in the
34
+ organism, the idea is to change wire activation, to fix this errors"*). Hosts now keep a thin
35
+ call site. `decideReview` itself was already correct and is unchanged; the missing piece was a
36
+ faithful producer of `{fire:false}`.
37
+ - `fetchReviewDecision(target, { deviceKey, sessionId? })` returns the FULL `{fire, reason, arm}`
38
+ on 2xx, and `null` **only** on unreachable / non-2xx / bad JSON, so fail-open to local rules is
39
+ preserved exactly where it belongs. A body without a boolean `fire` is not a decision → null.
40
+ - **`sessionId` stays OPTIONAL, deliberately.** The gate lives on the home feed, where there is
41
+ no onboarding session; `deviceKey` is the real identity. Today's prod still requires
42
+ `session_id` on the decision route and answers **422** without it (the server relaxation is
43
+ unmerged), and a 422 is just a non-2xx → `null` → local rules. Tightening `sessionId` here
44
+ would break the very hosts this unblocks. Permissive wire, strict client.
45
+ - `arm` is carried through so a host can echo it back as `meta.firing_arm` (the server keeps an
46
+ echoed arm verbatim, else recomputes from the sticky hash).
47
+
48
+ ### Fixed: the 5-star review never reached the server (live data loss)
49
+
50
+ - **Every promoter was being dropped.** `ReviewGate`'s 5-star branch called
51
+ `requestStoreReview` and nothing else: `submitReview` was reachable ONLY from the feedback
52
+ phase, which only a 1-4 rating enters. So server-side `by_stars["5"]` and `store_routed` sat
53
+ at **0 forever** and `avg` averaged detractors only — the firing experiment could never
54
+ attribute a promoter to an arm. The four gate events go to the HOST's `onEvent`, never to
55
+ Wire, so nothing surfaced it. Tapping 5 now POSTs a text-less `stars=5` row.
56
+ - **`meta.store_route` records which route we took** (`native` / `store_url` / `none`) — the
57
+ return value of `requestStoreReview`, which was previously computed and thrown away.
58
+ **Honest ceiling:** `requestReview` is OS-quota'd and reports NOTHING about whether the user
59
+ actually left a review. This only ever means *we asked*. Never read it as a review count.
60
+ - No server change needed: the body is exactly the shape `CreateReviewRequest` already
61
+ documents as the store route, and the shape its `store_routed` aggregate already counts
62
+ (`stars == 5 and not feedback_text`).
63
+
64
+ ### Changed: a 1-4 rating must now leave feedback — and can no longer take its rating with it
65
+
66
+ - **The opinion box is MANDATORY** (Malik, 2026-07-16), mirroring the questionnaire's forced
67
+ last step. Send stays disabled until it holds non-whitespace text, and the feedback step's
68
+ Skip link is gone. The suggestion box stays optional. Enforced **client-side only**:
69
+ `feedback_text` stays optional on the wire, because every already-shipped client still posts
70
+ text-less rows and the transport is fire-and-forget — tightening the server would silently
71
+ 422 them into oblivion. Permissive wire, strict client.
72
+ - **Forcing the text does NOT cost us the rating.** Naively, blocking Send would lose the
73
+ ratings of exactly the detractors who bail rather than type. So a rating that is still
74
+ unposted when the gate goes away is now posted anyway, with whatever text was typed, flagged
75
+ `meta.abandoned`. The user is pushed to explain; the rating is never the price. We do not
76
+ trap the user in the modal — backdrop / Android back still work.
77
+ - **Exactly ONE row per gate, ever** (`postOnce`). We deliberately did NOT post at star-tap and
78
+ again on submit: the server mints its own id (`_new_id()`) and `CreateReviewRequest` has no id
79
+ field, so two posts = two rows = a double-counted `count` and a corrupted `avg` — the same
80
+ class of corruption this change exists to fix. Revisit only once the server accepts a
81
+ client-supplied idempotency id.
82
+
83
+ ### Added: an icon vocabulary the AI emits, and `CardGridSelectCard`
84
+
85
+ - **`icon` on choice options.** `SelectionCard` options may now carry an optional `icon`, so
86
+ "Where did you hear about us?" can put a real Instagram mark next to the Instagram row.
87
+ Bare-string options and bare `{value,label}` options are unchanged.
88
+ - **A named semantic vocabulary is the contract, not raw library glyph names.** The AI emits
89
+ `"instagram"` / `"friend"` / `"wedding"`; `src/icons/vocabulary.ts` maps that to a concrete
90
+ glyph. The model is never coupled to a library version. `WIRE_ICON_NAMES` is exported for
91
+ pasting into the server prompt.
92
+ - **`CardGridSelectCard`** — the 2-column grid of small icon cards Malik asked for (icon on
93
+ top, short label beneath, selection on the card). It is its OWN card, not a `display` mode on
94
+ `SelectionCard`, because a card's `description` IS the LLM's router and cannot honestly carry
95
+ three modes; `ChipSelectCard` sets the precedent that a presentation difference earns a name.
96
+ - **`@expo/vector-icons` is an OPTIONAL peer**, lazily required. A host without it installs,
97
+ bundles, and renders fine (icons simply absent) — **no native rebuild is forced on anyone**,
98
+ which is what the no-native-dependency policy actually forbids. Both live hosts already ship
99
+ `^15.0.3`. A new `icons` prop overrides any name with host artwork, so the library is never
100
+ the only path.
101
+ - **Every icon miss is a no-op.** Unknown / hallucinated / newer-than-this-client name, or a
102
+ missing peer → no icon, no crash, no tofu box, and no layout shift (a garbage icon renders a
103
+ tree identical to no icon — asserted in `test/canary/icons.test.tsx`).
104
+ - **Back-compat:** an old kit receiving `{value,label,icon}` STRIPS the unknown key (zod strips
105
+ by default and the SDK renderer mounts `parsed.data`), so it renders its plain row — the wire
106
+ stays additive. `CardGridSelectCard` is invisible to shipped clients until they re-pin, by
107
+ construction: they never advertise it in `supportedComponents`, so the server intersects it
108
+ away. ⚠️ **It cannot be emitted until the server adds it to `KNOWN_COMPONENTS` +
109
+ `COMPONENT_DOCS` + the tenant's `allowed_components`** — a two-sided change, still open.
110
+
111
+ ### Changed: the questionnaire is multi-step, the FORCED answer moved to the last step; both gates animate
112
+
113
+ - **The mandatory-opinion gate is GONE.** `QuestionnaireGate` was one screen with three
114
+ stacked boxes and a Send disabled until the "what do you think" box had text. It is now a
115
+ MULTI-STEP popup: ONE question per step, and the first two steps are skippable.
116
+ - **The LAST step is REQUIRED instead** (Malik, on device: "i want at least something
117
+ forced"). `suggestions` — "Any improvements or feedback?" — renders no Skip control and
118
+ keeps Send disabled until the box holds non-whitespace text. So the forced answer moved
119
+ from step 1 to step 3: the user picks WHICH thing they say, not WHETHER they say anything.
120
+ Requiredness is per-step data (`steps.ts` → `REQUIRED_STEPS`), enforced in `commitStep`,
121
+ which `skipStep` (defined as committing `""`) inherits for free.
122
+ - ⚠️ **This client now REQUIRES the server to accept an empty `answers.opinion`.** Step 1 is
123
+ skippable, so a skipped step 1 posts `opinion: ""`; the live endpoint validates `opinion`
124
+ with minLength=1 and rejects that body with a 422 — **silently**, because the transport is
125
+ fire-and-forget, taking the user's forced step-3 answer down with it. The client is
126
+ deliberately NOT worked around (no placeholder opinion, no field reordering): the server
127
+ schema is the fix.
128
+ - **The wire shape is UNCHANGED.** `buildQuestionnaireSubmission` still builds the same
129
+ `POST /v1/questionnaires/{id}/responses` body from the same three answers; a skipped step is
130
+ simply an empty answer.
131
+ - **The step logic is pure and tested.** `questionnaire/steps.ts` owns advance / skip / back /
132
+ the required gate as pure functions, covered by `questionnaire/steps.test.ts` under node:test.
133
+
134
+ ### Changed: questionnaire chrome — dots, a back icon, no "Not now", no repeated title
135
+
136
+ All four from Malik's on-device test, 2026-07-16. The **review** gate's chrome is untouched.
137
+
138
+ - **Step DOTS replace the "Question 2 of 3" counter.** New `components/StepDots` — a filled
139
+ `colors.primary` layer crossfades over a `colors.border` base at the design system's
140
+ `SELECT_FILL_MS`, opacity-only on the native driver, snapping under reduce motion. The row
141
+ is one `progressbar` node labelled "Step 2 of 3" with a numeric `accessibilityValue`, so
142
+ screen readers keep the count sighted users now read from the dots. (Distinct from
143
+ `StepProgress`, which serves the onboarding flow and deliberately shows no count.)
144
+ - **Back is a top-left icon**, not a text link in the button stack — a `‹` Text glyph, no
145
+ vector dependency (the `DoneCheck` ✓ / review-star idiom). It is **absent** on step 1
146
+ rather than present-and-dead, and keeps its "Back to the previous question" label.
147
+ - **"Not now" is gone.** Per-step "Skip step" is the only in-card opt-out.
148
+ **`questionnaire_dismissed` moved to the modal's dismiss path** rather than dying with the
149
+ link: dismissal is still reachable (backdrop tap, Android hardware back) and now fires the
150
+ event from there. This also FIXES a hole — those two paths never fired it before, so the
151
+ metric only ever counted users who found the link. Expect the number to legitimately RISE.
152
+ Submitting still does not fire it.
153
+ - **The repeated title is gone.** The gate drew `questionnaire.title` as a heading on EVERY
154
+ step, directly above that step's own question — two asking-texts stacked, the same string
155
+ three times, and mid-slide the outgoing and incoming panes each drew their own copy, so it
156
+ was briefly on screen twice. The step's question is the heading now and renders once.
157
+ `QuestionnaireDefinition.title` is **deprecated and no longer rendered**; it stays in the
158
+ type because tenants still send it and the server contract is unchanged.
159
+ - `components/ModalPaneHost` gained a `header` slot: chrome measured with the pane but
160
+ outside the `CardHandoff`, so it never slides, never doubles mid-handoff, and its removal
161
+ (form → thanks) rides the eased card resize instead of snapping.
162
+ - **Motion.** Both gates now transition between panes instead of swapping in place, via the new
163
+ shared `components/ModalPaneHost` (it delegates the slide to the existing `CardHandoff` and
164
+ eases the modal card's height). `CardHandoff` gained `direction` (mirrors the slide for Back)
165
+ and `layout` (`"auto"` for panes inside an auto-height card). The thank-you plays the new
166
+ shared `components/DoneCheck` before the existing 1500ms auto-close. Every new animation
167
+ honors reduce motion, and no new motion values were invented — all come from `motionSpec`.
168
+ - **Haptics (new OPTIONAL peer `expo-haptics`).** A star tap, a step change, and a submit now
169
+ fire a haptic through `haptics/haptics.ts`, loaded lazily via a guarded dynamic import —
170
+ the same contract `expo-store-review` uses. A host that has not installed it feels nothing
171
+ and nothing throws. Install with `npx expo install expo-haptics` to enable.
172
+ - **Copy.** The default `improve`/`suggestions` labels dropped their "(optional)" tags.
173
+ Skippability is carried by the CONTROLS (Skip present or absent, Send live or disabled),
174
+ never by label text — labels are tenant-configurable and would drift out of sync.
175
+ - **A11y.** The in-card questionnaire controls are real labelled buttons instead of bare
176
+ text, the dots carry a `progressbar` role + "Step X of N", and the back icon is a labelled
177
+ button. The 5-star → native store review route (`routeRating`) is untouched.
178
+ - `theme/mergeThemeOver` extracts the helper both gates had copy-pasted.
179
+
23
180
  ### Added: "device" is now fully automatic — auto-minted, persisted per-install `device_key`
24
181
 
25
182
  - The analytics façade (`createAnalytics`) now auto-mints a stable, NON-PII per-install `device_key`
@@ -87,7 +244,7 @@ Historical entries below the rename keep the old package name on purpose.
87
244
  - The façade's `identify` (and `track`/`screen` app-events) now source their `session_id` from
88
245
  `config.sessionId ?? getCurrentSessionId() ?? <stable per-instance id>` — so identity binds to the
89
246
  session the server ALREADY saw (via `app.session_started`) instead of minting a fresh id the server
90
- back-fills into a synthetic session. This is what inflated the Morrow/Myelino session counts. An
247
+ back-fills into a synthetic session. This is what inflated the Morrow/a host app session counts. An
91
248
  explicit `config.sessionId` still freezes the id (opt-out). This is also the kit half of the #205
92
249
  "align all app-events to the per-open session" follow-up. **No server change required** for the
93
250
  normal flow (session-start fires on mount, identify at login); the only residual is an `identify`
@@ -130,7 +287,7 @@ Historical entries below the rename keep the old package name on purpose.
130
287
  user once `userId` arrives (here or via `identifyOnboarding`).
131
288
  - Convenience hook `useSessionStart(config, {userId, sessionCount, deviceKey})` fires on mount and
132
289
  on foreground-after-30-min-background (a real new open, not a quick app-switch), collecting the
133
- device snapshot for you. Optional: a host with its own session counter (Myelino/Morrow) can call
290
+ device snapshot for you. Optional: a host with its own session counter (a host app/Morrow) can call
134
291
  the plain `reportSessionStart` directly instead. Both paths are first-class.
135
292
  - Exports added to the core barrel: `reportSessionStart`, `useSessionStart`, `SESSION_STARTED_EVENT`,
136
293
  `BACKGROUND_SESSION_MS`, and the `ReportSessionStartOptions` / `SessionStartConfig` /
@@ -362,7 +519,7 @@ Historical entries below the rename keep the old package name on purpose.
362
519
  (`OnboardingIntroPanel.tsx`), and the full-bleed image hero panel
363
520
  (`OnboardingImageContainer.tsx`'s `colorBg`). So the button fix also painted the
364
521
  whole hero panel in the accent, which dominated the first slide and "hid" the
365
- app (reported in Myelino on 0.3.1). The package exposes no per-surface color key
522
+ app (reported in a host app on 0.3.1). The package exposes no per-surface color key
366
523
  to split them, but it does accept a `background?: () => ReactNode` render prop
367
524
  that REPLACES that panel fill outright. `FeatureShowcase` now passes one: a calm
368
525
  theme `surface` base with a faint accent tint (~10% opacity) on top. Result: the
@@ -400,7 +557,7 @@ Historical entries below the rename keep the old package name on purpose.
400
557
  ### Changed: the in-app review prompt is now a centered popup by default
401
558
  - **`ReviewGate` renders as a centered modal** (dimmed backdrop, card in the middle of the
402
559
  screen, spring/fade entrance) instead of the bare host-positioned card, which rendered
403
- bottom-anchored and half-hidden on device (reported in Myelino on 0.3.0). The entrance
560
+ bottom-anchored and half-hidden on device (reported in a host app on 0.3.0). The entrance
404
561
  uses RN's built-in `Animated` on the native driver (the same idiom as `CompletionView` /
405
562
  `AnimatedCard`), so the reviews module stays dependency-free (no reanimated). The card
406
563
  surface, radius, spacing, and text all come from the active theme, so it is theme-aware in
@@ -410,7 +567,7 @@ Historical entries below the rename keep the old package name on purpose.
410
567
  (transparent RN `Modal` + centered container + backdrop-tap dismiss + content
411
568
  stop-propagation + springy zoom-in), reimplemented on RN core primitives.
412
569
  - **Additive API, no app change needed.** New optional `presentation?: "modal" | "inline"`
413
- prop on `ReviewGate`, defaulting to `"modal"`. Apps like Myelino's `HomeReviewGate` pick up
570
+ prop on `ReviewGate`, defaulting to `"modal"`. Apps like a host app's `HomeReviewGate` pick up
414
571
  the centered popup automatically when they bump to 0.3.1, with zero code changes. Pass
415
572
  `presentation="inline"` to keep the legacy bare card. New exports: `ReviewModal`,
416
573
  `ReviewModalHandle`, `ReviewModalProps`, `ReviewPresentation`, `resolvePresentation`,
@@ -426,7 +583,7 @@ Historical entries below the rename keep the old package name on purpose.
426
583
  `background.primary` — its BRAND/action color, despite the "primary" name. The kit
427
584
  was mapping `background.primary` to the theme's `background`, so every consuming app
428
585
  rendered the "Next/Get Started" CTA in its own screen background color: white-on-white
429
- in light mode, dark-on-dark in dark mode (reported in Morrow Self + Myelino, both on
586
+ in light mode, dark-on-dark in dark mode (reported in Morrow Self + a host app, both on
430
587
  0.3.0). The CTA now maps to the theme's `primary` (or the `accentColor` override), and
431
588
  the screen/panel background maps to `background`. No app change is required to pick up
432
589
  the fix — apps already pass their theme/`accentColor` correctly.
package/README.md CHANGED
@@ -28,10 +28,16 @@ Sponsored by [AI Mobile Launcher](https://aimobilelauncher.com?utm_source=github
28
28
  | **Backend** | `wire-rn/examples/dynamic-onboarding/server` | Multi-tenant LangGraph engine. Per-app `product_context` + `allowed_components`; validates every emission against `KNOWN_COMPONENTS`. Live on fly dev. |
29
29
  | **Consumers** | Production B2C mobile apps | Each hosts `<WireOnboarding>` with its own theme + illustration registry. |
30
30
 
31
- The kit's peers are `react`, `react-native`, `wireai-rn`, and
32
- `react-native-safe-area-context` (>=4.0.0, for safe-area insets the host must render a
33
- `SafeAreaProvider` ancestor, standard in every Expo/RN app) **no reanimated, no SVG, no
34
- icon font.** Brand artwork is supplied by the host (see [Illustrations](#illustrations)).
31
+ The kit's required peers are `react`, `react-native`, `wireai-rn`, `zod`, and
32
+ `react-native-safe-area-context` (>=4.0.0, for safe-area insets, so the host must render a
33
+ `SafeAreaProvider` ancestor, standard in every Expo/RN app). **Nothing in the kit forces you into
34
+ a native rebuild.** Brand artwork is supplied by the host (see [Illustrations](#illustrations)).
35
+
36
+ Everything else is an **optional peer**: `@expo/vector-icons` (icons on the choice cards, see
37
+ [Icons](#icons)), `expo-store-review`, `expo-haptics`, `expo-blur`, `react-native-reanimated`, and
38
+ `@blazejkustra/react-native-onboarding`. npm never installs them for you. Each one sits behind a
39
+ guarded lazy require and falls back to a defined no-op when it is missing. Don't want the icon
40
+ library? Pass your own nodes through the `icons` prop.
35
41
 
36
42
  ## How to include it in an app (end-to-end)
37
43
 
@@ -380,7 +386,7 @@ carries both ids.)
380
386
 
381
387
  **Two ways to wire it. Both are first-class.**
382
388
 
383
- If your app already keeps a session counter (Myelino and Morrow do), call the plain function
389
+ If your app already keeps a session counter (most host apps do), call the plain function
384
390
  from your own "app opened" path and pass the counter value:
385
391
 
386
392
  ```tsx
@@ -450,11 +456,18 @@ to use your own button.
450
456
  ## Cards
451
457
 
452
458
  `onboardingComponents` registers: `TextInputCard`, `SelectionCard`,
453
- `ChipSelectCard`, `NumberStepperCard`, `StatusCard` (terminal recap), and
454
- **`InterstitialCard`** — a mid-flow momentum/value screen (Duolingo/Cal-AI style):
459
+ `ChipSelectCard`, **`CardGridSelectCard`**, `NumberStepperCard`, `StatusCard` (terminal recap),
460
+ and **`InterstitialCard`** — a mid-flow momentum/value screen (Duolingo/Cal-AI style):
455
461
  an illustration + a line that reflects the user's answers back + a Continue tap.
456
462
  It is not a question; the backend emits it once mid-flow.
457
463
 
464
+ The three choice cards differ only in presentation, and each card's `description` is what the
465
+ model routes on. `SelectionCard` is a vertical list, for options wordy enough that someone has to
466
+ read them. `ChipSelectCard` packs 4 to 12 short tags into compact pills. `CardGridSelectCard` puts
467
+ 2 to 6 visual choices in a two-column grid, icon on top, short label under it ("What brings you
468
+ here?"). All three accept an option as a bare string, a `{value, label}`, or a
469
+ `{value, label, icon}`.
470
+
458
471
  ### Illustrations
459
472
 
460
473
  A tenant can configure **multiple** growth images per app (name + description +
@@ -472,6 +485,51 @@ illustrations={{
472
485
  }}
473
486
  ```
474
487
 
488
+ ### Icons
489
+
490
+ Options on `SelectionCard` and `CardGridSelectCard` can carry an `icon`, so "Where did you hear
491
+ about us?" puts a real Instagram mark next to the Instagram row. The AI emits a **semantic name
492
+ from a fixed vocabulary**, never a raw glyph name from an icon library:
493
+
494
+ ```jsonc
495
+ // what the backend emits
496
+ { "component": "CardGridSelectCard", "props": { "title": "What brings you here?", "options": [
497
+ { "value": "wedding", "label": "Wedding day", "icon": "wedding" },
498
+ { "value": "travel", "label": "Travelling", "icon": "travel" }
499
+ ]}}
500
+ ```
501
+
502
+ That indirection is the whole point. The model never learns a library's glyph names, so swapping
503
+ or upgrading the icon set is a one-file change in `src/icons/vocabulary.ts`. Import
504
+ `WIRE_ICON_NAMES` to get the list to paste into your prompt as the allowed values.
505
+
506
+ Resolution runs in three steps, and every miss is silent:
507
+
508
+ | Step | Source | When it applies |
509
+ |---|---|---|
510
+ | 1 | your `icons` prop | Always checked first. Overrides any name, and accepts names of your own. |
511
+ | 2 | `@expo/vector-icons` | If that optional peer resolves. |
512
+ | 3 | nothing | Renders `null`. |
513
+
514
+ Step 3 is a feature, not a gap. An icon name that is unknown, misspelled, or simply newer than the
515
+ installed build renders nothing at all: no crash, no tofu box, no layout shift, because the row
516
+ lays out exactly as it would with no icon. That is what lets the vocabulary keep growing without
517
+ waiting on every shipped app to update.
518
+
519
+ `@expo/vector-icons` sits behind a guarded lazy require. Install it and the icons work with no
520
+ native rebuild (most Expo apps already ship it). Skip it and the cards render fine without them.
521
+ Or skip it and bring your own brand nodes, keyed by the same vocabulary names:
522
+
523
+ ```tsx
524
+ icons={{
525
+ instagram: <BrandInstagram />, // override one name with your own mark
526
+ "my-custom-thing": <MyGlyph />, // or add a name the AI can emit
527
+ }}
528
+ ```
529
+
530
+ Icons are decorative: the label carries the meaning, so an icon stays hidden from screen readers
531
+ and never becomes an option's accessible name.
532
+
475
533
  ## Backend coupling
476
534
 
477
535
  The backend (`wire-rn/examples/dynamic-onboarding/server`) is the source of truth
@@ -480,9 +538,19 @@ for what the agent may emit. To make a new card emittable you must, server-side:
480
538
  1. add it to `app/wire.py` `KNOWN_COMPONENTS` + `COMPONENT_DOCS`, **and**
481
539
  2. add its name to the tenant's `allowed_components` (the `register_*.py` script),
482
540
 
483
- then `fly deploy`. Registering the card in this kit alone is **not** enough the
484
- server validates every emission and replaces unknown components with a safe
485
- `StatusCard`. (`InterstitialCard` is already wired on both sides.)
541
+ then `fly deploy`. Registering the card in this kit alone is **not** enough: the server validates
542
+ every emission against `KNOWN_COMPONENTS` and the tenant's `allowed_components`, so a card it does
543
+ not know is simply never emitted. (`InterstitialCard` is already wired on both sides.
544
+ `CardGridSelectCard` is registered here but **not yet on the server**, so it cannot be emitted
545
+ until the two steps above are done.)
546
+
547
+ Old app versions are protected by a handshake, not by luck: the kit advertises the exact set it
548
+ can render (`supportedComponents`, sent in the reserved A2A `metadata`), and the server narrows
549
+ each turn to that intersection. That is why adding a card here cannot break an app already in the
550
+ stores. It never advertises the new name, so the server never sends it one. Do not bypass that
551
+ check, because a component name a device cannot render does **not** degrade gracefully. It throws
552
+ `COMPONENT_NOT_FOUND` inside `wireai-rn` before the renderer ever sees it, and the user loses the
553
+ turn to the retry/fallback path.
486
554
 
487
555
  ## Consuming from source (Metro + tsconfig)
488
556
 
@@ -749,6 +817,33 @@ per-app trigger rules against the app's event stream, and later a learnings-driv
749
817
  replaces the internals behind the same contract. Building the seam now is the whole cost of
750
818
  being AI-ready.
751
819
 
820
+ **Use `fetchReviewDecision`. Do not hand-roll the fetch.**
821
+
822
+ ```tsx
823
+ import { fetchReviewDecision, useReviewGate } from "@wireai/activation/reviews";
824
+
825
+ // The review gate usually lives on the home feed, where there is no onboarding session,
826
+ // so deviceKey is the identity that matters. sessionId is optional.
827
+ const decision = await fetchReviewDecision(target, { deviceKey });
828
+
829
+ const gate = useReviewGate({ config, decision: decision ?? undefined, storage });
830
+ ```
831
+
832
+ It returns the full `{ fire, reason, arm }` on a 2xx and `null` on anything else. That
833
+ distinction is the whole contract, and it is worth being blunt about why.
834
+
835
+ A host wrote its own version of this and collapsed an explicit `{fire: false}` into
836
+ `undefined`. Since the seam is `decision ?? local`, `undefined` means "the server has no
837
+ opinion, use my local rules", so the server could turn prompts **on** but never **off**. The
838
+ local timer then asked a real user to rate the app about three minutes into their first
839
+ session, before they had seen anything. They left one star.
840
+
841
+ So: `fire: false` must reach the gate as `false`. `null` must mean only that the server was
842
+ unreachable or answered badly, which is the one case where local rules should take over. A 422,
843
+ a 500, a 404, or bad JSON all return `null` and never throw. If the app runs a firing
844
+ experiment, echo the returned `arm` back as `meta.firing_arm` on the submission so per-arm
845
+ attribution survives a later reweighting.
846
+
752
847
  The firing chain, in order: a server `decision` wins immediately, then after
753
848
  `timeoutFallbackMs` the local rules take over (the timeout is a fallback only), and the
754
849
  once-per-version gate (`wire_review_<id>_seen`) always applies locally so a server bug can
@@ -1,10 +1,10 @@
1
- export { R as ReportAppEventOptions, r as reportAppEvent } from '../transport-BeO_Brcu.mjs';
2
- import { W as WireUserContext, E as EventQueueOptions } from '../currentSession-d9CrBxwe.mjs';
3
- export { A as AnalyticsEvent, C as ClientEvent, a as ClientEventTarget, b as ClientEventType, c as ContextEnvelope, d as ContextEnvelopeInput, e as EnvelopeSource, f as EventQueue, g as WIRE_ONBOARDING_EVENTS, h as WireOnboardingEventName, i as buildContextEnvelope, j as createEventQueue, k as getCurrentSessionId, m as makeSessionId, r as reportClientEvent, l as reportClientEvents, n as resetCurrentSessionId, s as setCurrentSessionId, t as toAnalyticsEvent } from '../currentSession-d9CrBxwe.mjs';
4
- import '../types-A6pTxIZV.mjs';
1
+ export { R as ReportAppEventOptions, r as reportAppEvent } from '../transport-f5VVB5hH.mjs';
2
+ import { W as WireUserContext, E as EventQueueOptions } from '../currentSession-Bs2JfTJ8.mjs';
3
+ export { A as AnalyticsEvent, C as ClientEvent, a as ClientEventTarget, b as ClientEventType, c as ContextEnvelope, d as ContextEnvelopeInput, e as EnvelopeSource, f as EventQueue, g as WIRE_ONBOARDING_EVENTS, h as WireOnboardingEventName, i as buildContextEnvelope, j as createEventQueue, k as getCurrentSessionId, m as makeSessionId, r as reportClientEvent, l as reportClientEvents, n as resetCurrentSessionId, s as setCurrentSessionId, t as toAnalyticsEvent } from '../currentSession-Bs2JfTJ8.mjs';
4
+ import '../types-CNUqMK0D.mjs';
5
5
  import '../types-BKfpdZzX.mjs';
6
- import '../types-GL_hQ0TN.mjs';
7
- import '../types-CMuOexw0.mjs';
6
+ import '../types-BcmagF6K.mjs';
7
+ import '../types-CKFhyrMu.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-DLpd1v5_.js';
2
- import { W as WireUserContext, E as EventQueueOptions } from '../currentSession-f7LWcdWG.js';
3
- export { A as AnalyticsEvent, C as ClientEvent, a as ClientEventTarget, b as ClientEventType, c as ContextEnvelope, d as ContextEnvelopeInput, e as EnvelopeSource, f as EventQueue, g as WIRE_ONBOARDING_EVENTS, h as WireOnboardingEventName, i as buildContextEnvelope, j as createEventQueue, k as getCurrentSessionId, m as makeSessionId, r as reportClientEvent, l as reportClientEvents, n as resetCurrentSessionId, s as setCurrentSessionId, t as toAnalyticsEvent } from '../currentSession-f7LWcdWG.js';
4
- import '../types-BhpXJGlg.js';
1
+ export { R as ReportAppEventOptions, r as reportAppEvent } from '../transport-DzU-TqZz.js';
2
+ import { W as WireUserContext, E as EventQueueOptions } from '../currentSession-61dcm3V-.js';
3
+ export { A as AnalyticsEvent, C as ClientEvent, a as ClientEventTarget, b as ClientEventType, c as ContextEnvelope, d as ContextEnvelopeInput, e as EnvelopeSource, f as EventQueue, g as WIRE_ONBOARDING_EVENTS, h as WireOnboardingEventName, i as buildContextEnvelope, j as createEventQueue, k as getCurrentSessionId, m as makeSessionId, r as reportClientEvent, l as reportClientEvents, n as resetCurrentSessionId, s as setCurrentSessionId, t as toAnalyticsEvent } from '../currentSession-61dcm3V-.js';
4
+ import '../types-Buj9Lw9t.js';
5
5
  import '../types-BKfpdZzX.js';
6
- import '../types-GL_hQ0TN.js';
7
- import '../types-CMuOexw0.js';
6
+ import '../types-BcmagF6K.js';
7
+ import '../types-CKFhyrMu.js';
8
8
  import 'react';
9
9
  import 'wireai-rn';
10
10
  import 'react-native';