@wireai/activation 0.13.0 → 0.13.2

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 (41) hide show
  1. package/AGENTS.md +3 -1
  2. package/CHANGELOG.md +116 -3
  3. package/README.md +82 -0
  4. package/dist/analytics/index.d.mts +2 -2
  5. package/dist/analytics/index.d.ts +2 -2
  6. package/dist/analytics/index.js +61 -2
  7. package/dist/analytics/index.js.map +1 -1
  8. package/dist/analytics/index.mjs +61 -2
  9. package/dist/analytics/index.mjs.map +1 -1
  10. package/dist/{currentSession-_GynvhzT.d.mts → currentSession-ClkLjcJ0.d.mts} +298 -13
  11. package/dist/{currentSession-D7zabMXK.d.ts → currentSession-DOVZEWJl.d.ts} +298 -13
  12. package/dist/index.d.mts +195 -4
  13. package/dist/index.d.ts +195 -4
  14. package/dist/index.js +909 -340
  15. package/dist/index.js.map +1 -1
  16. package/dist/index.mjs +693 -145
  17. package/dist/index.mjs.map +1 -1
  18. package/dist/questionnaire/index.js.map +1 -1
  19. package/dist/questionnaire/index.mjs.map +1 -1
  20. package/dist/reviews/index.js +12 -1
  21. package/dist/reviews/index.js.map +1 -1
  22. package/dist/reviews/index.mjs +12 -1
  23. package/dist/reviews/index.mjs.map +1 -1
  24. package/package.json +1 -1
  25. package/src/OnboardingFlow.tsx +131 -0
  26. package/src/WireOnboarding.tsx +63 -2
  27. package/src/analytics/analyticsEvent.ts +16 -1
  28. package/src/analytics/eventQueue.ts +2 -0
  29. package/src/analytics/reportClientEvent.ts +68 -12
  30. package/src/cards/PermissionCard.tsx +438 -0
  31. package/src/cards/index.ts +7 -0
  32. package/src/illustrations/defaultIllustrations.tsx +44 -3
  33. package/src/index.ts +38 -0
  34. package/src/permissions/index.ts +64 -0
  35. package/src/permissions/permissionCopy.ts +87 -0
  36. package/src/permissions/permissionEvents.ts +76 -0
  37. package/src/permissions/permissionMemory.ts +88 -0
  38. package/src/permissions/placement.ts +88 -0
  39. package/src/permissions/types.ts +131 -0
  40. package/src/session/persistedSession.ts +10 -3
  41. package/src/types.ts +66 -4
@@ -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
+ });
@@ -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,
@@ -16,8 +16,8 @@ import React from "react";
16
16
  import { StyleSheet, Text, View } from "react-native";
17
17
  import { useOnboardingTheme } from "../theme/ThemeContext";
18
18
 
19
- /** A soft tinted disc with a centered glyph the shared frame for every default. */
20
- const Glyph: React.FC<{ children: React.ReactNode }> = ({ children }) => {
19
+ /** The soft tinted disc every default sits in, whether its content is a glyph or a shape. */
20
+ const Disc: React.FC<{ children: React.ReactNode }> = ({ children }) => {
21
21
  const t = useOnboardingTheme();
22
22
  return (
23
23
  <View
@@ -26,16 +26,51 @@ const Glyph: React.FC<{ children: React.ReactNode }> = ({ children }) => {
26
26
  { backgroundColor: t.colors.primarySoft, borderRadius: t.radius.full },
27
27
  ]}
28
28
  >
29
+ {children}
30
+ </View>
31
+ );
32
+ };
33
+
34
+ /** A soft tinted disc with a centered glyph: the shared frame for every text-glyph default. */
35
+ const Glyph: React.FC<{ children: React.ReactNode }> = ({ children }) => {
36
+ const t = useOnboardingTheme();
37
+ return (
38
+ <Disc>
29
39
  <Text style={[styles.glyphText, { color: t.colors.primary }]} allowFontScaling={false}>
30
40
  {children}
31
41
  </Text>
32
- </View>
42
+ </Disc>
33
43
  );
34
44
  };
