@wireai/activation 0.14.2 → 0.14.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.
- package/CHANGELOG.md +71 -0
- package/README.md +12 -2
- package/dist/coachmarks/index.d.mts +12 -4
- package/dist/coachmarks/index.d.ts +12 -4
- package/dist/coachmarks/index.js +205 -56
- package/dist/coachmarks/index.js.map +1 -1
- package/dist/coachmarks/index.mjs +157 -7
- package/dist/coachmarks/index.mjs.map +1 -1
- package/dist/showcase/index.js +235 -46
- package/dist/showcase/index.js.map +1 -1
- package/dist/showcase/index.mjs +201 -10
- package/dist/showcase/index.mjs.map +1 -1
- package/metro/index.js +16 -5
- package/package.json +1 -1
- package/src/coachmarks/GestureHint.tsx +16 -7
- package/src/coachmarks/SpotlightOverlay.tsx +19 -13
- package/src/coachmarks/index.ts +7 -2
- package/src/coachmarks/reanimated.ts +342 -0
- package/src/showcase/FeatureShowcase.tsx +25 -9
- package/src/showcase/blazejOnboarding.ts +136 -0
- package/src/showcase/index.ts +8 -4
|
@@ -6,7 +6,12 @@ import {
|
|
|
6
6
|
useWindowDimensions,
|
|
7
7
|
View,
|
|
8
8
|
} from "react-native";
|
|
9
|
-
|
|
9
|
+
|
|
10
|
+
import { useOnboardingTheme } from "../theme/ThemeContext";
|
|
11
|
+
import { resolveBlurView } from "./expoBlur";
|
|
12
|
+
import { GestureHint } from "./GestureHint";
|
|
13
|
+
import {
|
|
14
|
+
Animated,
|
|
10
15
|
FadeIn,
|
|
11
16
|
FadeOut,
|
|
12
17
|
useAnimatedProps,
|
|
@@ -16,11 +21,7 @@ import Animated, {
|
|
|
16
21
|
withRepeat,
|
|
17
22
|
withSequence,
|
|
18
23
|
withTiming,
|
|
19
|
-
} from "
|
|
20
|
-
|
|
21
|
-
import { useOnboardingTheme } from "../theme/ThemeContext";
|
|
22
|
-
import { resolveBlurView } from "./expoBlur";
|
|
23
|
-
import { GestureHint } from "./GestureHint";
|
|
24
|
+
} from "./reanimated";
|
|
24
25
|
import type { GestureKind, Placement, TargetRect } from "./types";
|
|
25
26
|
|
|
26
27
|
const BLUR_INTENSITY = 26;
|
|
@@ -55,14 +56,19 @@ export interface SpotlightOverlayProps {
|
|
|
55
56
|
* root (above the tab bar) via CoachmarkOverlayHost.
|
|
56
57
|
*
|
|
57
58
|
* Performance: one BlurView (mounted only while a step is visible), the ring
|
|
58
|
-
* glow is a single
|
|
59
|
-
* Reduce Motion drops the pulse.
|
|
59
|
+
* glow is a single animated view, and with `react-native-reanimated` installed
|
|
60
|
+
* every animation runs on the UI thread. Reduce Motion drops the pulse.
|
|
60
61
|
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
62
|
+
* BOTH native peers are OPTIONAL, and each is resolved through a guarded lazy
|
|
63
|
+
* require, so the subpath never carries a static top-level import of either:
|
|
64
|
+
* • `expo-blur` (`expoBlur.ts`) — absent, the frost drops to an equivalent
|
|
65
|
+
* dimmed scrim. The animated component is built once, on first use.
|
|
66
|
+
* • `react-native-reanimated` (`reanimated.ts`) — absent, the overlay renders
|
|
67
|
+
* STATICALLY at its resting frame: the ring sits at scale 1 (no pulse), the
|
|
68
|
+
* blur at full BLUR_INTENSITY (no fade-in), and the whole overlay appears
|
|
69
|
+
* and leaves without the fade. Nothing moves; everything renders.
|
|
70
|
+
* A missing decoration must never blank a tour, so the ring, tooltip, copy and
|
|
71
|
+
* gestures are identical in all four combinations.
|
|
66
72
|
*/
|
|
67
73
|
const SpotlightOverlayComponent: React.FC<SpotlightOverlayProps> = ({
|
|
68
74
|
message,
|
package/src/coachmarks/index.ts
CHANGED
|
@@ -2,8 +2,13 @@
|
|
|
2
2
|
* @wireai/activation/coachmarks — the performance-first guided-tour engine.
|
|
3
3
|
*
|
|
4
4
|
* Subpath entry, kept OUT of the main barrel so the core kit stays dependency-
|
|
5
|
-
* free
|
|
6
|
-
* `expo-blur
|
|
5
|
+
* free. Importing this pulls in NO native peer: `react-native-reanimated` and
|
|
6
|
+
* `expo-blur` are both OPTIONAL and both reached through guarded lazy requires
|
|
7
|
+
* (`reanimated.ts`, `expoBlur.ts`). Installed, you get UI-thread animation and a
|
|
8
|
+
* frosted spotlight; absent, the tour still renders — statically, and with a
|
|
9
|
+
* plain dimmed scrim instead of the frost. (This doc used to say the subpath
|
|
10
|
+
* "pulls in" both peers, which was true until 0.14.2 / 0.14.3 removed the static
|
|
11
|
+
* imports.) The app declares WHERE things anchor (useCoachmarkAnchor) and
|
|
7
12
|
* WHICH tour plays (useCoachmarkTour); the kit owns the animation, measuring,
|
|
8
13
|
* blur, ring, gesture hand, and one-overlay-at-a-time queue.
|
|
9
14
|
*
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* reanimated — resolve `react-native-reanimated`, lazily and optionally.
|
|
3
|
+
*
|
|
4
|
+
* ── WHY THIS DOES NOT BREAK THE NO-NATIVE-DEPENDENCY POLICY ──────────────────────────────
|
|
5
|
+
*
|
|
6
|
+
* `react-native-reanimated` is an OPTIONAL peer of the coachmarks subpath. `SpotlightOverlay` and
|
|
7
|
+
* `GestureHint` animate with it WHEN it is installed, and fall back to a STATIC render of the same
|
|
8
|
+
* visual when it is not — same ring, same tooltip, same glyph, same copy, same gestures, just
|
|
9
|
+
* without motion. So a host that never installs it can still run the guided tour and the feature
|
|
10
|
+
* showcase, and an Expo Go / Lite build is not forced into a native module it cannot take.
|
|
11
|
+
*
|
|
12
|
+
* Before this file, both components did `import Animated, { … } from "react-native-reanimated"` at
|
|
13
|
+
* module scope, and `coachmarks/index.ts` re-exports them with no wildcard escape — so importing
|
|
14
|
+
* ANYTHING from `@wireai/activation/coachmarks` (even `setCoachmarkStorage`) dragged the peer in,
|
|
15
|
+
* and a host without it hit a Metro resolution failure. `@wireai/activation/showcase` inherited the
|
|
16
|
+
* same edge through `GestureHint`. This resolver removes it. Same class as the 0.14.2 `expo-blur`
|
|
17
|
+
* fix (`expoBlur.ts`), same shape, one size up.
|
|
18
|
+
*
|
|
19
|
+
* ── THE SPECIFIER MUST BE A STRING LITERAL, INSIDE A TRY/CATCH ───────────────────────────
|
|
20
|
+
*
|
|
21
|
+
* This follows `icons/expoIcons.ts` and `coachmarks/expoBlur.ts` exactly, and expoIcons' header
|
|
22
|
+
* carries the full autopsy. In short: Metro collects dependencies STATICALLY, matching a call whose
|
|
23
|
+
* callee is literally the identifier `require` and whose argument is literally a string — an
|
|
24
|
+
* aliased callee (`const req = require; req(name)`) or a variable specifier is collected NOWHERE,
|
|
25
|
+
* so the module never enters the bundle (that shape shipped broken in 0.8.0). And the call sitting
|
|
26
|
+
* inside a TRY/CATCH is literally how Metro marks the dependency `isOptional`, which is what lets
|
|
27
|
+
* an absent peer degrade instead of failing the build. Do NOT "simplify" either half away.
|
|
28
|
+
*
|
|
29
|
+
* ── WHY THE EXPORTS ARE BARE FUNCTIONS WITH THE PEER'S OWN NAMES ─────────────────────────
|
|
30
|
+
*
|
|
31
|
+
* The call sites import `useAnimatedStyle` / `useAnimatedProps` / `withTiming` … from HERE instead
|
|
32
|
+
* of from the peer, and call them unqualified, exactly as before. That is load-bearing, not style:
|
|
33
|
+
* reanimated's Babel plugin decides what to workletize by the CALLEE NAME (`react-native-worklets`
|
|
34
|
+
* `plugin/index.js` → `reanimatedFunctionHooks` / `reanimatedFunctionArgsToWorkletize`; it reads
|
|
35
|
+
* `callee.name`, or `callee.property.name` for a member call, and never checks which module the
|
|
36
|
+
* name came from). Keeping the names identical keeps every worklet in `SpotlightOverlay` and
|
|
37
|
+
* `GestureHint` workletized byte-for-byte on a host that HAS the peer. Rename them and the
|
|
38
|
+
* animations silently stop running on the UI thread.
|
|
39
|
+
*
|
|
40
|
+
* ── THE DEGRADE: THE FINAL FRAME, NOT A DEAD ONE ─────────────────────────────────────────
|
|
41
|
+
*
|
|
42
|
+
* The shim resolves every animation to its RESTING value — `withTiming(to)` is `to`,
|
|
43
|
+
* `withSequence(a, b)` is `b`, `withRepeat(x)` is `x` — so a component lands on the pose it would
|
|
44
|
+
* have settled into anyway (ring at scale 1, glyph at full opacity, blur at BLUR_INTENSITY) rather
|
|
45
|
+
* than on frame zero, which for the blur would have meant a fully transparent overlay. Shared
|
|
46
|
+
* values are state-backed so a write from an effect actually reaches the next render; reanimated's
|
|
47
|
+
* own values do not need this because they drive the UI thread directly. The kit's own
|
|
48
|
+
* AccessibilityInfo-backed `useReducedMotion` stands in for the peer's, so the reduce-motion
|
|
49
|
+
* contract in ai_rules/rules/performance.md §3 holds with or without the peer.
|
|
50
|
+
*
|
|
51
|
+
* This is also what `ai_rules/rules/frequent_rules.md` #1 prescribes: when the kit needs motion
|
|
52
|
+
* without the optional peer, it uses core React Native, never Reanimated.
|
|
53
|
+
*/
|
|
54
|
+
import {
|
|
55
|
+
createElement,
|
|
56
|
+
forwardRef,
|
|
57
|
+
useReducer,
|
|
58
|
+
useRef,
|
|
59
|
+
type ComponentType,
|
|
60
|
+
type ReactNode,
|
|
61
|
+
} from "react";
|
|
62
|
+
import { View, type ViewProps, type ViewStyle } from "react-native";
|
|
63
|
+
|
|
64
|
+
import { useReducedMotion as useOsReducedMotion } from "../motion/useReducedMotion";
|
|
65
|
+
|
|
66
|
+
// Metro injects a module-scoped `require`; it is ABSENT in a pure-ESM runtime (the kit's own tests
|
|
67
|
+
// run under `node --test` as ESM). Declared locally so this type-checks without ambient Node types;
|
|
68
|
+
// the `typeof` guard keeps the reference ESM-safe.
|
|
69
|
+
declare const require: ((id: string) => unknown) | undefined;
|
|
70
|
+
|
|
71
|
+
/** A `require`-like resolver. Injectable in tests; production uses the guarded literal require. */
|
|
72
|
+
export type OptionalRequire = (moduleName: string) => unknown;
|
|
73
|
+
|
|
74
|
+
/** A reanimated shared value, narrowed to the one member the kit uses. */
|
|
75
|
+
export interface SharedValue<T> {
|
|
76
|
+
value: T;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Props `Animated.View` accepts on top of a plain View (layout-animation descriptors). */
|
|
80
|
+
export interface AnimatedViewProps extends ViewProps {
|
|
81
|
+
entering?: unknown;
|
|
82
|
+
exiting?: unknown;
|
|
83
|
+
children?: ReactNode;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** A component built by `createAnimatedComponent`: its own props plus reanimated's driven props. */
|
|
87
|
+
export type AnimatedComponent<P> = ComponentType<P & { animatedProps?: object }>;
|
|
88
|
+
|
|
89
|
+
/** The slice of reanimated the coachmark surfaces actually use. */
|
|
90
|
+
export interface ReanimatedApi {
|
|
91
|
+
/** True when the real peer resolved; false when this is the static fallback. */
|
|
92
|
+
present: boolean;
|
|
93
|
+
View: ComponentType<AnimatedViewProps>;
|
|
94
|
+
createAnimatedComponent: <P extends object>(component: ComponentType<P>) => AnimatedComponent<P>;
|
|
95
|
+
useSharedValue: <T>(initial: T) => SharedValue<T>;
|
|
96
|
+
useAnimatedStyle: (factory: () => ViewStyle) => ViewStyle;
|
|
97
|
+
useAnimatedProps: <P extends object>(factory: () => P) => P;
|
|
98
|
+
useReducedMotion: () => boolean;
|
|
99
|
+
withTiming: (toValue: number, config?: object) => number;
|
|
100
|
+
withRepeat: (animation: number, count?: number, reverse?: boolean) => number;
|
|
101
|
+
withSequence: (...animations: number[]) => number;
|
|
102
|
+
withDelay: (ms: number, animation: number) => number;
|
|
103
|
+
/** The `entering` / `exiting` descriptors, or undefined when the peer is absent. */
|
|
104
|
+
fadeIn: (durationMs: number) => unknown;
|
|
105
|
+
fadeOut: (durationMs: number) => unknown;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The production resolver. The specifier is a LITERAL so Metro collects it (see the header); the
|
|
110
|
+
* `moduleName` parameter exists only to keep the `OptionalRequire` seam shape, so anything other
|
|
111
|
+
* than the one module this file owns resolves to undefined.
|
|
112
|
+
*/
|
|
113
|
+
const runtimeRequire: OptionalRequire = (moduleName) => {
|
|
114
|
+
if (moduleName !== "react-native-reanimated") return undefined;
|
|
115
|
+
if (typeof require !== "function") return undefined;
|
|
116
|
+
try {
|
|
117
|
+
return require("react-native-reanimated");
|
|
118
|
+
} catch {
|
|
119
|
+
return undefined;
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* TEST-ONLY seam. `SpotlightOverlay` / `GestureHint` have frozen public props, so neither can take
|
|
125
|
+
* a `requireModule` the way `WireIcon` does — this lets a component render exercise the PRESENT
|
|
126
|
+
* path. Production never sets it; `runtimeRequire` is the only resolver.
|
|
127
|
+
*/
|
|
128
|
+
let testRequire: OptionalRequire | undefined;
|
|
129
|
+
export const __setReanimatedRequireForTests = (fn: OptionalRequire | undefined): void => {
|
|
130
|
+
testRequire = fn;
|
|
131
|
+
cached = undefined;
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
// ── the static fallback ───────────────────────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* A shared value that re-renders its owner on write. Reanimated's own value drives the UI thread
|
|
138
|
+
* and deliberately does NOT re-render; here the render IS the only output, so a value written from
|
|
139
|
+
* an effect (`intensity.value = withTiming(26)`) has to reach it or the overlay would sit on frame
|
|
140
|
+
* zero forever — a fully transparent blur. The box identity is stable for the component's lifetime,
|
|
141
|
+
* exactly like the real one, so it stays safe in a dependency list.
|
|
142
|
+
*/
|
|
143
|
+
const useStaticSharedValue = <T,>(initial: T): SharedValue<T> => {
|
|
144
|
+
const [, bump] = useReducer((n: number): number => n + 1, 0);
|
|
145
|
+
const box = useRef<SharedValue<T> | null>(null);
|
|
146
|
+
if (box.current === null) {
|
|
147
|
+
let current = initial;
|
|
148
|
+
box.current = {
|
|
149
|
+
get value(): T {
|
|
150
|
+
return current;
|
|
151
|
+
},
|
|
152
|
+
set value(next: T) {
|
|
153
|
+
if (Object.is(next, current)) return;
|
|
154
|
+
current = next;
|
|
155
|
+
bump();
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
return box.current;
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
/** Run a worklet body on the JS side. It must never throw into the render — degrade, don't crash. */
|
|
163
|
+
const runWorklet = <R extends object>(factory: () => R, fallback: R): R => {
|
|
164
|
+
try {
|
|
165
|
+
return factory() ?? fallback;
|
|
166
|
+
} catch {
|
|
167
|
+
return fallback;
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
/** `Animated.View` without the peer: a plain View, with the layout-animation props dropped. */
|
|
172
|
+
const StaticAnimatedView = forwardRef<View, AnimatedViewProps>(function StaticAnimatedView(
|
|
173
|
+
{ entering: _entering, exiting: _exiting, children, ...rest },
|
|
174
|
+
ref,
|
|
175
|
+
) {
|
|
176
|
+
return createElement(View, { ...rest, ref }, children);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* `createAnimatedComponent` without the peer. The wrapper MERGES `animatedProps` into the real
|
|
181
|
+
* props, because that object is the only carrier for values the worklet would have driven (the
|
|
182
|
+
* spotlight's blur `intensity`). Built once per wrapped component at the call site's `useMemo`, so
|
|
183
|
+
* the returned type identity is stable and React never remounts the subtree.
|
|
184
|
+
*/
|
|
185
|
+
const staticCreateAnimatedComponent = <P extends object>(
|
|
186
|
+
component: ComponentType<P>,
|
|
187
|
+
): AnimatedComponent<P> =>
|
|
188
|
+
function StaticAnimatedComponent({ animatedProps, ...rest }) {
|
|
189
|
+
return createElement(component, { ...(rest as P), ...(animatedProps as object) });
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* The peer-absent API. Every animation resolves to its RESTING value, so components render the
|
|
194
|
+
* frame they would have settled on. Nothing here schedules work, allocates a native module, or
|
|
195
|
+
* throws.
|
|
196
|
+
*/
|
|
197
|
+
const staticApi: ReanimatedApi = {
|
|
198
|
+
present: false,
|
|
199
|
+
View: StaticAnimatedView,
|
|
200
|
+
createAnimatedComponent: staticCreateAnimatedComponent,
|
|
201
|
+
useSharedValue: useStaticSharedValue,
|
|
202
|
+
useAnimatedStyle: (factory) => runWorklet(factory, {}),
|
|
203
|
+
useAnimatedProps: (factory) => runWorklet(factory, {} as never),
|
|
204
|
+
useReducedMotion: useOsReducedMotion,
|
|
205
|
+
withTiming: (toValue) => toValue,
|
|
206
|
+
withRepeat: (animation) => animation,
|
|
207
|
+
withSequence: (...animations) => animations[animations.length - 1] ?? 0,
|
|
208
|
+
withDelay: (_ms, animation) => animation,
|
|
209
|
+
fadeIn: () => undefined,
|
|
210
|
+
fadeOut: () => undefined,
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
// ── the real peer ─────────────────────────────────────────────────────────────
|
|
214
|
+
|
|
215
|
+
const isFunction = (value: unknown): boolean => typeof value === "function";
|
|
216
|
+
|
|
217
|
+
/** A React component is a function, or an object carrying `$$typeof` (memo / forwardRef). */
|
|
218
|
+
const isComponent = (value: unknown): boolean =>
|
|
219
|
+
typeof value === "function" || (!!value && typeof value === "object" && "$$typeof" in value);
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Build the API from a resolved module namespace, or undefined when it is not the module we expect.
|
|
223
|
+
*
|
|
224
|
+
* The hooks are NAMED exports; `View` and `createAnimatedComponent` live on the DEFAULT export (the
|
|
225
|
+
* `Animated` object). A CJS interop wrapper puts everything one level down under `default`, so both
|
|
226
|
+
* are looked up on the namespace first and the default second. EVERY member is verified before the
|
|
227
|
+
* namespace is accepted: a partial module must degrade to the static fallback, never crash halfway
|
|
228
|
+
* through a render.
|
|
229
|
+
*/
|
|
230
|
+
const fromModule = (mod: unknown): ReanimatedApi | undefined => {
|
|
231
|
+
if (!mod || typeof mod !== "object") return undefined;
|
|
232
|
+
const ns = mod as Record<string, unknown>;
|
|
233
|
+
const def =
|
|
234
|
+
ns.default && typeof ns.default === "object"
|
|
235
|
+
? (ns.default as Record<string, unknown>)
|
|
236
|
+
: undefined;
|
|
237
|
+
const pick = (name: string): unknown => ns[name] ?? def?.[name];
|
|
238
|
+
|
|
239
|
+
const view = def?.View ?? ns.View;
|
|
240
|
+
const createAnimated = def?.createAnimatedComponent ?? ns.createAnimatedComponent;
|
|
241
|
+
const hooks = {
|
|
242
|
+
useSharedValue: pick("useSharedValue"),
|
|
243
|
+
useAnimatedStyle: pick("useAnimatedStyle"),
|
|
244
|
+
useAnimatedProps: pick("useAnimatedProps"),
|
|
245
|
+
useReducedMotion: pick("useReducedMotion"),
|
|
246
|
+
withTiming: pick("withTiming"),
|
|
247
|
+
withRepeat: pick("withRepeat"),
|
|
248
|
+
withSequence: pick("withSequence"),
|
|
249
|
+
withDelay: pick("withDelay"),
|
|
250
|
+
};
|
|
251
|
+
const fadeIn = pick("FadeIn");
|
|
252
|
+
const fadeOut = pick("FadeOut");
|
|
253
|
+
|
|
254
|
+
if (!isComponent(view) || !isFunction(createAnimated)) return undefined;
|
|
255
|
+
if (Object.values(hooks).some((member) => !isFunction(member))) return undefined;
|
|
256
|
+
if (!fadeIn || !fadeOut) return undefined;
|
|
257
|
+
|
|
258
|
+
// The single narrowing seam. Everything above proved the members exist and are callable; the peer
|
|
259
|
+
// is untyped here (it is resolved through `unknown`), so the cast happens once, at the boundary,
|
|
260
|
+
// and the exported signatures above are the contract from here on.
|
|
261
|
+
const api = {
|
|
262
|
+
present: true,
|
|
263
|
+
View: view,
|
|
264
|
+
createAnimatedComponent: createAnimated,
|
|
265
|
+
...hooks,
|
|
266
|
+
fadeIn: (durationMs: number) => (fadeIn as { duration: (ms: number) => unknown }).duration(durationMs),
|
|
267
|
+
fadeOut: (durationMs: number) => (fadeOut as { duration: (ms: number) => unknown }).duration(durationMs),
|
|
268
|
+
} as unknown as ReanimatedApi;
|
|
269
|
+
return api;
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Module-level memo. Holds the RESOLVED api (real or static), so an absent peer costs exactly one
|
|
274
|
+
* failed require per process, not one per mount — and, more importantly, so the implementation can
|
|
275
|
+
* never change between two renders of the same component, which would break the rules of hooks.
|
|
276
|
+
*/
|
|
277
|
+
let cached: ReanimatedApi | undefined;
|
|
278
|
+
|
|
279
|
+
/** Reset the memo. TEST-ONLY seam — production never calls it. */
|
|
280
|
+
export const resetReanimatedModuleCache = (): void => {
|
|
281
|
+
cached = undefined;
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Resolve the reanimated API, or the static fallback when the peer is absent/unresolvable.
|
|
286
|
+
* Never throws and never returns undefined: an absent animation library must degrade to a still
|
|
287
|
+
* frame, never break the tour.
|
|
288
|
+
*/
|
|
289
|
+
export const resolveReanimated = (
|
|
290
|
+
requireModule: OptionalRequire = testRequire ?? runtimeRequire,
|
|
291
|
+
): ReanimatedApi => {
|
|
292
|
+
try {
|
|
293
|
+
if (cached === undefined || requireModule !== runtimeRequire) {
|
|
294
|
+
const resolved = fromModule(requireModule("react-native-reanimated")) ?? staticApi;
|
|
295
|
+
// Don't poison the module memo from an injected test require.
|
|
296
|
+
if (requireModule === runtimeRequire) cached = resolved;
|
|
297
|
+
return resolved;
|
|
298
|
+
}
|
|
299
|
+
return cached;
|
|
300
|
+
} catch {
|
|
301
|
+
return staticApi;
|
|
302
|
+
}
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
// ── the call-site surface: the peer's own names, resolved lazily ──────────────
|
|
306
|
+
//
|
|
307
|
+
// Each one delegates on every call, so nothing is resolved at import time. `api()` is memoized, so
|
|
308
|
+
// the delegation is a property read.
|
|
309
|
+
|
|
310
|
+
const api = (): ReanimatedApi => resolveReanimated();
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* The `Animated` namespace, with `View` behind a getter so merely importing this module never
|
|
314
|
+
* touches the peer. The component identity it returns is stable (the peer's own `Animated.View`, or
|
|
315
|
+
* the module-level `StaticAnimatedView`), so React never remounts the tree under it.
|
|
316
|
+
*/
|
|
317
|
+
export const Animated: {
|
|
318
|
+
readonly View: ComponentType<AnimatedViewProps>;
|
|
319
|
+
createAnimatedComponent: <P extends object>(component: ComponentType<P>) => AnimatedComponent<P>;
|
|
320
|
+
} = {
|
|
321
|
+
get View() {
|
|
322
|
+
return api().View;
|
|
323
|
+
},
|
|
324
|
+
createAnimatedComponent: (component) => api().createAnimatedComponent(component),
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
/** Layout-animation descriptors. `.duration()` is the only builder the kit uses. */
|
|
328
|
+
export const FadeIn = { duration: (durationMs: number): unknown => api().fadeIn(durationMs) };
|
|
329
|
+
export const FadeOut = { duration: (durationMs: number): unknown => api().fadeOut(durationMs) };
|
|
330
|
+
|
|
331
|
+
export const useSharedValue = <T,>(initial: T): SharedValue<T> => api().useSharedValue(initial);
|
|
332
|
+
export const useAnimatedStyle = (factory: () => ViewStyle): ViewStyle =>
|
|
333
|
+
api().useAnimatedStyle(factory);
|
|
334
|
+
export const useAnimatedProps = <P extends object>(factory: () => P): P =>
|
|
335
|
+
api().useAnimatedProps(factory);
|
|
336
|
+
export const useReducedMotion = (): boolean => api().useReducedMotion();
|
|
337
|
+
export const withTiming = (toValue: number, config?: object): number =>
|
|
338
|
+
api().withTiming(toValue, config);
|
|
339
|
+
export const withRepeat = (animation: number, count?: number, reverse?: boolean): number =>
|
|
340
|
+
api().withRepeat(animation, count, reverse);
|
|
341
|
+
export const withSequence = (...animations: number[]): number => api().withSequence(...animations);
|
|
342
|
+
export const withDelay = (ms: number, animation: number): number => api().withDelay(ms, animation);
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
1
|
+
import type {
|
|
2
|
+
OnboardingColors,
|
|
3
|
+
OnboardingFonts,
|
|
4
|
+
OnboardingProps,
|
|
5
|
+
OnboardingStep,
|
|
6
6
|
} from "@blazejkustra/react-native-onboarding";
|
|
7
7
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
8
8
|
import { type ImageSourcePropType, StyleSheet, View } from "react-native";
|
|
@@ -12,6 +12,7 @@ import { hasSeenGate, markSeenGate, showcaseGateKey } from "../coachmarks/runtim
|
|
|
12
12
|
import { useResolvedFeatures } from "../features/WireFeaturesProvider";
|
|
13
13
|
import { useOnboardingTheme } from "../theme/ThemeContext";
|
|
14
14
|
import type { OnboardingTheme } from "../theme/types";
|
|
15
|
+
import { resolveShowcaseOnboarding } from "./blazejOnboarding";
|
|
15
16
|
import { showcaseColorsFromTheme, showcasePanelBackground } from "./showcaseColors";
|
|
16
17
|
import type { FeatureShowcaseProps } from "./types";
|
|
17
18
|
|
|
@@ -47,6 +48,13 @@ const mergeThemeOver = (
|
|
|
47
48
|
*
|
|
48
49
|
* When the gate says "seen", it renders nothing and calls `onDone` from an
|
|
49
50
|
* effect (never during render).
|
|
51
|
+
*
|
|
52
|
+
* The underlying package is an OPTIONAL peer, resolved through the guarded lazy
|
|
53
|
+
* require in `blazejOnboarding.ts` rather than a static import, so the showcase
|
|
54
|
+
* subpath builds on a host that never installed it. Absent, this takes the SAME
|
|
55
|
+
* path as the kill switch — render null, call `onDone`, write no gate — so the
|
|
56
|
+
* host's flow always advances and the showcase still plays once if the peer is
|
|
57
|
+
* added later.
|
|
50
58
|
*/
|
|
51
59
|
const _FeatureShowcase: React.FC<FeatureShowcaseProps> = ({
|
|
52
60
|
config,
|
|
@@ -71,14 +79,20 @@ const _FeatureShowcase: React.FC<FeatureShowcaseProps> = ({
|
|
|
71
79
|
const flags = useResolvedFeatures({ flags: features, config: featuresConfig });
|
|
72
80
|
const disabled = !flags.showcase.enabled;
|
|
73
81
|
|
|
82
|
+
// The optional pager peer. Resolved once, on first render, instead of at module scope — so
|
|
83
|
+
// merely importing this file (and thus the showcase subpath) never needs it. Absent → treated
|
|
84
|
+
// exactly like the kill switch below: no render, no gate write, `onDone` from the effect.
|
|
85
|
+
const Onboarding = useMemo(() => resolveShowcaseOnboarding(), []);
|
|
86
|
+
|
|
74
87
|
const gateKey = showcaseGateKey(config.id);
|
|
75
88
|
const seen = useMemo(
|
|
76
89
|
() => hasSeenGate(gateKey, storage, isTesting),
|
|
77
90
|
[gateKey, storage, isTesting],
|
|
78
91
|
);
|
|
79
|
-
//
|
|
80
|
-
// gate write (in `finish`); the
|
|
81
|
-
|
|
92
|
+
// Already-seen, feature-disabled OR the peer missing short-circuits the showcase. Only the seen
|
|
93
|
+
// path is a gate write (in `finish`); the other two never persist, so the showcase replays once
|
|
94
|
+
// the feature is re-enabled or the peer is installed.
|
|
95
|
+
const skip = seen || disabled || !Onboarding;
|
|
82
96
|
|
|
83
97
|
const doneRef = useRef(false);
|
|
84
98
|
const finish = useCallback(() => {
|
|
@@ -177,7 +191,9 @@ const _FeatureShowcase: React.FC<FeatureShowcaseProps> = ({
|
|
|
177
191
|
[t],
|
|
178
192
|
);
|
|
179
193
|
|
|
180
|
-
|
|
194
|
+
// `!Onboarding` is already folded into `skip`; it is repeated here so the narrowing is explicit
|
|
195
|
+
// to the reader and to the compiler at the JSX below.
|
|
196
|
+
if (skip || !Onboarding) return null;
|
|
181
197
|
|
|
182
198
|
const activeGesture = config.slides[activeIndex]?.gesture;
|
|
183
199
|
// Same rule as the coachmark path: tap / double-tap slides show no glyph.
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* blazejOnboarding — resolve `@blazejkustra/react-native-onboarding`, lazily and optionally.
|
|
3
|
+
*
|
|
4
|
+
* ── WHY THIS DOES NOT BREAK THE NO-NATIVE-DEPENDENCY POLICY ──────────────────────────────
|
|
5
|
+
*
|
|
6
|
+
* `@blazejkustra/react-native-onboarding` is an OPTIONAL peer of the showcase subpath — it is the
|
|
7
|
+
* slide pager `FeatureShowcase` wraps. Before this file, `FeatureShowcase.tsx` imported its default
|
|
8
|
+
* export at module scope, and `showcase/index.ts` re-exports the component with no wildcard escape,
|
|
9
|
+
* so importing ANYTHING from `@wireai/activation/showcase` (even `selectShowcaseSlides`) dragged
|
|
10
|
+
* the peer in, and a host that skipped the "optional" peer hit a Metro resolution failure. This
|
|
11
|
+
* resolver removes that static edge. Same class and same shape as the 0.14.2 `expo-blur` fix
|
|
12
|
+
* (`coachmarks/expoBlur.ts`), whose header carries the reasoning in full.
|
|
13
|
+
*
|
|
14
|
+
* The TYPES stay statically imported on purpose: a type-only import is erased by every toolchain
|
|
15
|
+
* (babel, esbuild and sucrase all drop it), so it creates no runtime edge and no bundle dependency,
|
|
16
|
+
* and `showcaseColors.ts` already relies on exactly that.
|
|
17
|
+
*
|
|
18
|
+
* ── THE SPECIFIER MUST BE A STRING LITERAL, INSIDE A TRY/CATCH ───────────────────────────
|
|
19
|
+
*
|
|
20
|
+
* Metro collects dependencies statically and matches only a call whose callee is literally the
|
|
21
|
+
* identifier `require` and whose argument is literally a string; the try/catch around it is what
|
|
22
|
+
* marks the dependency `isOptional`. Both halves are load-bearing — see `icons/expoIcons.ts` for
|
|
23
|
+
* the full autopsy of the 0.8.0 shape that aliased the callee and was collected nowhere.
|
|
24
|
+
*
|
|
25
|
+
* ── THE DEGRADE ─────────────────────────────────────────────────────────────────────────
|
|
26
|
+
*
|
|
27
|
+
* Absent → `FeatureShowcase` renders null and calls `onDone` from an effect, which is the SAME path
|
|
28
|
+
* the feature kill switch already takes. The host's flow always advances (a showcase that cannot
|
|
29
|
+
* render must never strand the user on a blank screen), and the seen-gate is deliberately NOT
|
|
30
|
+
* written, so installing the peer later still plays the showcase once.
|
|
31
|
+
*/
|
|
32
|
+
import type { ComponentType } from "react";
|
|
33
|
+
|
|
34
|
+
import type { OnboardingProps } from "@blazejkustra/react-native-onboarding";
|
|
35
|
+
|
|
36
|
+
// Metro injects a module-scoped `require`; it is ABSENT in a pure-ESM runtime (the kit's own tests
|
|
37
|
+
// run under `node --test` as ESM). Declared locally so this type-checks without ambient Node types;
|
|
38
|
+
// the `typeof` guard keeps the reference ESM-safe.
|
|
39
|
+
declare const require: ((id: string) => unknown) | undefined;
|
|
40
|
+
|
|
41
|
+
/** A `require`-like resolver. Injectable in tests; production uses the guarded literal require. */
|
|
42
|
+
export type OptionalRequire = (moduleName: string) => unknown;
|
|
43
|
+
|
|
44
|
+
/** The pager component `FeatureShowcase` wraps. */
|
|
45
|
+
export type OnboardingComponent = ComponentType<OnboardingProps>;
|
|
46
|
+
|
|
47
|
+
const MODULE_NAME = "@blazejkustra/react-native-onboarding";
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The production resolver. The specifier is a LITERAL so Metro collects it (see the header); the
|
|
51
|
+
* `moduleName` parameter exists only to keep the `OptionalRequire` seam shape, so anything other
|
|
52
|
+
* than the one module this file owns resolves to undefined.
|
|
53
|
+
*/
|
|
54
|
+
const runtimeRequire: OptionalRequire = (moduleName) => {
|
|
55
|
+
if (moduleName !== MODULE_NAME) return undefined;
|
|
56
|
+
if (typeof require !== "function") return undefined;
|
|
57
|
+
try {
|
|
58
|
+
return require("@blazejkustra/react-native-onboarding");
|
|
59
|
+
} catch {
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* TEST-ONLY seam. `FeatureShowcase`'s public props are frozen, so it cannot take a `requireModule`
|
|
66
|
+
* the way `WireIcon` does — this lets a component render exercise the PRESENT path without the peer
|
|
67
|
+
* installed. Production never sets it; `runtimeRequire` is the only resolver.
|
|
68
|
+
*/
|
|
69
|
+
let testRequire: OptionalRequire | undefined;
|
|
70
|
+
export const __setShowcaseOnboardingRequireForTests = (fn: OptionalRequire | undefined): void => {
|
|
71
|
+
testRequire = fn;
|
|
72
|
+
cached = undefined;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Read the component off the module. The package ships it as a DEFAULT export; a CJS build may put
|
|
77
|
+
* the component directly on `module.exports` instead, so both shapes are accepted.
|
|
78
|
+
*/
|
|
79
|
+
const interop = (mod: unknown): unknown => {
|
|
80
|
+
if (typeof mod === "function") return mod;
|
|
81
|
+
if (!mod || typeof mod !== "object") return undefined;
|
|
82
|
+
const ns = mod as Record<string, unknown>;
|
|
83
|
+
if (ns.default) return ns.default;
|
|
84
|
+
// `module.exports = forwardRef(...)`: the component IS the namespace, and it is an object rather
|
|
85
|
+
// than a function, so it would slip past the check above.
|
|
86
|
+
if ("$$typeof" in ns) return ns;
|
|
87
|
+
return undefined;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Narrow an unknown export to something mountable: a real component (function or class) or a
|
|
92
|
+
* React.memo / forwardRef wrapper (an object carrying `$$typeof`). Reject anything else rather than
|
|
93
|
+
* handing the reconciler a non-component.
|
|
94
|
+
*/
|
|
95
|
+
const asComponent = (value: unknown): OnboardingComponent | undefined => {
|
|
96
|
+
if (typeof value === "function") return value as OnboardingComponent;
|
|
97
|
+
if (value && typeof value === "object" && "$$typeof" in (value as object)) {
|
|
98
|
+
return value as OnboardingComponent;
|
|
99
|
+
}
|
|
100
|
+
return undefined;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Module-level memo. `null` = "we looked and it is not there" (distinct from "not looked yet"), so
|
|
105
|
+
* an absent peer costs exactly one failed require per process, not one per showcase mount.
|
|
106
|
+
*/
|
|
107
|
+
let cached: OnboardingComponent | null | undefined;
|
|
108
|
+
|
|
109
|
+
/** Reset the memo. TEST-ONLY seam — production never calls it. */
|
|
110
|
+
export const resetShowcaseOnboardingCache = (): void => {
|
|
111
|
+
cached = undefined;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Resolve the pager component, or undefined when the peer is absent/unresolvable. Never throws: an
|
|
116
|
+
* absent showcase must advance the host's flow, never break it.
|
|
117
|
+
*
|
|
118
|
+
* `requireModule` is injectable so tests can exercise BOTH the found and absent paths without
|
|
119
|
+
* installing the peer (same convention as `resolveBlurView` / `resolveIconFamily`).
|
|
120
|
+
*/
|
|
121
|
+
export const resolveShowcaseOnboarding = (
|
|
122
|
+
requireModule: OptionalRequire = testRequire ?? runtimeRequire,
|
|
123
|
+
): OnboardingComponent | undefined => {
|
|
124
|
+
try {
|
|
125
|
+
if (cached === undefined || requireModule !== runtimeRequire) {
|
|
126
|
+
const component = asComponent(interop(requireModule(MODULE_NAME)));
|
|
127
|
+
// Don't poison the module memo from an injected test require.
|
|
128
|
+
if (requireModule === runtimeRequire) cached = component ?? null;
|
|
129
|
+
return component;
|
|
130
|
+
}
|
|
131
|
+
if (cached === null) return undefined;
|
|
132
|
+
return cached;
|
|
133
|
+
} catch {
|
|
134
|
+
return undefined;
|
|
135
|
+
}
|
|
136
|
+
};
|
package/src/showcase/index.ts
CHANGED
|
@@ -2,10 +2,14 @@
|
|
|
2
2
|
* @wireai/activation/showcase — the pre-onboarding feature showcase ("app intro").
|
|
3
3
|
*
|
|
4
4
|
* Subpath entry, kept OUT of the main barrel so the core kit stays dependency-
|
|
5
|
-
* free
|
|
6
|
-
* `@blazejkustra/react-native-onboarding`
|
|
7
|
-
* `
|
|
8
|
-
*
|
|
5
|
+
* free. Importing this pulls in NO native peer (0.14.3): the pager
|
|
6
|
+
* `@blazejkustra/react-native-onboarding` is reached through the guarded lazy
|
|
7
|
+
* require in `blazejOnboarding.ts`, and the transitive edge through GestureHint
|
|
8
|
+
* to `react-native-reanimated` is guarded the same way in `coachmarks/reanimated.ts`.
|
|
9
|
+
* Without the pager the showcase renders null and calls `onDone` so the host's
|
|
10
|
+
* flow advances; without reanimated the gesture glyph renders static. The app
|
|
11
|
+
* supplies a declarative ShowcaseConfig; the kit bakes in the Wire theme +
|
|
12
|
+
* optional per-slide gesture hand and gates once.
|
|
9
13
|
*
|
|
10
14
|
* import { FeatureShowcase, selectShowcaseSlides } from "@wireai/activation/showcase";
|
|
11
15
|
*/
|