@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.
Files changed (142) hide show
  1. package/AGENTS.md +73 -0
  2. package/CHANGELOG.md +572 -0
  3. package/INTEGRATION_PROMPT.md +55 -0
  4. package/LICENSE +21 -0
  5. package/README.md +923 -0
  6. package/dist/CenteredModal-C3qQBHsA.d.mts +38 -0
  7. package/dist/CenteredModal-Cdgns6--.d.ts +38 -0
  8. package/dist/coachmarks/index.d.mts +352 -0
  9. package/dist/coachmarks/index.d.ts +352 -0
  10. package/dist/coachmarks/index.js +944 -0
  11. package/dist/coachmarks/index.js.map +1 -0
  12. package/dist/coachmarks/index.mjs +918 -0
  13. package/dist/coachmarks/index.mjs.map +1 -0
  14. package/dist/decision-CFvGY6nP.d.ts +228 -0
  15. package/dist/decision-Cl8OFYzu.d.mts +228 -0
  16. package/dist/index.d.mts +1390 -0
  17. package/dist/index.d.ts +1390 -0
  18. package/dist/index.js +3260 -0
  19. package/dist/index.js.map +1 -0
  20. package/dist/index.mjs +3186 -0
  21. package/dist/index.mjs.map +1 -0
  22. package/dist/questionnaire/index.d.mts +249 -0
  23. package/dist/questionnaire/index.d.ts +249 -0
  24. package/dist/questionnaire/index.js +922 -0
  25. package/dist/questionnaire/index.js.map +1 -0
  26. package/dist/questionnaire/index.mjs +906 -0
  27. package/dist/questionnaire/index.mjs.map +1 -0
  28. package/dist/reviews/index.d.mts +213 -0
  29. package/dist/reviews/index.d.ts +213 -0
  30. package/dist/reviews/index.js +1001 -0
  31. package/dist/reviews/index.js.map +1 -0
  32. package/dist/reviews/index.mjs +971 -0
  33. package/dist/reviews/index.mjs.map +1 -0
  34. package/dist/showcase/index.d.mts +119 -0
  35. package/dist/showcase/index.d.ts +119 -0
  36. package/dist/showcase/index.js +660 -0
  37. package/dist/showcase/index.js.map +1 -0
  38. package/dist/showcase/index.mjs +651 -0
  39. package/dist/showcase/index.mjs.map +1 -0
  40. package/dist/types-BKfpdZzX.d.mts +84 -0
  41. package/dist/types-BKfpdZzX.d.ts +84 -0
  42. package/dist/types-CMuOexw0.d.mts +116 -0
  43. package/dist/types-CMuOexw0.d.ts +116 -0
  44. package/dist/types-GL_hQ0TN.d.mts +46 -0
  45. package/dist/types-GL_hQ0TN.d.ts +46 -0
  46. package/llms.txt +36 -0
  47. package/metro/index.d.ts +32 -0
  48. package/metro/index.js +83 -0
  49. package/package.json +171 -0
  50. package/src/DemoOnboarding.tsx +133 -0
  51. package/src/OnboardingFlow.tsx +504 -0
  52. package/src/WireOnboarding.tsx +248 -0
  53. package/src/analytics/analyticsEvent.ts +69 -0
  54. package/src/analytics/reportClientEvent.ts +119 -0
  55. package/src/analytics/sendPreview.ts +73 -0
  56. package/src/attribution/attribution.ts +46 -0
  57. package/src/cards/ChipSelectCard.tsx +265 -0
  58. package/src/cards/InterstitialCard.tsx +299 -0
  59. package/src/cards/NumberStepperCard.tsx +121 -0
  60. package/src/cards/SelectionCard.tsx +270 -0
  61. package/src/cards/StatusCard.tsx +170 -0
  62. package/src/cards/TextInputCard.tsx +147 -0
  63. package/src/cards/index.ts +23 -0
  64. package/src/coachmarks/CoachmarkOverlayHost.tsx +44 -0
  65. package/src/coachmarks/CoachmarkProvider.tsx +108 -0
  66. package/src/coachmarks/GestureHint.tsx +331 -0
  67. package/src/coachmarks/SpotlightOverlay.tsx +254 -0
  68. package/src/coachmarks/coachmarkAnchorRegistry.ts +70 -0
  69. package/src/coachmarks/coachmarkOverlayStore.ts +63 -0
  70. package/src/coachmarks/index.ts +62 -0
  71. package/src/coachmarks/runtime.ts +103 -0
  72. package/src/coachmarks/selectTourSteps.ts +45 -0
  73. package/src/coachmarks/types.ts +58 -0
  74. package/src/coachmarks/useCoachmarkAnchor.ts +49 -0
  75. package/src/coachmarks/useCoachmarkTour.ts +200 -0
  76. package/src/components/AnimatedSparkle.tsx +101 -0
  77. package/src/components/Button.tsx +129 -0
  78. package/src/components/CardHandoff.tsx +212 -0
  79. package/src/components/CardLayout.tsx +103 -0
  80. package/src/components/CenteredModal.tsx +184 -0
  81. package/src/components/CompletionView.tsx +186 -0
  82. package/src/components/DoneBlock.tsx +37 -0
  83. package/src/components/ErrorBlock.tsx +42 -0
  84. package/src/components/Illustration.tsx +36 -0
  85. package/src/components/LoadingBlock.tsx +182 -0
  86. package/src/components/LoadingScreen.tsx +35 -0
  87. package/src/components/OnboardingScaffold.tsx +105 -0
  88. package/src/components/StepProgress.tsx +85 -0
  89. package/src/components/loaderChrome.ts +28 -0
  90. package/src/config/onboardingFlag.ts +39 -0
  91. package/src/config/wireConfigFromEnv.ts +52 -0
  92. package/src/device/deviceContext.ts +158 -0
  93. package/src/features/WireFeaturesProvider.tsx +81 -0
  94. package/src/features/cache.ts +92 -0
  95. package/src/features/defaults.ts +67 -0
  96. package/src/features/fetchWireFeatures.ts +86 -0
  97. package/src/features/index.ts +39 -0
  98. package/src/features/types.ts +61 -0
  99. package/src/features/useWireFeatures.ts +57 -0
  100. package/src/identity/userIdentity.ts +100 -0
  101. package/src/illustrations/defaultIllustrations.tsx +99 -0
  102. package/src/index.ts +159 -0
  103. package/src/motion/centeredModalMotion.ts +45 -0
  104. package/src/motion/motionSpec.ts +133 -0
  105. package/src/motion/useReducedMotion.ts +43 -0
  106. package/src/questionnaire/QuestionnaireGate.tsx +235 -0
  107. package/src/questionnaire/decision.ts +63 -0
  108. package/src/questionnaire/index.ts +54 -0
  109. package/src/questionnaire/runtime.ts +20 -0
  110. package/src/questionnaire/transport.ts +83 -0
  111. package/src/questionnaire/types.ts +182 -0
  112. package/src/questionnaire/useQuestionnaireGate.ts +159 -0
  113. package/src/reviews/ReviewGate.tsx +316 -0
  114. package/src/reviews/ReviewModal.tsx +13 -0
  115. package/src/reviews/decision.ts +120 -0
  116. package/src/reviews/equality.ts +54 -0
  117. package/src/reviews/expo-store-review.d.ts +16 -0
  118. package/src/reviews/feedbackForm.ts +41 -0
  119. package/src/reviews/index.ts +78 -0
  120. package/src/reviews/presentation.ts +29 -0
  121. package/src/reviews/runtime.ts +45 -0
  122. package/src/reviews/storeReview.ts +62 -0
  123. package/src/reviews/transport.ts +89 -0
  124. package/src/reviews/types.ts +172 -0
  125. package/src/reviews/useReviewGate.ts +137 -0
  126. package/src/session/persistedSession.ts +143 -0
  127. package/src/session-analytics/index.ts +18 -0
  128. package/src/session-analytics/reportSessionStart.ts +144 -0
  129. package/src/session-analytics/useSessionStart.ts +114 -0
  130. package/src/showcase/FeatureShowcase.tsx +233 -0
  131. package/src/showcase/index.ts +19 -0
  132. package/src/showcase/selectShowcaseSlides.ts +45 -0
  133. package/src/showcase/showcaseColors.ts +91 -0
  134. package/src/showcase/types.ts +74 -0
  135. package/src/theme/ThemeContext.tsx +38 -0
  136. package/src/theme/defaultTheme.ts +70 -0
  137. package/src/theme/themeFromBrand.ts +93 -0
  138. package/src/theme/types.ts +88 -0
  139. package/src/theme/typography.ts +46 -0
  140. package/src/types.ts +215 -0
  141. package/src/utils/deriveAnswers.ts +43 -0
  142. 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
+ });