35
45
 
36
46
  /** Forward-motion / momentum: an upward arrow. */
37
47
  const MomentumGlyph: React.FC = () => <Glyph>{"↗"}</Glyph>;
38
48
 
49
+ /**
50
+ * Notification priming: a bell, DRAWN FROM VIEWS rather than written as an emoji.
51
+ *
52
+ * A color emoji carries its own palette and ignores the `color` its container sets, so a "🔔" here
53
+ * would be the one default illustration that refuses to repaint with the host's theme, sitting
54
+ * next to siblings that do. Three plain Views (a domed body, a rim, a clapper) take
55
+ * `t.colors.primary` directly, and they depend on no glyph being present in the platform font and
56
+ * on no guess about whether it renders as text or as emoji.
57
+ *
58
+ * Keyed by the PERMISSION name, which is what PermissionCard looks up by default, so a
59
+ * notification screen is never a blank box with no host wiring.
60
+ */
61
+ const NotificationsGlyph: React.FC = () => {
62
+ const t = useOnboardingTheme();
63
+ return (
64
+ <Disc>
65
+ <View style={styles.bell}>
66
+ <View style={[styles.bellBody, { backgroundColor: t.colors.primary }]} />
67
+ <View style={[styles.bellRim, { backgroundColor: t.colors.primary }]} />
68
+ <View style={[styles.bellClapper, { backgroundColor: t.colors.primary }]} />
69
+ </View>
70
+ </Disc>
71
+ );
72
+ };
73
+
39
74
  /** Before → after: two states with an arrow between. */
