@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.
- package/AGENTS.md +29 -5
- package/CHANGELOG.md +163 -6
- package/README.md +105 -10
- package/dist/analytics/index.d.mts +6 -6
- package/dist/analytics/index.d.ts +6 -6
- package/dist/analytics/index.js +447 -49
- package/dist/analytics/index.js.map +1 -1
- package/dist/analytics/index.mjs +447 -49
- package/dist/analytics/index.mjs.map +1 -1
- package/dist/coachmarks/index.d.mts +2 -2
- package/dist/coachmarks/index.d.ts +2 -2
- package/dist/coachmarks/index.js.map +1 -1
- package/dist/coachmarks/index.mjs.map +1 -1
- package/dist/{currentSession-f7LWcdWG.d.ts → currentSession-61dcm3V-.d.ts} +10 -2
- package/dist/{currentSession-d9CrBxwe.d.mts → currentSession-Bs2JfTJ8.d.mts} +10 -2
- package/dist/{decision-BzbiKwk3.d.mts → decision-Bl_M2y3r.d.mts} +1 -1
- package/dist/{decision-plDEOCkt.d.ts → decision-Cau5KmP6.d.ts} +1 -1
- package/dist/index.d.mts +558 -15
- package/dist/index.d.ts +558 -15
- package/dist/index.js +1144 -375
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +969 -207
- package/dist/index.mjs.map +1 -1
- package/dist/questionnaire/index.d.mts +69 -23
- package/dist/questionnaire/index.d.ts +69 -23
- package/dist/questionnaire/index.js +643 -172
- package/dist/questionnaire/index.js.map +1 -1
- package/dist/questionnaire/index.mjs +608 -137
- package/dist/questionnaire/index.mjs.map +1 -1
- package/dist/reviews/index.d.mts +24 -12
- package/dist/reviews/index.d.ts +24 -12
- package/dist/reviews/index.js +507 -173
- package/dist/reviews/index.js.map +1 -1
- package/dist/reviews/index.mjs +462 -129
- package/dist/reviews/index.mjs.map +1 -1
- package/dist/showcase/index.d.mts +2 -2
- package/dist/showcase/index.d.ts +2 -2
- package/dist/showcase/index.js.map +1 -1
- package/dist/showcase/index.mjs.map +1 -1
- package/dist/transport-DzU-TqZz.d.ts +86 -0
- package/dist/transport-f5VVB5hH.d.mts +86 -0
- package/dist/{types-GL_hQ0TN.d.mts → types-BcmagF6K.d.mts} +1 -1
- package/dist/{types-GL_hQ0TN.d.ts → types-BcmagF6K.d.ts} +1 -1
- package/dist/{types-BhpXJGlg.d.ts → types-Buj9Lw9t.d.ts} +15 -3
- package/dist/{types-CMuOexw0.d.mts → types-CKFhyrMu.d.mts} +1 -1
- package/dist/{types-CMuOexw0.d.ts → types-CKFhyrMu.d.ts} +1 -1
- package/dist/{types-A6pTxIZV.d.mts → types-CNUqMK0D.d.mts} +15 -3
- package/metro/index.d.ts +18 -1
- package/metro/index.js +80 -0
- package/package.json +9 -1
- package/src/WireOnboarding.tsx +4 -0
- package/src/analytics/currentSession.ts +1 -1
- package/src/cards/CardGridSelectCard.tsx +253 -0
- package/src/cards/ChipSelectCard.tsx +1 -1
- package/src/cards/SelectionCard.tsx +15 -19
- package/src/cards/index.ts +13 -1
- package/src/cards/optionSchema.ts +65 -0
- package/src/coachmarks/types.ts +1 -1
- package/src/components/CardHandoff.tsx +39 -7
- package/src/components/DoneCheck.tsx +94 -0
- package/src/components/ModalPaneHost.tsx +141 -0
- package/src/components/StepDots.tsx +112 -0
- package/src/device/appVersion.ts +29 -8
- package/src/device/deviceModel.ts +21 -8
- package/src/features/cache.ts +1 -1
- package/src/haptics/expo-haptics.d.ts +31 -0
- package/src/haptics/haptics.ts +58 -0
- package/src/icons/IconRegistry.tsx +53 -0
- package/src/icons/WireIcon.tsx +85 -0
- package/src/icons/expoIcons.ts +165 -0
- package/src/icons/index.ts +18 -0
- package/src/icons/vocabulary.ts +154 -0
- package/src/index.ts +20 -0
- package/src/questionnaire/QuestionnaireGate.tsx +315 -164
- package/src/questionnaire/decision.ts +4 -2
- package/src/questionnaire/index.ts +3 -2
- package/src/questionnaire/steps.ts +261 -0
- package/src/questionnaire/types.ts +33 -12
- package/src/reviews/ReviewGate.tsx +250 -152
- package/src/reviews/index.ts +7 -3
- package/src/reviews/transport.ts +77 -3
- package/src/reviews/types.ts +13 -0
- package/src/session/persistedSession.ts +1 -1
- package/src/session-analytics/lifecycle.ts +1 -1
- package/src/session-analytics/useSessionStart.ts +2 -2
- package/src/showcase/FeatureShowcase.tsx +1 -1
- package/src/showcase/showcaseColors.ts +1 -1
- package/src/theme/mergeThemeOver.ts +27 -0
- package/src/types.ts +8 -0
- package/dist/transport-BeO_Brcu.d.mts +0 -40
- 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
|
|
16
|
-
|
|
17
|
-
(>=4.0.0, for safe-area insets; the host must render a
|
|
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/
|
|
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 (
|
|
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
|
|
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
|
|
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
|
|
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 +
|
|
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
|
|
33
|
-
`SafeAreaProvider` ancestor, standard in every Expo/RN app)
|
|
34
|
-
|
|
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 (
|
|
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),
|
|
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
|
|
484
|
-
|
|
485
|
-
|
|
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-
|
|
2
|
-
import { W as WireUserContext, E as EventQueueOptions } from '../currentSession-
|
|
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-
|
|
4
|
-
import '../types-
|
|
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-
|
|
7
|
-
import '../types-
|
|
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-
|
|
2
|
-
import { W as WireUserContext, E as EventQueueOptions } from '../currentSession-
|
|
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-
|
|
4
|
-
import '../types-
|
|
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-
|
|
7
|
-
import '../types-
|
|
6
|
+
import '../types-BcmagF6K.js';
|
|
7
|
+
import '../types-CKFhyrMu.js';
|
|
8
8
|
import 'react';
|
|
9
9
|
import 'wireai-rn';
|
|
10
10
|
import 'react-native';
|