@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
@@ -0,0 +1,438 @@
1
+ /**
2
+ * PermissionCard - the priming screen that stands in front of an OS permission dialog.
3
+ *
4
+ * THE PRIMING PATTERN, which is the entire point: the OS dialog opens from the primary press
5
+ * handler and from nowhere else. There is no mount effect, no auto-fire, no timer, and no code path
6
+ * from render to `request()`. iOS grants an app exactly ONE native notification prompt for its
7
+ * whole lifetime, so this cheap in-app screen spends first and only forwards the users who said
8
+ * yes. The secondary ("Maybe later") advances the flow with the prompt still unspent.
9
+ *
10
+ * THREE PRIMARY ACTIONS, picked from the non-prompting `getStatus` probe (never from `request`):
11
+ * • ask (the default, and the only branch that can reach `request`)
12
+ * • settings (status `blocked`: the OS would show nothing, so the only route left is Settings)
13
+ * • continue (already granted, or a host that supplied no `request` at all - the kit declines to
14
+ * fabricate a prompt it has no way to open)
15
+ *
16
+ * IT IS NOT A QUESTION. It sends nothing to the backend, appends nothing to the thread, and mints
17
+ * no `key` / `slot_id`, so `deriveAnswers` and every completion semantic are untouched. Every
18
+ * outcome, including a denial, continues the flow.
19
+ *
20
+ * Motion: the same register as the other value beats. The illustration springs in
21
+ * (STATUS_POP_SPRING, the StatusCard glyph pop) and the copy rises behind it on the interstitial
22
+ * head stagger. Reduce motion: final frame at once, like every other card.
23
+ *
24
+ * Registered as a `WireAIComponent` so a later server-emitted placement (AI-chosen timing) renders
25
+ * through the same component with no rewrite. It is deliberately NOT in `onboardingComponents`:
26
+ * that array is what the device ADVERTISES as renderable, and a backend told it may emit this card
27
+ * could emit one for a host that wired no `request`.
28
+ */
29
+ import React, { useCallback, useEffect, useRef, useState } from "react";
30
+ import { Animated, Easing, StyleSheet, Text, View } from "react-native";
31
+ import { z } from "zod";
32
+ import type { InjectedProps, WireAIComponent } from "wireai-rn";
33
+ import { useOnboardingTheme } from "../theme/ThemeContext";
34
+ import { bodyStyle, headingStyle } from "../theme/typography";
35
+ import { useIllustration } from "../components/Illustration";
36
+ import { Button } from "../components/Button";
37
+ import { CardLayout } from "../components/CardLayout";
38
+ import {
39
+ INTERSTITIAL_HEAD_MS,
40
+ INTERSTITIAL_HEAD_STAGGER_MS,
41
+ STATUS_POP_SCALE_FROM,
42
+ STATUS_POP_SPRING,
43
+ WIRE_BEZIER,
44
+ scaledMs,
45
+ } from "../motion/motionSpec";
46
+ import { useReducedMotion } from "../motion/useReducedMotion";
47
+ import { normalizePermissionStatus } from "../permissions/permissionEvents";
48
+ import type {
49
+ PermissionStage,
50
+ WirePermissionOutcome,
51
+ WirePermissionStatus,
52
+ } from "../permissions/types";
53
+ import { playHaptic } from "../haptics/haptics";
54
+ import { warnInDev } from "../utils/warnInDev";
55
+
56
+ const easeWire = Easing.bezier(...WIRE_BEZIER);
57
+ const RISE_PX = 10;
58
+
59
+ /** The name the card is registered under, and the `component` stamped on its events. */
60
+ export const PERMISSION_CARD_NAME = "PermissionCard";
61
+
62
+ /**
63
+ * How long a host's `request` may stay outstanding before the kit hands the controls back.
64
+ *
65
+ * DELIBERATELY GENEROUS. This is not a race against the user: someone reading an OS permission
66
+ * dialog, switching apps mid-prompt, or hitting a slow native bridge is doing nothing wrong, and a
67
+ * short ceiling would advance the flow underneath a dialog that is still open. It exists for one
68
+ * failure only, a `request` that never settles at all (a swallowed native callback, a promise
69
+ * nobody resolves), which would otherwise leave the user on a screen whose buttons are all
70
+ * disabled. On expiry the kit records NO outcome, because a pending request is not a denial.
71
+ */
72
+ export const REQUEST_WATCHDOG_MS = 90_000;
73
+
74
+ /**
75
+ * The COPY half of the card, i.e. everything a server could legitimately author. The capability
76
+ * half (`request` / `getStatus` / `openSettings`) is host-injected and deliberately absent here:
77
+ * a schema field can only ever carry data, never a function, and the kit imports no native module.
78
+ */
79
+ const schema = z.object({
80
+ permission: z
81
+ .string()
82
+ .describe("Which OS permission this screen primes, e.g. 'notifications'"),
83
+ title: z.string().describe("Headline for the ask"),
84
+ message: z.string().describe("Why the app needs it, in the user's terms"),
85
+ primaryLabel: z.string().describe("Primary button, the only control that can open the OS dialog"),
86
+ secondaryLabel: z.string().describe("Secondary button, advances without spending the OS prompt"),
87
+ blockedTitle: z.string().optional().describe("Headline once the permission is permanently refused"),
88
+ blockedMessage: z.string().optional().describe("Rationale for the settings route"),
89
+ settingsLabel: z.string().optional().describe("Primary button label on the blocked route"),
90
+ continueLabel: z.string().optional().describe("Primary button label when there is nothing to ask"),
91
+ illustration: z
92
+ .string()
93
+ .optional()
94
+ .describe("Name of an app-provided illustration (defaults to the permission name)"),
95
+ });
96
+
97
+ export type PermissionCardProps = z.infer<typeof schema> &
98
+ Partial<InjectedProps> & {
99
+ /** THE ONLY function that can open an OS dialog. Called from the primary handler alone. */
100
+ request?: () => Promise<WirePermissionStatus>;
101
+ /** NON-PROMPTING status read. Decides which primary action is offered, nothing else. */
102
+ getStatus?: () => Promise<WirePermissionStatus>;
103
+ /** Open the OS settings page. Only reachable on the `blocked` route. */
104
+ openSettings?: () => void | Promise<void>;
105
+ /** Reports each moment for analytics. Never control flow. */
106
+ onStage?: (stage: PermissionStage, status?: WirePermissionStatus) => void;
107
+ /** Fires exactly once, with the outcome this screen produced. The flow advances on it. */
108
+ onSettled?: (outcome: WirePermissionOutcome) => void;
109
+ };
110
+
111
+ /** Which action the primary button performs. `ask` is the only one that can reach `request`. */
112
+ type PrimaryAction = "ask" | "settings" | "continue";
113
+
114
+ const _PermissionCard: React.FC<PermissionCardProps> = ({
115
+ permission,
116
+ title,
117
+ message,
118
+ primaryLabel,
119
+ secondaryLabel,
120
+ blockedTitle,
121
+ blockedMessage,
122
+ settingsLabel,
123
+ continueLabel,
124
+ illustration,
125
+ request,
126
+ getStatus,
127
+ openSettings,
128
+ onStage,
129
+ onSettled,
130
+ }) => {
131
+ const t = useOnboardingTheme();
132
+ const reduced = useReducedMotion();
133
+ const art = useIllustration(illustration ?? permission);
134
+ // The probed status. `undefined` means "not known", which is the ASK state: the kit never
135
+ // assumes a grant it has not been told about.
136
+ const [status, setStatus] = useState<WirePermissionStatus | undefined>(undefined);
137
+ const [busy, setBusy] = useState(false);
138
+ // One settle per mount. The card is keyed by screen id, so this is one settle per screen. It is
139
+ // also what makes a late `request` settlement a no-op, since `finish` reaches `settle`
140
+ // synchronously (see `handlePrimary`).
141
+ const settledRef = useRef(false);
142
+ // `accepted` is a per-screen boolean, so `accepted / shown` stays a readable rate.
143
+ const acceptedRef = useRef(false);
144
+ const watchdogRef = useRef<ReturnType<typeof setTimeout> | null>(null);
145
+ const clearWatchdog = useCallback(() => {
146
+ if (watchdogRef.current) {
147
+ clearTimeout(watchdogRef.current);
148
+ watchdogRef.current = null;
149
+ }
150
+ }, []);
151
+ // A pending watchdog must never outlive the screen (it would setState on an unmounted card).
152
+ useEffect(() => clearWatchdog, [clearWatchdog]);
153
+
154
+ // Host callbacks held in refs so the mount-once effects below stay mount-once no matter how a
155
+ // host passes them (the documented usage is an INLINE `permissionScreens={[...]}`, which mints a
156
+ // fresh closure for every one of them on every render).
157
+ const onStageRef = useRef(onStage);
158
+ onStageRef.current = onStage;
159
+ const onSettledRef = useRef(onSettled);
160
+ onSettledRef.current = onSettled;
161
+ const getStatusRef = useRef(getStatus);
162
+ getStatusRef.current = getStatus;
163
+
164
+ // `shown` fires once per mount, ref-guarded so StrictMode's dev double-invoke cannot
165
+ // double-count the denominator every rate in this funnel is measured against.
166
+ const shownRef = useRef(false);
167
+ useEffect(() => {
168
+ if (shownRef.current) return;
169
+ shownRef.current = true;
170
+ onStageRef.current?.("shown");
171
+ }, []);
172
+
173
+ // The status probe, MOUNT-ONCE. `getPermissionsAsync()` and its equivalents READ, they never
174
+ // prompt - which is exactly why `request` is not called here and why this effect may exist at
175
+ // all. All it decides is which primary action the screen offers.
176
+ useEffect(() => {
177
+ const probe = getStatusRef.current;
178
+ if (!probe) return;
179
+ let cancelled = false;
180
+ try {
181
+ void Promise.resolve(probe())
182
+ .then((value) => {
183
+ if (!cancelled) setStatus(normalizePermissionStatus(value));
184
+ })
185
+ .catch(() => {
186
+ // A host probe that throws just leaves the screen in its ask state.
187
+ });
188
+ } catch {
189
+ // A synchronously-throwing probe, same treatment.
190
+ }
191
+ return () => {
192
+ cancelled = true;
193
+ };
194
+ }, []);
195
+
196
+ // A host that configured a screen with no `request` gets a screen that cannot ask. The kit says
197
+ // so instead of rendering a button that silently does nothing (dev only, never a throw).
198
+ const requestRef = useRef(request);
199
+ requestRef.current = request;
200
+ useEffect(() => {
201
+ if (requestRef.current) return;
202
+ warnInDev(
203
+ `[wireai] <WireOnboarding> got a permission screen for "${permission}" with no \`request\` ` +
204
+ "function, so it cannot open the OS dialog and renders as a plain continue. Pass " +
205
+ "request: () => Promise<'granted' | 'denied' | 'blocked'> (5 lines around " +
206
+ "expo-notifications, see the README).",
207
+ );
208
+ }, [permission]);
209
+
210
+ const settle = useCallback((outcome: WirePermissionOutcome) => {
211
+ if (settledRef.current) return;
212
+ settledRef.current = true;
213
+ clearWatchdog();
214
+ onSettledRef.current?.(outcome);
215
+ }, [clearWatchdog]);
216
+
217
+ const primaryAction: PrimaryAction =
218
+ status === "blocked" ? "settings" : status === "granted" || !request ? "continue" : "ask";
219
+
220
+ const handlePrimary = useCallback(() => {
221
+ if (settledRef.current || busy) return;
222
+
223
+ if (primaryAction === "settings") {
224
+ onStageRef.current?.("settings", "blocked");
225
+ try {
226
+ void Promise.resolve(openSettings?.()).catch(() => {});
227
+ } catch {
228
+ // A settings redirect that fails is not a reason to trap the user on this screen.
229
+ }
230
+ settle("blocked");
231
+ return;
232
+ }
233
+
234
+ if (primaryAction === "continue") {
235
+ // Already granted (nothing to ask), or no `request` was supplied. Report the state as it is;
236
+ // never report a grant this screen did not produce as anything other than what it is.
237
+ if (status === "granted") {
238
+ onStageRef.current?.("granted", "granted");
239
+ settle("granted");
240
+ } else {
241
+ onStageRef.current?.("skipped");
242
+ settle("skipped");
243
+ }
244
+ return;
245
+ }
246
+
247
+ // THE ONE PATH TO THE OS DIALOG, reachable from this press handler only.
248
+ //
249
+ // `accepted` is a per-screen boolean, not a per-tap counter: the screen is shown once, and
250
+ // `accepted / shown` is the rate that says whether the rationale copy works. A second tap after
251
+ // the watchdog re-armed the UI would push that rate past 100%, so it is emitted once.
252
+ if (!acceptedRef.current) {
253
+ acceptedRef.current = true;
254
+ onStageRef.current?.("accepted");
255
+ }
256
+ setBusy(true);
257
+ // FIRST SETTLEMENT WINS, and `settledRef` is the whole mechanism. The watchdog below can hand
258
+ // the UI back while a `request` is still outstanding, so a second tap can put a SECOND one in
259
+ // flight, and the original can answer late. What decides between them is that this function
260
+ // runs STRAIGHT THROUGH to `settle` with no await in between: the first settlement to arrive
261
+ // has already flipped that ref by the time any later one is invoked, so the later one returns
262
+ // on the line below. The flow advances once and each stage event is emitted once. The same ref
263
+ // is why a settlement landing after the user skipped changes nothing.
264
+ const finish = (value: unknown) => {
265
+ if (settledRef.current) return;
266
+ clearWatchdog();
267
+ const resolved = normalizePermissionStatus(value);
268
+ setBusy(false);
269
+ setStatus(resolved);
270
+ if (resolved === "granted") {
271
+ // A light success tap on the grant, through the kit's optional-peer haptics: a host
272
+ // without `expo-haptics` simply feels nothing and nothing throws.
273
+ playHaptic("success");
274
+ onStageRef.current?.("granted", "granted");
275
+ } else {
276
+ onStageRef.current?.("denied", resolved);
277
+ }
278
+ settle(resolved);
279
+ };
280
+
281
+ // THE WATCHDOG, and what it deliberately does NOT do.
282
+ //
283
+ // A host `request` that never settles (a native module that swallows its callback, a promise
284
+ // that is never resolved) would otherwise BRICK the screen: `busy` disables both buttons, the
285
+ // secondary re-guards on it, and nothing upstream rescues a rendered card. Dead end, and
286
+ // `onComplete` never fires.
287
+ //
288
+ // So the ceiling exists to un-brick a HOST BUG, never to race the user. A person can sit on an
289
+ // OS permission dialog for a long time, so a short ceiling that recorded `denied` on expiry
290
+ // would advance the flow underneath a dialog that is still open and log an outcome the user
291
+ // never gave. On expiry this therefore fabricates NOTHING: no outcome, no event, no settle. It
292
+ // only hands the controls back so the user can tap again or skip, and says so in dev.
293
+ clearWatchdog();
294
+ watchdogRef.current = setTimeout(() => {
295
+ watchdogRef.current = null;
296
+ if (settledRef.current) return;
297
+ setBusy(false);
298
+ warnInDev(
299
+ `[wireai] the \`request\` for the "${permission}" permission screen has not settled after ` +
300
+ `${Math.round(REQUEST_WATCHDOG_MS / 1000)}s, so the kit handed the controls back rather ` +
301
+ "than leaving the user on a dead-end screen. It recorded NO outcome, because a pending " +
302
+ "request is not a denial. Make sure your request resolves to 'granted' | 'denied' | " +
303
+ "'blocked' on every branch, including the one where the user dismisses the OS dialog.",
304
+ );
305
+ }, REQUEST_WATCHDOG_MS);
306
+
307
+ try {
308
+ void Promise.resolve(request?.()).then(finish, () => finish("denied"));
309
+ } catch {
310
+ // A synchronously-throwing request is a denial, never a stuck screen.
311
+ finish("denied");
312
+ }
313
+ }, [busy, primaryAction, status, request, openSettings, settle, clearWatchdog, permission]);
314
+
315
+ const handleSecondary = useCallback(() => {
316
+ if (settledRef.current || busy) return;
317
+ onStageRef.current?.("skipped");
318
+ settle("skipped");
319
+ }, [busy, settle]);
320
+
321
+ // Art springs in, copy rises behind it (final frame at once under reduce motion).
322
+ const popT = useRef(new Animated.Value(reduced ? 1 : 0)).current;
323
+ const titleT = useRef(new Animated.Value(reduced ? 1 : 0)).current;
324
+ const bodyT = useRef(new Animated.Value(reduced ? 1 : 0)).current;
325
+ useEffect(() => {
326
+ if (reduced) {
327
+ popT.setValue(1);
328
+ titleT.setValue(1);
329
+ bodyT.setValue(1);
330
+ return;
331
+ }
332
+ const rise = (value: Animated.Value, delay: number) =>
333
+ Animated.timing(value, {
334
+ toValue: 1,
335
+ duration: scaledMs(INTERSTITIAL_HEAD_MS),
336
+ delay,
337
+ easing: easeWire,
338
+ useNativeDriver: true,
339
+ });
340
+ const anims = [
341
+ Animated.spring(popT, {
342
+ toValue: 1,
343
+ friction: STATUS_POP_SPRING.friction,
344
+ tension: STATUS_POP_SPRING.tension,
345
+ useNativeDriver: true,
346
+ }),
347
+ rise(titleT, scaledMs(INTERSTITIAL_HEAD_STAGGER_MS)),
348
+ rise(bodyT, scaledMs(INTERSTITIAL_HEAD_STAGGER_MS * 2)),
349
+ ];
350
+ anims.forEach((a) => a.start());
351
+ return () => anims.forEach((a) => a.stop());
352
+ }, [reduced, popT, titleT, bodyT]);
353
+
354
+ const popScale = popT.interpolate({
355
+ inputRange: [0, 1],
356
+ outputRange: [STATUS_POP_SCALE_FROM, 1],
357
+ });
358
+ const riseStyle = (value: Animated.Value) => ({
359
+ opacity: value,
360
+ transform: [
361
+ { translateY: value.interpolate({ inputRange: [0, 1], outputRange: [RISE_PX, 0] }) },
362
+ ],
363
+ });
364
+
365
+ const blocked = status === "blocked";
366
+ const shownTitle = blocked ? (blockedTitle ?? title) : title;
367
+ const shownMessage = blocked ? (blockedMessage ?? message) : message;
368
+ const primaryTitle =
369
+ primaryAction === "settings"
370
+ ? (settingsLabel ?? primaryLabel)
371
+ : primaryAction === "continue"
372
+ ? (continueLabel ?? primaryLabel)
373
+ : primaryLabel;
374
+ // Nothing left to decline once the permission is already granted.
375
+ const showSecondary = status !== "granted";
376
+
377
+ return (
378
+ <CardLayout
379
+ align="center"
380
+ footer={
381
+ <View style={[styles.footer, { gap: t.spacing.sm }]}>
382
+ <Button title={primaryTitle} onPress={handlePrimary} variant="primary" disabled={busy} />
383
+ {showSecondary ? (
384
+ <Button
385
+ title={secondaryLabel}
386
+ onPress={handleSecondary}
387
+ variant="outline"
388
+ disabled={busy}
389
+ />
390
+ ) : null}
391
+ </View>
392
+ }
393
+ >
394
+ <View style={[styles.center, { gap: t.spacing.md }]}>
395
+ {art ? (
396
+ <Animated.View
397
+ style={[styles.art, { opacity: popT, transform: [{ scale: popScale }] }]}
398
+ >
399
+ {art}
400
+ </Animated.View>
401
+ ) : null}
402
+
403
+ <Animated.View style={riseStyle(titleT)}>
404
+ <Text style={[headingStyle(t.fonts), { color: t.colors.text, textAlign: "center" }]}>
405
+ {shownTitle}
406
+ </Text>
407
+ </Animated.View>
408
+
409
+ <Animated.View style={riseStyle(bodyT)}>
410
+ <Text style={[bodyStyle(t.fonts), { color: t.colors.textMuted, textAlign: "center" }]}>
411
+ {shownMessage}
412
+ </Text>
413
+ </Animated.View>
414
+ </View>
415
+ </CardLayout>
416
+ );
417
+ };
418
+
419
+ /** The typed component the flow renders directly (host-injected placement). */
420
+ export const PermissionCardView = React.memo(_PermissionCard);
421
+
422
+ /**
423
+ * The SDK registration object, so a server-emitted placement can adopt this exact screen later.
424
+ * Not part of `onboardingComponents` on purpose - see the file header.
425
+ */
426
+ export const PermissionCard: WireAIComponent = {
427
+ name: PERMISSION_CARD_NAME,
428
+ description:
429
+ "A priming screen shown BEFORE an OS permission dialog: it explains why the app wants the permission and asks only on the primary tap. Never first, never last. The user is never blocked by it: the secondary advances the flow with the OS prompt unspent.",
430
+ component: PermissionCardView as WireAIComponent["component"],
431
+ propsSchema: schema,
432
+ };
433
+
434
+ const styles = StyleSheet.create({
435
+ center: { width: "100%", alignItems: "center", justifyContent: "center" },
436
+ art: { alignItems: "center", justifyContent: "center" },
437
+ footer: { width: "100%", alignItems: "center" },
438
+ });
@@ -80,7 +80,7 @@ const _OptionRow: React.FC<OptionRowProps> = ({ opt, isSelected, multiSelect, on
80
80
 
81
81
  // Selected fill: the selected border+bg live on an absolute overlay whose opacity
82
82
  // crossfades in 180ms ease-wire — same visual as a color interpolation, but it
83
- // stays on the native driver (see EXTRACTED-SPEC.md D7). Reduce motion: snap.
83
+ // stays on the native driver. Reduce motion: snap.
84
84
  const selectT = useRef(new Animated.Value(isSelected ? 1 : 0)).current;
85
85
  useEffect(() => {
86
86
  const anim = Animated.timing(selectT, {
@@ -21,9 +21,16 @@ export {
21
21
  NumberStepperCard,
22
22
  InterstitialCard,
23
23
  };
24
+ export { PermissionCard, PermissionCardView, PERMISSION_CARD_NAME } from "./PermissionCard";
25
+ export type { PermissionCardProps } from "./PermissionCard";
24
26
  export { normalizeOptions, optionsField, optionObjectSchema } from "./optionSchema";
25
27
  export type { CardOption } from "./optionSchema";
26
28
 
29
+ // ⚠️ `PermissionCard` is deliberately NOT in this array. The list is what the device ADVERTISES to
30
+ // the backend as renderable (`metadata.supportedComponents`), and a backend told it may emit a
31
+ // permission screen could emit one into a host that injected no `request` function, which the kit
32
+ // has no way to honour. Permission screens are host-declared for now (`permissionScreens`); the
33
+ // card is registered and ready for the day a server-emitted placement lands.
27
34
  export const onboardingComponents: WireAIComponent[] = [
28
35
  ChipSelectCard,
29
36
  TextInputCard,
@@ -39,6 +39,10 @@ type CoachmarkRuntime = {
39
39
  // resolved flags. Disabled → the tour never ARMS and the overlay `show()` is a no-op, so nothing
40
40
  // paints and — critically — no once-gate is written, so re-enabling replays the tour correctly.
41
41
  coachmarksEnabled: boolean;
42
+ // Subscribers to the kill switch. OPTIONAL on the type (never on the behaviour) so a record left
43
+ // in the registry by an inlined copy from an OLDER kit version — which had no listener set — is
44
+ // upgraded in place rather than read as corrupt.
45
+ listeners?: Set<() => void>;
42
46
  };
43
47
 
44
48
  type GlobalWithCoachmarkRuntime = typeof globalThis & {
@@ -55,17 +59,73 @@ const coachmarkRuntime = (): CoachmarkRuntime => {
55
59
  return created;
56
60
  };
57
61
 
62
+ const enabledListeners = (): Set<() => void> => {
63
+ const runtime = coachmarkRuntime();
64
+ if (!runtime.listeners) runtime.listeners = new Set();
65
+ return runtime.listeners;
66
+ };
67
+
68
+ /**
69
+ * Wake the subscribers on a MICROTASK, never synchronously.
70
+ *
71
+ * `CoachmarkProvider` writes this flag during its RENDER phase on purpose (so a child's mount
72
+ * effect already sees the resolved value). Notifying from there synchronously would schedule a
73
+ * React update while a *different* component is rendering. Every subscriber reads the value
74
+ * through `areCoachmarksEnabled()` at render time anyway, so the notification only has to wake
75
+ * the components that are not already re-rendering — one microtask later is soon enough, and it
76
+ * keeps the write side legal from anywhere.
77
+ */
78
+ const notifyCoachmarksEnabled = (): void => {
79
+ if (enabledListeners().size === 0) return;
80
+ void Promise.resolve().then(() => {
81
+ for (const listener of [...enabledListeners()]) {
82
+ try {
83
+ listener();
84
+ } catch {
85
+ // A broken subscriber must never break the kill switch for the others.
86
+ }
87
+ }
88
+ });
89
+ };
90
+
58
91
  /**
59
92
  * Set the coachmarks master switch (from the resolved feature flags). Default true (fail-open).
60
- * Written by CoachmarkProvider; read imperatively by the tour arm + the overlay store.
93
+ * Written by CoachmarkProvider; read imperatively by the tour arm + the overlay store, and
94
+ * SUBSCRIBED to by `useCoachmarkTour` (see `subscribeCoachmarksEnabled`).
95
+ *
96
+ * No-ops when the value is unchanged, which matters because the provider writes it on every
97
+ * render — without that guard every render would fan out a notification.
61
98
  */
62
99
  export const setCoachmarksEnabled = (value: boolean): void => {
63
- coachmarkRuntime().coachmarksEnabled = value;
100
+ const runtime = coachmarkRuntime();
101
+ if (runtime.coachmarksEnabled === value) return;
102
+ runtime.coachmarksEnabled = value;
103
+ notifyCoachmarksEnabled();
64
104
  };
65
105
 
66
106
  /** Whether the coachmarks module is enabled. False → tours/overlays are silently skipped. */
67
107
  export const areCoachmarksEnabled = (): boolean => coachmarkRuntime().coachmarksEnabled;
68
108
 
109
+ /**
110
+ * Subscribe to kill-switch flips. Returns the unsubscribe function — the `useSyncExternalStore`
111
+ * contract, and deliberately the SAME shape as `coachmarkOverlay.subscribe` next door rather than
112
+ * a second pattern to keep correct.
113
+ *
114
+ * WHY IT EXISTS: the flag is a mutable `globalThis` value, so on its own it can only be SAMPLED.
115
+ * A tour that samples it cannot notice a tenant flipping it off mid-flight — the overlay's
116
+ * `show()` quietly becomes a no-op while the tour still believes it is running, so nothing paints,
117
+ * nothing can be tapped to advance, and no terminal path is reachable. `useCoachmarkTour`
118
+ * subscribes instead. The listener set lives in the SAME `globalThis` record as the flag, so every
119
+ * inlined copy of this module shares one subscriber list.
120
+ */
121
+ export const subscribeCoachmarksEnabled = (listener: () => void): (() => void) => {
122
+ const listeners = enabledListeners();
123
+ listeners.add(listener);
124
+ return () => {
125
+ listeners.delete(listener);
126
+ };
127
+ };
128
+
69
129
  /** Set (or clear) the injected sync gate storage. Called by CoachmarkProvider. */
70
130
  export const setCoachmarkStorage = (storage: CoachmarkStorage | null): void => {
71
131
  coachmarkRuntime().storage = storage;
@@ -1,8 +1,14 @@
1
- import { useCallback, useEffect, useRef, useState } from "react";
1
+ import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
2
2
 
3
3
  import { coachmarkAnchors } from "./coachmarkAnchorRegistry";
4
4
  import { coachmarkOverlay } from "./coachmarkOverlayStore";
5
- import { areCoachmarksEnabled, coachmarkGateKey, hasSeenGate, markSeenGate } from "./runtime";
5
+ import {
6
+ areCoachmarksEnabled,
7
+ coachmarkGateKey,
8
+ hasSeenGate,
9
+ markSeenGate,
10
+ subscribeCoachmarksEnabled,
11
+ } from "./runtime";
6
12
  import type { CoachmarkStep } from "./types";
7
13
 
8
14
  export interface UseCoachmarkTourOptions {
@@ -61,6 +67,15 @@ const DEFAULT_START_DELAY_MS = 3000;
61
67
  * target is picked up. `onStepShown` is suppressed on that resume (last-shown
62
68
  * step id is tracked in a ref) so a pause/resume never double-counts a step.
63
69
  *
70
+ * KILL SWITCH vs PAUSE — two different gates, two different endings. `enabled` is the host's
71
+ * domain gate and PAUSES (position kept, gate unwritten, resumes on the same step). The tenant's
72
+ * coachmarks feature flag (`setCoachmarksEnabled`, from GET /v1/features) is TERMINAL: flipped off
73
+ * mid-tour it ends the tour and fires `onComplete`, but does NOT write the once-gate, so the tour
74
+ * replays on the next mount once the flag is back on. It is read through a subscription rather
75
+ * than sampled, because the flip has to reach a tour that is already in flight: the overlay's
76
+ * `show()` is a no-op while the flag is off, so a tour that carried on would paint nothing, could
77
+ * not be tapped to advance, and would never reach a terminal state.
78
+ *
64
79
  * IMPORTANT: `steps` MUST be a stable (memoized) array. If a new array identity
65
80
  * is passed on every render the drive effect re-runs and re-shows the current
66
81
  * step (wasteful anchor re-resolves / overlay churn), and analytics can
@@ -97,34 +112,67 @@ export const useCoachmarkTour = (
97
112
  const callbacksRef = useRef({ onStepShown, onStepEngaged, onStepDismissed });
98
113
  callbacksRef.current = { onStepShown, onStepEngaged, onStepDismissed };
99
114
 
100
- const finish = useCallback(() => {
101
- if (finishedRef.current) return;
102
- finishedRef.current = true;
103
- if (showOnce) markSeenGate(coachmarkGateKey(tourId));
104
- coachmarkOverlay.hide();
105
- setActiveIndex(null);
106
- onComplete?.();
107
- }, [onComplete, showOnce, tourId]);
115
+ // The tenant's coachmarks kill switch, SUBSCRIBED rather than sampled so a mid-tour flip reaches
116
+ // a tour already in flight. See the KILL SWITCH vs PAUSE note above.
117
+ const coachmarksOn = useSyncExternalStore(
118
+ subscribeCoachmarksEnabled,
119
+ areCoachmarksEnabled,
120
+ areCoachmarksEnabled,
121
+ );
122
+
123
+ /**
124
+ * The single terminal exit. `writeGate` is what separates the two ways a tour can end:
125
+ * • the user reached the end (or dismissed the last step) → the once-gate IS written, so a
126
+ * finished tour never nags again;
127
+ * • the kill switch went off mid-tour → the gate is NOT written. That gate belongs to a tour
128
+ * the user actually saw, and the module's whole kill-switch contract is that disabling
129
+ * never consumes it, so re-enabling replays the tour.
130
+ * Either way `onComplete` fires: a host that gates its own UI on the tour finishing must not be
131
+ * left waiting on a callback that can no longer arrive.
132
+ */
133
+ const exit = useCallback(
134
+ (writeGate: boolean) => {
135
+ if (finishedRef.current) return;
136
+ finishedRef.current = true;
137
+ if (writeGate && showOnce) markSeenGate(coachmarkGateKey(tourId));
138
+ coachmarkOverlay.hide();
139
+ setActiveIndex(null);
140
+ onComplete?.();
141
+ },
142
+ [onComplete, showOnce, tourId],
143
+ );
144
+
145
+ const finish = useCallback(() => exit(true), [exit]);
108
146
 
109
147
  // Arm once, after the delay, when enabled and the gate is unseen.
110
148
  useEffect(() => {
111
149
  if (!enabled || startedRef.current) return undefined;
112
150
  // Feature kill switch: never arm while coachmarks are disabled, so no timer starts, no
113
- // overlay shows, and the once-gate is NOT consumed — re-enabling replays the tour.
114
- if (!areCoachmarksEnabled()) return undefined;
151
+ // overlay shows, and the once-gate is NOT consumed — re-enabling replays the tour. Reading
152
+ // the SUBSCRIBED value (and listing it in the deps) is what makes that last clause true
153
+ // without a remount: flipping it off clears a pending arm timer, flipping it back on re-arms.
154
+ if (!coachmarksOn) return undefined;
115
155
  if (showOnce && hasSeenGate(coachmarkGateKey(tourId))) return undefined;
116
156
  const timer = setTimeout(() => {
117
157
  startedRef.current = true;
118
158
  setActiveIndex(0);
119
159
  }, startDelayMs);
120
160
  return () => clearTimeout(timer);
121
- }, [enabled, startDelayMs, showOnce, tourId]);
161
+ }, [enabled, coachmarksOn, startDelayMs, showOnce, tourId]);
122
162
 
123
163
  // Drive the active step. Keyed on `enabled` too, so a mid-tour focus loss
124
164
  // pauses (hide, keep position) and a return to focus resumes the same step.
125
165
  useEffect(() => {
126
166
  if (activeIndex === null) return undefined;
127
167
 
168
+ // KILL SWITCH, mid-tour: exit terminally, WITHOUT writing the once-gate, so the tour replays
169
+ // when it is switched back on. Checked BEFORE the pause branch below — a disable is terminal,
170
+ // a focus loss is not.
171
+ if (!coachmarksOn) {
172
+ exit(false);
173
+ return undefined;
174
+ }
175
+
128
176
  // PAUSE: armed but the screen lost focus (enabled flipped false while in
129
177
  // flight). Hide the overlay so a stale rect can't float over the wrong
130
178
  // screen, but KEEP activeIndex and DON'T write the gate. When enabled
@@ -188,7 +236,7 @@ export const useCoachmarkTour = (
188
236
  return () => {
189
237
  cancelled = true;
190
238
  };
191
- }, [activeIndex, steps, finish, enabled]);
239
+ }, [activeIndex, steps, finish, exit, enabled, coachmarksOn]);
192
240
 
193
241
  // Safety: clear the overlay if the screen unmounts mid-tour.
194
242
  useEffect(