40
75
  const BeforeAfterGlyph: React.FC = () => {
41
76
  const t = useOnboardingTheme();
@@ -67,6 +102,7 @@ const BeforeAfterGlyph: React.FC = () => {
67
102
  export const defaultIllustrations: Record<string, React.ReactNode> = {
68
103
  momentum: <MomentumGlyph />,
69
104
  "before-after": <BeforeAfterGlyph />,
105
+ notifications: <NotificationsGlyph />,
70
106
  };
71
107
 
72
108
  const styles = StyleSheet.create({
@@ -96,4 +132,9 @@ const styles = StyleSheet.create({
96
132
  fontSize: 28,
97
133
  fontWeight: "700",
98
134
  },
135
+ // The bell: a domed body over a wider rim, with the clapper hanging below it.
136
+ bell: { alignItems: "center", justifyContent: "center" },
137
+ bellBody: { width: 34, height: 30, borderTopLeftRadius: 17, borderTopRightRadius: 17 },
138
+ bellRim: { width: 44, height: 5, borderRadius: 3, marginTop: 2 },
139
+ bellClapper: { width: 9, height: 9, borderRadius: 5, marginTop: 3 },
99
140
  });
package/src/index.ts CHANGED
@@ -57,6 +57,10 @@ export {
57
57
  onboardingComponents,
58
58
  } from "./cards";
59
59
  export type { CardOption } from "./cards";
60
+ // The priming screen. NOT in `onboardingComponents` (see the note there): it is host-declared via
61
+ // the `permissionScreens` prop, and registered so a server-emitted placement can adopt it later.
62
+ export { PermissionCard, PermissionCardView, PERMISSION_CARD_NAME } from "./cards";
63
+ export type { PermissionCardProps } from "./cards";
60
64
 
61
65
  // ─── Icons (semantic vocabulary → optional @expo/vector-icons → nothing) ──────
62
66
  // WIRE_ICON_NAMES is the list to paste into the server/AI prompt as the allowed `icon` values.
@@ -243,6 +247,40 @@ export type {
243
247
  RevenueCatSink,
244
248
  } from "./revenuecat";
245
249
 
250
+ // ─── Permission screens (mid-flow priming; the OS dialog only ever on the primary tap) ──
251
+ export {
252
+ WIRE_PERMISSION_EVENTS,
253
+ permissionEventName,
254
+ permissionEventProps,
255
+ normalizePermissionStatus,
256
+ DEFAULT_PERMISSION_PLACEMENT,
257
+ isPermissionDue,
258
+ normalizeAfterCard,
259
+ permissionScreenId,
260
+ selectDuePermissionScreen,
261
+ DEFAULT_PERMISSION_COPY,
262
+ GENERIC_PERMISSION_COPY,
263
+ NOTIFICATIONS_PERMISSION_COPY,
264
+ resolvePermissionCopy,
265
+ clearSettledPermissions,
266
+ loadSettledPermissions,
267
+ permissionStorageKey,
268
+ readSettledPermissions,
269
+ saveSettledPermissions,
270
+ } from "./permissions";
271
+ export type {
272
+ PermissionPlacement,
273
+ PermissionScreenConfig,
274
+ PermissionScreenCopy,
275
+ PermissionStage,
276
+ WirePermissionKind,
277
+ WirePermissionOutcome,
278
+ WirePermissionStatus,
279
+ WirePermissionEventName,
280
+ FlowPosition,
281
+ DuePermissionScreen,
282
+ } from "./permissions";
283
+
246
284
  // ─── Session mapping (one `app.session_started` per app-open → /v1/events) ─────
247
285
  export {
248
286
  reportSessionStart,
@@ -0,0 +1,64 @@
1
+ /**
2
+ * permissions - injectable mid-flow permission screens.
3
+ *
4
+ * Re-exported from the main `@wireai/activation` barrel (no separate subpath), the same way
5
+ * `revenuecat` is: everything here is pure, dependency-free and UI-free, so it costs a consumer
6
+ * that never configures a screen nothing. The screen itself lives in `cards/PermissionCard.tsx`.
7
+ *
8
+ * Adopting it is one prop:
9
+ *
10
+ * <WireOnboarding
11
+ * permissionScreens={[{ permission: "notifications", request: askForNotifications }]}
12
+ * ...
13
+ * />
14
+ *
15
+ * See `types.ts` for why the kit imports no native permission module, and `PermissionCard.tsx` for
16
+ * the priming rule the whole feature exists to enforce.
17
+ */
18
+
19
+ // ─── Canonical permission-funnel names + the pure mappers behind them ─────────
20
+ export {
21
+ WIRE_PERMISSION_EVENTS,
22
+ permissionEventName,
23
+ permissionEventProps,
24
+ normalizePermissionStatus,
25
+ } from "./permissionEvents";
26
+ export type { WirePermissionEventName } from "./permissionEvents";
27
+
28
+ // ─── Placement math (server-driven stream, so a target past the end clamps) ───
29
+ export {
30
+ DEFAULT_PERMISSION_PLACEMENT,
31
+ isPermissionDue,
32
+ normalizeAfterCard,
33
+ permissionScreenId,
34
+ selectDuePermissionScreen,
35
+ } from "./placement";
36
+ export type { FlowPosition, DuePermissionScreen } from "./placement";
37
+
38
+ // ─── Rationale copy (kit defaults + host overrides) ───────────────────────────
39
+ export {
40
+ DEFAULT_PERMISSION_COPY,
41
+ GENERIC_PERMISSION_COPY,
42
+ NOTIFICATIONS_PERMISSION_COPY,
43
+ resolvePermissionCopy,
44
+ } from "./permissionCopy";
45
+
46
+ // ─── Once-only across an app kill (the same host-injected `storage`) ──────────
47
+ export {
48
+ clearSettledPermissions,
49
+ loadSettledPermissions,
50
+ permissionStorageKey,
51
+ readSettledPermissions,
52
+ saveSettledPermissions,
53
+ } from "./permissionMemory";
54
+
55
+ // ─── Types ───────────────────────────────────────────────────────────────────
56
+ export type {
57
+ PermissionPlacement,
58
+ PermissionScreenConfig,
59
+ PermissionScreenCopy,
60
+ PermissionStage,
61
+ WirePermissionKind,
62
+ WirePermissionOutcome,
63
+ WirePermissionStatus,
64
+ } from "./types";