@wireai/activation 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +73 -0
- package/CHANGELOG.md +572 -0
- package/INTEGRATION_PROMPT.md +55 -0
- package/LICENSE +21 -0
- package/README.md +923 -0
- package/dist/CenteredModal-C3qQBHsA.d.mts +38 -0
- package/dist/CenteredModal-Cdgns6--.d.ts +38 -0
- package/dist/coachmarks/index.d.mts +352 -0
- package/dist/coachmarks/index.d.ts +352 -0
- package/dist/coachmarks/index.js +944 -0
- package/dist/coachmarks/index.js.map +1 -0
- package/dist/coachmarks/index.mjs +918 -0
- package/dist/coachmarks/index.mjs.map +1 -0
- package/dist/decision-CFvGY6nP.d.ts +228 -0
- package/dist/decision-Cl8OFYzu.d.mts +228 -0
- package/dist/index.d.mts +1390 -0
- package/dist/index.d.ts +1390 -0
- package/dist/index.js +3260 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +3186 -0
- package/dist/index.mjs.map +1 -0
- package/dist/questionnaire/index.d.mts +249 -0
- package/dist/questionnaire/index.d.ts +249 -0
- package/dist/questionnaire/index.js +922 -0
- package/dist/questionnaire/index.js.map +1 -0
- package/dist/questionnaire/index.mjs +906 -0
- package/dist/questionnaire/index.mjs.map +1 -0
- package/dist/reviews/index.d.mts +213 -0
- package/dist/reviews/index.d.ts +213 -0
- package/dist/reviews/index.js +1001 -0
- package/dist/reviews/index.js.map +1 -0
- package/dist/reviews/index.mjs +971 -0
- package/dist/reviews/index.mjs.map +1 -0
- package/dist/showcase/index.d.mts +119 -0
- package/dist/showcase/index.d.ts +119 -0
- package/dist/showcase/index.js +660 -0
- package/dist/showcase/index.js.map +1 -0
- package/dist/showcase/index.mjs +651 -0
- package/dist/showcase/index.mjs.map +1 -0
- package/dist/types-BKfpdZzX.d.mts +84 -0
- package/dist/types-BKfpdZzX.d.ts +84 -0
- package/dist/types-CMuOexw0.d.mts +116 -0
- package/dist/types-CMuOexw0.d.ts +116 -0
- package/dist/types-GL_hQ0TN.d.mts +46 -0
- package/dist/types-GL_hQ0TN.d.ts +46 -0
- package/llms.txt +36 -0
- package/metro/index.d.ts +32 -0
- package/metro/index.js +83 -0
- package/package.json +171 -0
- package/src/DemoOnboarding.tsx +133 -0
- package/src/OnboardingFlow.tsx +504 -0
- package/src/WireOnboarding.tsx +248 -0
- package/src/analytics/analyticsEvent.ts +69 -0
- package/src/analytics/reportClientEvent.ts +119 -0
- package/src/analytics/sendPreview.ts +73 -0
- package/src/attribution/attribution.ts +46 -0
- package/src/cards/ChipSelectCard.tsx +265 -0
- package/src/cards/InterstitialCard.tsx +299 -0
- package/src/cards/NumberStepperCard.tsx +121 -0
- package/src/cards/SelectionCard.tsx +270 -0
- package/src/cards/StatusCard.tsx +170 -0
- package/src/cards/TextInputCard.tsx +147 -0
- package/src/cards/index.ts +23 -0
- package/src/coachmarks/CoachmarkOverlayHost.tsx +44 -0
- package/src/coachmarks/CoachmarkProvider.tsx +108 -0
- package/src/coachmarks/GestureHint.tsx +331 -0
- package/src/coachmarks/SpotlightOverlay.tsx +254 -0
- package/src/coachmarks/coachmarkAnchorRegistry.ts +70 -0
- package/src/coachmarks/coachmarkOverlayStore.ts +63 -0
- package/src/coachmarks/index.ts +62 -0
- package/src/coachmarks/runtime.ts +103 -0
- package/src/coachmarks/selectTourSteps.ts +45 -0
- package/src/coachmarks/types.ts +58 -0
- package/src/coachmarks/useCoachmarkAnchor.ts +49 -0
- package/src/coachmarks/useCoachmarkTour.ts +200 -0
- package/src/components/AnimatedSparkle.tsx +101 -0
- package/src/components/Button.tsx +129 -0
- package/src/components/CardHandoff.tsx +212 -0
- package/src/components/CardLayout.tsx +103 -0
- package/src/components/CenteredModal.tsx +184 -0
- package/src/components/CompletionView.tsx +186 -0
- package/src/components/DoneBlock.tsx +37 -0
- package/src/components/ErrorBlock.tsx +42 -0
- package/src/components/Illustration.tsx +36 -0
- package/src/components/LoadingBlock.tsx +182 -0
- package/src/components/LoadingScreen.tsx +35 -0
- package/src/components/OnboardingScaffold.tsx +105 -0
- package/src/components/StepProgress.tsx +85 -0
- package/src/components/loaderChrome.ts +28 -0
- package/src/config/onboardingFlag.ts +39 -0
- package/src/config/wireConfigFromEnv.ts +52 -0
- package/src/device/deviceContext.ts +158 -0
- package/src/features/WireFeaturesProvider.tsx +81 -0
- package/src/features/cache.ts +92 -0
- package/src/features/defaults.ts +67 -0
- package/src/features/fetchWireFeatures.ts +86 -0
- package/src/features/index.ts +39 -0
- package/src/features/types.ts +61 -0
- package/src/features/useWireFeatures.ts +57 -0
- package/src/identity/userIdentity.ts +100 -0
- package/src/illustrations/defaultIllustrations.tsx +99 -0
- package/src/index.ts +159 -0
- package/src/motion/centeredModalMotion.ts +45 -0
- package/src/motion/motionSpec.ts +133 -0
- package/src/motion/useReducedMotion.ts +43 -0
- package/src/questionnaire/QuestionnaireGate.tsx +235 -0
- package/src/questionnaire/decision.ts +63 -0
- package/src/questionnaire/index.ts +54 -0
- package/src/questionnaire/runtime.ts +20 -0
- package/src/questionnaire/transport.ts +83 -0
- package/src/questionnaire/types.ts +182 -0
- package/src/questionnaire/useQuestionnaireGate.ts +159 -0
- package/src/reviews/ReviewGate.tsx +316 -0
- package/src/reviews/ReviewModal.tsx +13 -0
- package/src/reviews/decision.ts +120 -0
- package/src/reviews/equality.ts +54 -0
- package/src/reviews/expo-store-review.d.ts +16 -0
- package/src/reviews/feedbackForm.ts +41 -0
- package/src/reviews/index.ts +78 -0
- package/src/reviews/presentation.ts +29 -0
- package/src/reviews/runtime.ts +45 -0
- package/src/reviews/storeReview.ts +62 -0
- package/src/reviews/transport.ts +89 -0
- package/src/reviews/types.ts +172 -0
- package/src/reviews/useReviewGate.ts +137 -0
- package/src/session/persistedSession.ts +143 -0
- package/src/session-analytics/index.ts +18 -0
- package/src/session-analytics/reportSessionStart.ts +144 -0
- package/src/session-analytics/useSessionStart.ts +114 -0
- package/src/showcase/FeatureShowcase.tsx +233 -0
- package/src/showcase/index.ts +19 -0
- package/src/showcase/selectShowcaseSlides.ts +45 -0
- package/src/showcase/showcaseColors.ts +91 -0
- package/src/showcase/types.ts +74 -0
- package/src/theme/ThemeContext.tsx +38 -0
- package/src/theme/defaultTheme.ts +70 -0
- package/src/theme/themeFromBrand.ts +93 -0
- package/src/theme/types.ts +88 -0
- package/src/theme/typography.ts +46 -0
- package/src/types.ts +215 -0
- package/src/utils/deriveAnswers.ts +43 -0
- package/src/utils/readProgress.ts +33 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AnimatedSparkle — the themed "AI is thinking" glyph (✦), animated with RN's
|
|
3
|
+
* built-in `Animated` (native driver) so the kit needs neither reanimated nor a
|
|
4
|
+
* vector-icon dependency.
|
|
5
|
+
*
|
|
6
|
+
* Variants:
|
|
7
|
+
* - "spin" (default, unchanged): continuous rotation — the original loader.
|
|
8
|
+
* - "pulse": the Wire Activation loading treatment — the mark breathes
|
|
9
|
+
* (opacity 0.35 ↔ 1, 1600ms loop) instead of rotating.
|
|
10
|
+
*
|
|
11
|
+
* Reduce motion: no loop at all — the glyph renders static at full opacity
|
|
12
|
+
* (final frame), per design-refs/EXTRACTED-SPEC.md row 17.
|
|
13
|
+
*/
|
|
14
|
+
import React, { useEffect, useRef } from "react";
|
|
15
|
+
import { Animated, Easing, StyleSheet, Text } from "react-native";
|
|
16
|
+
import { useOnboardingTheme } from "../theme/ThemeContext";
|
|
17
|
+
import { LOADER_PULSE_MIN_OPACITY, LOADER_PULSE_MS, loopEnabled, scaledMs } from "../motion/motionSpec";
|
|
18
|
+
import { useReducedMotion } from "../motion/useReducedMotion";
|
|
19
|
+
|
|
20
|
+
const ROTATION_MS = 1800;
|
|
21
|
+
|
|
22
|
+
export type AnimatedSparkleProps = {
|
|
23
|
+
/** Glyph size in px. */
|
|
24
|
+
size?: number;
|
|
25
|
+
/** Override color; defaults to the theme primary. */
|
|
26
|
+
color?: string;
|
|
27
|
+
/** "spin" rotates (default, the original), "pulse" breathes (the loader treatment). */
|
|
28
|
+
variant?: "spin" | "pulse";
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const _AnimatedSparkle: React.FC<AnimatedSparkleProps> = ({ size = 30, color, variant = "spin" }) => {
|
|
32
|
+
const t = useOnboardingTheme();
|
|
33
|
+
const reduced = useReducedMotion();
|
|
34
|
+
const progress = useRef(new Animated.Value(0)).current;
|
|
35
|
+
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
if (!loopEnabled(reduced)) {
|
|
38
|
+
// Static final frame: no rotation, full opacity.
|
|
39
|
+
progress.setValue(0);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
const loop =
|
|
43
|
+
variant === "pulse"
|
|
44
|
+
? Animated.loop(
|
|
45
|
+
Animated.sequence([
|
|
46
|
+
Animated.timing(progress, {
|
|
47
|
+
toValue: 1,
|
|
48
|
+
duration: scaledMs(LOADER_PULSE_MS / 2),
|
|
49
|
+
easing: Easing.inOut(Easing.ease),
|
|
50
|
+
useNativeDriver: true,
|
|
51
|
+
}),
|
|
52
|
+
Animated.timing(progress, {
|
|
53
|
+
toValue: 0,
|
|
54
|
+
duration: scaledMs(LOADER_PULSE_MS / 2),
|
|
55
|
+
easing: Easing.inOut(Easing.ease),
|
|
56
|
+
useNativeDriver: true,
|
|
57
|
+
}),
|
|
58
|
+
]),
|
|
59
|
+
)
|
|
60
|
+
: Animated.loop(
|
|
61
|
+
Animated.timing(progress, {
|
|
62
|
+
toValue: 1,
|
|
63
|
+
duration: scaledMs(ROTATION_MS),
|
|
64
|
+
easing: Easing.linear,
|
|
65
|
+
useNativeDriver: true,
|
|
66
|
+
}),
|
|
67
|
+
);
|
|
68
|
+
loop.start();
|
|
69
|
+
return () => loop.stop();
|
|
70
|
+
}, [progress, variant, reduced]);
|
|
71
|
+
|
|
72
|
+
const rotate = progress.interpolate({ inputRange: [0, 1], outputRange: ["0deg", "360deg"] });
|
|
73
|
+
const pulse = progress.interpolate({
|
|
74
|
+
inputRange: [0, 1],
|
|
75
|
+
outputRange: [1, LOADER_PULSE_MIN_OPACITY],
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const animatedStyle = reduced
|
|
79
|
+
? undefined
|
|
80
|
+
: variant === "pulse"
|
|
81
|
+
? { opacity: pulse }
|
|
82
|
+
: { transform: [{ rotate }] };
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<Animated.View style={animatedStyle}>
|
|
86
|
+
<Text
|
|
87
|
+
accessibilityElementsHidden
|
|
88
|
+
importantForAccessibility="no"
|
|
89
|
+
style={[styles.glyph, { fontSize: size, lineHeight: size * 1.1, color: color ?? t.colors.primary }]}
|
|
90
|
+
>
|
|
91
|
+
✦
|
|
92
|
+
</Text>
|
|
93
|
+
</Animated.View>
|
|
94
|
+
);
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
export const AnimatedSparkle = React.memo(_AnimatedSparkle);
|
|
98
|
+
|
|
99
|
+
const styles = StyleSheet.create({
|
|
100
|
+
glyph: { textAlign: "center" },
|
|
101
|
+
});
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Button — the kit's themed primary/outline button, replacing the SDK's hardcoded
|
|
3
|
+
* `Btn`. Every color/radius/font comes from the active theme.
|
|
4
|
+
*
|
|
5
|
+
* Motion (design-refs/EXTRACTED-SPEC.md row 20): "a footer button that answers
|
|
6
|
+
* the tap" — scale 1→0.975 spring on press-in, back on release, native driver.
|
|
7
|
+
* Reduce motion: an opacity dip to 0.85 while pressed, no scale. Disabled keeps
|
|
8
|
+
* the `disabled` token fill with no press handler (unchanged).
|
|
9
|
+
*/
|
|
10
|
+
import React, { useCallback, useRef } from "react";
|
|
11
|
+
import { Animated, Pressable, StyleSheet, Text } from "react-native";
|
|
12
|
+
import { useOnboardingTheme } from "../theme/ThemeContext";
|
|
13
|
+
import { buttonLabelStyle } from "../theme/typography";
|
|
14
|
+
import {
|
|
15
|
+
BUTTON_PRESS_SCALE,
|
|
16
|
+
BUTTON_PRESS_SPRING,
|
|
17
|
+
REDUCED_FADE_MS,
|
|
18
|
+
REDUCED_PRESS_OPACITY,
|
|
19
|
+
scaledMs,
|
|
20
|
+
} from "../motion/motionSpec";
|
|
21
|
+
import { useReducedMotion } from "../motion/useReducedMotion";
|
|
22
|
+
|
|
23
|
+
type Variant = "primary" | "outline";
|
|
24
|
+
|
|
25
|
+
type ButtonProps = {
|
|
26
|
+
title: string;
|
|
27
|
+
onPress?: () => void;
|
|
28
|
+
variant?: Variant;
|
|
29
|
+
disabled?: boolean;
|
|
30
|
+
/** Stretch to the container width — for form-style CTAs (e.g. the review form's
|
|
31
|
+
* "Send feedback"). Default false keeps the content-width pill used by cards. */
|
|
32
|
+
fullWidth?: boolean;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const _Button: React.FC<ButtonProps> = ({
|
|
36
|
+
title,
|
|
37
|
+
onPress,
|
|
38
|
+
variant = "primary",
|
|
39
|
+
disabled = false,
|
|
40
|
+
fullWidth = false,
|
|
41
|
+
}) => {
|
|
42
|
+
const t = useOnboardingTheme();
|
|
43
|
+
const reduced = useReducedMotion();
|
|
44
|
+
const isPrimary = variant === "primary";
|
|
45
|
+
|
|
46
|
+
// 0 = at rest, 1 = pressed. Scale normally; opacity dip under reduce motion.
|
|
47
|
+
const pressT = useRef(new Animated.Value(0)).current;
|
|
48
|
+
const pressTo = useCallback(
|
|
49
|
+
(to: number) => {
|
|
50
|
+
if (reduced) {
|
|
51
|
+
Animated.timing(pressT, {
|
|
52
|
+
toValue: to,
|
|
53
|
+
duration: scaledMs(REDUCED_FADE_MS),
|
|
54
|
+
useNativeDriver: true,
|
|
55
|
+
}).start();
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
Animated.spring(pressT, {
|
|
59
|
+
toValue: to,
|
|
60
|
+
friction: BUTTON_PRESS_SPRING.friction,
|
|
61
|
+
tension: BUTTON_PRESS_SPRING.tension,
|
|
62
|
+
useNativeDriver: true,
|
|
63
|
+
}).start();
|
|
64
|
+
},
|
|
65
|
+
[pressT, reduced],
|
|
66
|
+
);
|
|
67
|
+
const handlePressIn = useCallback(() => pressTo(1), [pressTo]);
|
|
68
|
+
const handlePressOut = useCallback(() => pressTo(0), [pressTo]);
|
|
69
|
+
|
|
70
|
+
const scale = pressT.interpolate({
|
|
71
|
+
inputRange: [0, 1],
|
|
72
|
+
outputRange: [1, reduced ? 1 : BUTTON_PRESS_SCALE],
|
|
73
|
+
});
|
|
74
|
+
const opacity = pressT.interpolate({
|
|
75
|
+
inputRange: [0, 1],
|
|
76
|
+
outputRange: [1, reduced ? REDUCED_PRESS_OPACITY : 1],
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<Pressable
|
|
81
|
+
onPress={disabled ? undefined : onPress}
|
|
82
|
+
onPressIn={disabled ? undefined : handlePressIn}
|
|
83
|
+
onPressOut={disabled ? undefined : handlePressOut}
|
|
84
|
+
disabled={disabled}
|
|
85
|
+
accessibilityRole="button"
|
|
86
|
+
accessibilityState={{ disabled }}
|
|
87
|
+
style={fullWidth ? styles.fill : undefined}
|
|
88
|
+
>
|
|
89
|
+
<Animated.View
|
|
90
|
+
style={[
|
|
91
|
+
styles.base,
|
|
92
|
+
fullWidth && styles.fill,
|
|
93
|
+
{
|
|
94
|
+
borderRadius: t.button.radius,
|
|
95
|
+
minHeight: t.button.height,
|
|
96
|
+
paddingHorizontal: t.button.paddingX,
|
|
97
|
+
opacity,
|
|
98
|
+
transform: [{ scale }],
|
|
99
|
+
},
|
|
100
|
+
isPrimary
|
|
101
|
+
? { backgroundColor: disabled ? t.colors.disabled : t.colors.primary }
|
|
102
|
+
: { borderWidth: 1.5, borderColor: disabled ? t.colors.border : t.colors.primary },
|
|
103
|
+
]}
|
|
104
|
+
>
|
|
105
|
+
<Text
|
|
106
|
+
style={[
|
|
107
|
+
buttonLabelStyle(t.fonts),
|
|
108
|
+
{
|
|
109
|
+
color: disabled
|
|
110
|
+
? t.colors.textMuted
|
|
111
|
+
: isPrimary
|
|
112
|
+
? t.colors.onPrimary
|
|
113
|
+
: t.colors.primary,
|
|
114
|
+
},
|
|
115
|
+
]}
|
|
116
|
+
>
|
|
117
|
+
{title}
|
|
118
|
+
</Text>
|
|
119
|
+
</Animated.View>
|
|
120
|
+
</Pressable>
|
|
121
|
+
);
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
export const Button = React.memo(_Button);
|
|
125
|
+
|
|
126
|
+
const styles = StyleSheet.create({
|
|
127
|
+
base: { alignItems: "center", justifyContent: "center" },
|
|
128
|
+
fill: { width: "100%" },
|
|
129
|
+
});
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CardHandoff — the card-to-card transition from the Wire Activation design:
|
|
3
|
+
* "cards hand off, they don't cut". When `transitionKey` changes, the outgoing
|
|
4
|
+
* child leaves left (translateX 0→-26, fade, 180ms) in an absolute layer while
|
|
5
|
+
* the incoming child arrives from the right (translateX +26→0, fade, 220ms)
|
|
6
|
+
* delayed by the 80ms exit lead — one moving surface, ~300ms total.
|
|
7
|
+
*
|
|
8
|
+
* Variants (per child):
|
|
9
|
+
* - "slide": questions — the directional stepper motion above.
|
|
10
|
+
* - "spring": the InterstitialCard value beat — scale 0.96→1 spring pop
|
|
11
|
+
* (the shell's springier variant, kept from AnimatedCard).
|
|
12
|
+
* - "fade": loaders — enter/exit as a plain 120ms fade so the "thinking"
|
|
13
|
+
* state hands off to the first card without a hard cut.
|
|
14
|
+
*
|
|
15
|
+
* Reduce motion: every handoff becomes a 120ms crossfade, no translate/scale.
|
|
16
|
+
* All values run on the native driver (transform/opacity only); in-flight
|
|
17
|
+
* animations are stopped when superseded and on unmount.
|
|
18
|
+
*
|
|
19
|
+
* This SUPERSEDES the enter-only AnimatedCard: same keyed-remount contract from
|
|
20
|
+
* OnboardingFlow (the child renderer stays keyed on the message id), plus the
|
|
21
|
+
* paired exit layer the design calls for.
|
|
22
|
+
*/
|
|
23
|
+
import React, { useEffect, useRef, useState } from "react";
|
|
24
|
+
import { Animated, Easing, StyleSheet } from "react-native";
|
|
25
|
+
import {
|
|
26
|
+
CARD_ENTER_MS,
|
|
27
|
+
CARD_ENTER_SCALE_FROM,
|
|
28
|
+
CARD_EXIT_LEAD_MS,
|
|
29
|
+
CARD_EXIT_MS,
|
|
30
|
+
CARD_SLIDE_PX,
|
|
31
|
+
CARD_SPRING,
|
|
32
|
+
LOADER_HANDOFF_FADE_MS,
|
|
33
|
+
REDUCED_FADE_MS,
|
|
34
|
+
WIRE_BEZIER,
|
|
35
|
+
scaledMs,
|
|
36
|
+
} from "../motion/motionSpec";
|
|
37
|
+
import { useReducedMotion } from "../motion/useReducedMotion";
|
|
38
|
+
|
|
39
|
+
const easeWire = Easing.bezier(...WIRE_BEZIER);
|
|
40
|
+
|
|
41
|
+
export type CardHandoffVariant = "slide" | "spring" | "fade";
|
|
42
|
+
|
|
43
|
+
export type CardHandoffProps = {
|
|
44
|
+
/** Identity of the current child (e.g. the message id). A change runs the handoff. */
|
|
45
|
+
transitionKey: string;
|
|
46
|
+
/** How the CURRENT child enters (and, snapshotted, how it will later exit). */
|
|
47
|
+
variant?: CardHandoffVariant;
|
|
48
|
+
children: React.ReactNode;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
type Snapshot = { key: string; node: React.ReactNode; variant: CardHandoffVariant };
|
|
52
|
+
|
|
53
|
+
const _CardHandoff: React.FC<CardHandoffProps> = ({
|
|
54
|
+
transitionKey,
|
|
55
|
+
variant = "slide",
|
|
56
|
+
children,
|
|
57
|
+
}) => {
|
|
58
|
+
const reduced = useReducedMotion();
|
|
59
|
+
const [leaving, setLeaving] = useState<Snapshot | null>(null);
|
|
60
|
+
|
|
61
|
+
const enterT = useRef(new Animated.Value(0)).current;
|
|
62
|
+
const exitT = useRef(new Animated.Value(0)).current;
|
|
63
|
+
// The previous render's child, snapshotted so it can play the exit layer.
|
|
64
|
+
const prev = useRef<Snapshot>({ key: transitionKey, node: children, variant });
|
|
65
|
+
// Variant of the child currently entering (drives the enter interpolations).
|
|
66
|
+
const [enterVariant, setEnterVariant] = useState<CardHandoffVariant>(variant);
|
|
67
|
+
const running = useRef<{ enter?: Animated.CompositeAnimation; exit?: Animated.CompositeAnimation }>({});
|
|
68
|
+
const mounted = useRef(false);
|
|
69
|
+
|
|
70
|
+
// One orchestration effect, run every render: refresh the snapshot while the key
|
|
71
|
+
// is stable; on a key change, snapshot the old child into the exit layer and run
|
|
72
|
+
// the paired exit+enter. (No deps array on purpose — `children` is a fresh node
|
|
73
|
+
// every parent render and must keep the snapshot current.)
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
const last = prev.current;
|
|
76
|
+
prev.current = { key: transitionKey, node: children, variant };
|
|
77
|
+
|
|
78
|
+
// First mount: enter only (the design's initial reveal), nothing exits.
|
|
79
|
+
if (!mounted.current) {
|
|
80
|
+
mounted.current = true;
|
|
81
|
+
setEnterVariant(variant);
|
|
82
|
+
running.current.enter = startEnter(enterT, variant, reduced, 0);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
if (last.key === transitionKey) return;
|
|
86
|
+
|
|
87
|
+
running.current.enter?.stop();
|
|
88
|
+
running.current.exit?.stop();
|
|
89
|
+
setLeaving(last);
|
|
90
|
+
setEnterVariant(variant);
|
|
91
|
+
enterT.setValue(0);
|
|
92
|
+
exitT.setValue(0);
|
|
93
|
+
|
|
94
|
+
const exitDuration = reduced
|
|
95
|
+
? REDUCED_FADE_MS
|
|
96
|
+
: last.variant === "fade"
|
|
97
|
+
? LOADER_HANDOFF_FADE_MS
|
|
98
|
+
: CARD_EXIT_MS;
|
|
99
|
+
const exit = Animated.timing(exitT, {
|
|
100
|
+
toValue: 1,
|
|
101
|
+
duration: scaledMs(exitDuration),
|
|
102
|
+
easing: easeWire,
|
|
103
|
+
useNativeDriver: true,
|
|
104
|
+
});
|
|
105
|
+
running.current.exit = exit;
|
|
106
|
+
exit.start();
|
|
107
|
+
|
|
108
|
+
const enter = startEnter(enterT, variant, reduced, reduced ? 0 : CARD_EXIT_LEAD_MS, () =>
|
|
109
|
+
setLeaving(null),
|
|
110
|
+
);
|
|
111
|
+
running.current.enter = enter;
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// Stop anything in flight on unmount (leaked animations drain the JS thread).
|
|
115
|
+
useEffect(
|
|
116
|
+
() => () => {
|
|
117
|
+
running.current.enter?.stop();
|
|
118
|
+
running.current.exit?.stop();
|
|
119
|
+
},
|
|
120
|
+
[],
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
const enterStyle = {
|
|
124
|
+
opacity: enterT,
|
|
125
|
+
transform: transformFor(enterVariant, enterT, reduced, "enter"),
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
return (
|
|
129
|
+
<>
|
|
130
|
+
{leaving ? (
|
|
131
|
+
<Animated.View
|
|
132
|
+
pointerEvents="none"
|
|
133
|
+
style={[
|
|
134
|
+
StyleSheet.absoluteFill,
|
|
135
|
+
{
|
|
136
|
+
opacity: exitT.interpolate({ inputRange: [0, 1], outputRange: [1, 0] }),
|
|
137
|
+
transform: transformFor(leaving.variant, exitT, reduced, "exit"),
|
|
138
|
+
},
|
|
139
|
+
]}
|
|
140
|
+
>
|
|
141
|
+
{leaving.node}
|
|
142
|
+
</Animated.View>
|
|
143
|
+
) : null}
|
|
144
|
+
<Animated.View style={[styles.fill, enterStyle]}>{children}</Animated.View>
|
|
145
|
+
</>
|
|
146
|
+
);
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
/** Kick off the enter animation for a variant (spring for the value beat, timing otherwise). */
|
|
150
|
+
const startEnter = (
|
|
151
|
+
value: Animated.Value,
|
|
152
|
+
variant: CardHandoffVariant,
|
|
153
|
+
reduced: boolean,
|
|
154
|
+
delay: number,
|
|
155
|
+
onDone?: () => void,
|
|
156
|
+
): Animated.CompositeAnimation => {
|
|
157
|
+
value.setValue(0);
|
|
158
|
+
const anim =
|
|
159
|
+
!reduced && variant === "spring"
|
|
160
|
+
? Animated.spring(value, {
|
|
161
|
+
toValue: 1,
|
|
162
|
+
delay: scaledMs(delay),
|
|
163
|
+
damping: CARD_SPRING.damping,
|
|
164
|
+
stiffness: CARD_SPRING.stiffness,
|
|
165
|
+
mass: CARD_SPRING.mass,
|
|
166
|
+
useNativeDriver: true,
|
|
167
|
+
})
|
|
168
|
+
: Animated.timing(value, {
|
|
169
|
+
toValue: 1,
|
|
170
|
+
delay: scaledMs(delay),
|
|
171
|
+
duration: scaledMs(
|
|
172
|
+
reduced
|
|
173
|
+
? REDUCED_FADE_MS
|
|
174
|
+
: variant === "fade"
|
|
175
|
+
? LOADER_HANDOFF_FADE_MS
|
|
176
|
+
: CARD_ENTER_MS,
|
|
177
|
+
),
|
|
178
|
+
easing: easeWire,
|
|
179
|
+
useNativeDriver: true,
|
|
180
|
+
});
|
|
181
|
+
anim.start(({ finished }) => {
|
|
182
|
+
if (finished) onDone?.();
|
|
183
|
+
});
|
|
184
|
+
return anim;
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
/** Transform track per variant/direction; reduce motion = opacity-only crossfade. */
|
|
188
|
+
const transformFor = (
|
|
189
|
+
variant: CardHandoffVariant,
|
|
190
|
+
t: Animated.Value,
|
|
191
|
+
reduced: boolean,
|
|
192
|
+
direction: "enter" | "exit",
|
|
193
|
+
) => {
|
|
194
|
+
if (reduced || variant === "fade") return [];
|
|
195
|
+
if (variant === "spring" && direction === "enter") {
|
|
196
|
+
return [{ scale: t.interpolate({ inputRange: [0, 1], outputRange: [CARD_ENTER_SCALE_FROM, 1] }) }];
|
|
197
|
+
}
|
|
198
|
+
return [
|
|
199
|
+
{
|
|
200
|
+
translateX: t.interpolate({
|
|
201
|
+
inputRange: [0, 1],
|
|
202
|
+
outputRange: direction === "enter" ? [CARD_SLIDE_PX, 0] : [0, -CARD_SLIDE_PX],
|
|
203
|
+
}),
|
|
204
|
+
},
|
|
205
|
+
];
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
export const CardHandoff = React.memo(_CardHandoff);
|
|
209
|
+
|
|
210
|
+
const styles = StyleSheet.create({
|
|
211
|
+
fill: { flex: 1 },
|
|
212
|
+
});
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CardLayout — the shared full-screen card body every card renders into. It owns
|
|
3
|
+
* the Cal-AI style vertical rhythm so no card re-implements layout:
|
|
4
|
+
*
|
|
5
|
+
* [ title ] ← fixed at top (small gap → subtitle)
|
|
6
|
+
* [ subtitle / hint ]
|
|
7
|
+
* [ ─ scrollable content ─ ]← options / chips / input; a flex:1 ScrollView that
|
|
8
|
+
* fills the gap, so it scrolls only when tall and
|
|
9
|
+
* otherwise just pushes the footer down
|
|
10
|
+
* [ footer / CTA ] ← the submit button, ANCHORED to the bottom
|
|
11
|
+
*
|
|
12
|
+
* The middle ScrollView (flex:1) IS the flexible spacer: when the content is short
|
|
13
|
+
* it grows to fill, pinning the footer to the bottom of the screen; when the
|
|
14
|
+
* content is many/tall options it scrolls while the CTA stays put. Everything
|
|
15
|
+
* reads from the theme tokens — no hardcoded spacing or color.
|
|
16
|
+
*
|
|
17
|
+
* `align="center"` vertically centers the content (used by the value/status beats:
|
|
18
|
+
* InterstitialCard / StatusCard), keeping the same pinned-CTA footer.
|
|
19
|
+
*
|
|
20
|
+
* The whole column sits in a KeyboardAvoidingView (behavior="padding") so the pinned
|
|
21
|
+
* footer rides above the soft keyboard on text questions (TextInputCard). On hosts
|
|
22
|
+
* whose window already resizes for the keyboard the computed overlap is ~0, so the
|
|
23
|
+
* padding is a no-op — safe on both iOS and Android, edge-to-edge or not.
|
|
24
|
+
*/
|
|
25
|
+
import React from "react";
|
|
26
|
+
import { KeyboardAvoidingView, ScrollView, StyleSheet, Text, View } from "react-native";
|
|
27
|
+
import { useOnboardingTheme } from "../theme/ThemeContext";
|
|
28
|
+
import { bodyStyle, labelStyle } from "../theme/typography";
|
|
29
|
+
|
|
30
|
+
export type CardLayoutProps = {
|
|
31
|
+
/** Fixed question/label shown at the top. Omit for a header-less centered beat. */
|
|
32
|
+
title?: string;
|
|
33
|
+
/** Optional supporting line under the title (small gap between them). */
|
|
34
|
+
subtitle?: string;
|
|
35
|
+
/** The options / chips / input area — the scrollable middle. */
|
|
36
|
+
children: React.ReactNode;
|
|
37
|
+
/** The pinned bottom CTA (usually a <Button/>). Omitted → no footer. */
|
|
38
|
+
footer?: React.ReactNode;
|
|
39
|
+
/** Vertical alignment of the middle content. "top" (questions) | "center" (beats). */
|
|
40
|
+
align?: "top" | "center";
|
|
41
|
+
/** Custom title node (e.g. StatusCard's icon+title row) rendered in place of `title`. */
|
|
42
|
+
titleNode?: React.ReactNode;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const _CardLayout: React.FC<CardLayoutProps> = ({
|
|
46
|
+
title,
|
|
47
|
+
subtitle,
|
|
48
|
+
children,
|
|
49
|
+
footer,
|
|
50
|
+
align = "top",
|
|
51
|
+
titleNode,
|
|
52
|
+
}) => {
|
|
53
|
+
const t = useOnboardingTheme();
|
|
54
|
+
const hasHeader = !!title || !!subtitle || !!titleNode;
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<KeyboardAvoidingView style={styles.fill} behavior="padding">
|
|
58
|
+
{hasHeader ? (
|
|
59
|
+
<View style={{ marginBottom: t.spacing.lg }}>
|
|
60
|
+
{titleNode ??
|
|
61
|
+
(title ? (
|
|
62
|
+
<Text style={[labelStyle(t.fonts), { color: t.colors.text }]}>{title}</Text>
|
|
63
|
+
) : null)}
|
|
64
|
+
{subtitle ? (
|
|
65
|
+
<Text
|
|
66
|
+
style={[
|
|
67
|
+
bodyStyle(t.fonts),
|
|
68
|
+
{ color: t.colors.textMuted, marginTop: t.spacing.xs },
|
|
69
|
+
]}
|
|
70
|
+
>
|
|
71
|
+
{subtitle}
|
|
72
|
+
</Text>
|
|
73
|
+
) : null}
|
|
74
|
+
</View>
|
|
75
|
+
) : null}
|
|
76
|
+
|
|
77
|
+
<ScrollView
|
|
78
|
+
style={styles.fill}
|
|
79
|
+
contentContainerStyle={[
|
|
80
|
+
styles.scrollContent,
|
|
81
|
+
align === "center" && styles.center,
|
|
82
|
+
]}
|
|
83
|
+
keyboardShouldPersistTaps="handled"
|
|
84
|
+
keyboardDismissMode="on-drag"
|
|
85
|
+
showsVerticalScrollIndicator={false}
|
|
86
|
+
>
|
|
87
|
+
{children}
|
|
88
|
+
</ScrollView>
|
|
89
|
+
|
|
90
|
+
{footer ? <View style={{ paddingTop: t.spacing.lg }}>{footer}</View> : null}
|
|
91
|
+
</KeyboardAvoidingView>
|
|
92
|
+
);
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
export const CardLayout = React.memo(_CardLayout);
|
|
96
|
+
|
|
97
|
+
const styles = StyleSheet.create({
|
|
98
|
+
fill: { flex: 1 },
|
|
99
|
+
// flexGrow lets a short content block grow to fill (pinning the footer) while a
|
|
100
|
+
// tall one scrolls past the visible area.
|
|
101
|
+
scrollContent: { flexGrow: 1 },
|
|
102
|
+
center: { justifyContent: "center" },
|
|
103
|
+
});
|