@wireai/activation 0.7.0 → 0.9.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 (94) hide show
  1. package/AGENTS.md +29 -5
  2. package/CHANGELOG.md +198 -6
  3. package/README.md +105 -10
  4. package/dist/analytics/index.d.mts +12 -8
  5. package/dist/analytics/index.d.ts +12 -8
  6. package/dist/analytics/index.js +525 -55
  7. package/dist/analytics/index.js.map +1 -1
  8. package/dist/analytics/index.mjs +525 -55
  9. package/dist/analytics/index.mjs.map +1 -1
  10. package/dist/coachmarks/index.d.mts +2 -2
  11. package/dist/coachmarks/index.d.ts +2 -2
  12. package/dist/coachmarks/index.js.map +1 -1
  13. package/dist/coachmarks/index.mjs.map +1 -1
  14. package/dist/{currentSession-CxnP7gAa.d.ts → currentSession-61dcm3V-.d.ts} +19 -3
  15. package/dist/{currentSession-BJBB7i4-.d.mts → currentSession-Bs2JfTJ8.d.mts} +19 -3
  16. package/dist/{decision-BzbiKwk3.d.mts → decision-Bl_M2y3r.d.mts} +1 -1
  17. package/dist/{decision-plDEOCkt.d.ts → decision-Cau5KmP6.d.ts} +1 -1
  18. package/dist/index.d.mts +620 -13
  19. package/dist/index.d.ts +620 -13
  20. package/dist/index.js +1194 -369
  21. package/dist/index.js.map +1 -1
  22. package/dist/index.mjs +1015 -201
  23. package/dist/index.mjs.map +1 -1
  24. package/dist/questionnaire/index.d.mts +69 -23
  25. package/dist/questionnaire/index.d.ts +69 -23
  26. package/dist/questionnaire/index.js +643 -172
  27. package/dist/questionnaire/index.js.map +1 -1
  28. package/dist/questionnaire/index.mjs +608 -137
  29. package/dist/questionnaire/index.mjs.map +1 -1
  30. package/dist/reviews/index.d.mts +24 -12
  31. package/dist/reviews/index.d.ts +24 -12
  32. package/dist/reviews/index.js +507 -173
  33. package/dist/reviews/index.js.map +1 -1
  34. package/dist/reviews/index.mjs +462 -129
  35. package/dist/reviews/index.mjs.map +1 -1
  36. package/dist/showcase/index.d.mts +2 -2
  37. package/dist/showcase/index.d.ts +2 -2
  38. package/dist/showcase/index.js.map +1 -1
  39. package/dist/showcase/index.mjs.map +1 -1
  40. package/dist/transport-DzU-TqZz.d.ts +86 -0
  41. package/dist/transport-f5VVB5hH.d.mts +86 -0
  42. package/dist/{types-GL_hQ0TN.d.mts → types-BcmagF6K.d.mts} +1 -1
  43. package/dist/{types-GL_hQ0TN.d.ts → types-BcmagF6K.d.ts} +1 -1
  44. package/dist/{types-BhpXJGlg.d.ts → types-Buj9Lw9t.d.ts} +15 -3
  45. package/dist/{types-CMuOexw0.d.mts → types-CKFhyrMu.d.mts} +1 -1
  46. package/dist/{types-CMuOexw0.d.ts → types-CKFhyrMu.d.ts} +1 -1
  47. package/dist/{types-A6pTxIZV.d.mts → types-CNUqMK0D.d.mts} +15 -3
  48. package/metro/index.d.ts +18 -1
  49. package/metro/index.js +80 -0
  50. package/package.json +9 -1
  51. package/src/WireOnboarding.tsx +4 -0
  52. package/src/analytics/analyticsFacade.ts +55 -10
  53. package/src/analytics/currentSession.ts +1 -1
  54. package/src/cards/CardGridSelectCard.tsx +253 -0
  55. package/src/cards/ChipSelectCard.tsx +1 -1
  56. package/src/cards/SelectionCard.tsx +15 -19
  57. package/src/cards/index.ts +13 -1
  58. package/src/cards/optionSchema.ts +65 -0
  59. package/src/coachmarks/types.ts +1 -1
  60. package/src/components/CardHandoff.tsx +39 -7
  61. package/src/components/DoneCheck.tsx +94 -0
  62. package/src/components/ModalPaneHost.tsx +141 -0
  63. package/src/components/StepDots.tsx +112 -0
  64. package/src/context/deviceId.ts +43 -0
  65. package/src/device/appVersion.ts +29 -8
  66. package/src/device/deviceContext.ts +14 -1
  67. package/src/device/deviceModel.ts +106 -0
  68. package/src/features/cache.ts +1 -1
  69. package/src/haptics/expo-haptics.d.ts +31 -0
  70. package/src/haptics/haptics.ts +58 -0
  71. package/src/icons/IconRegistry.tsx +53 -0
  72. package/src/icons/WireIcon.tsx +85 -0
  73. package/src/icons/expoIcons.ts +165 -0
  74. package/src/icons/index.ts +18 -0
  75. package/src/icons/vocabulary.ts +154 -0
  76. package/src/index.ts +22 -0
  77. package/src/questionnaire/QuestionnaireGate.tsx +315 -164
  78. package/src/questionnaire/decision.ts +4 -2
  79. package/src/questionnaire/index.ts +3 -2
  80. package/src/questionnaire/steps.ts +261 -0
  81. package/src/questionnaire/types.ts +33 -12
  82. package/src/reviews/ReviewGate.tsx +250 -152
  83. package/src/reviews/index.ts +7 -3
  84. package/src/reviews/transport.ts +77 -3
  85. package/src/reviews/types.ts +13 -0
  86. package/src/session/persistedSession.ts +1 -1
  87. package/src/session-analytics/lifecycle.ts +1 -1
  88. package/src/session-analytics/useSessionStart.ts +2 -2
  89. package/src/showcase/FeatureShowcase.tsx +1 -1
  90. package/src/showcase/showcaseColors.ts +1 -1
  91. package/src/theme/mergeThemeOver.ts +27 -0
  92. package/src/types.ts +8 -0
  93. package/dist/transport-BeO_Brcu.d.mts +0 -40
  94. package/dist/transport-DLpd1v5_.d.ts +0 -40
@@ -16,6 +16,8 @@ import { useOnboardingTheme } from "../theme/ThemeContext";
16
16
  import { labelStyle } from "../theme/typography";
17
17
  import { Button } from "../components/Button";
18
18
  import { CardLayout } from "../components/CardLayout";
19
+ import { WireIcon } from "../icons/WireIcon";
20
+ import { normalizeOptions, optionsField, type CardOption } from "./optionSchema";
19
21
  import {
20
22
  CHECK_SCALE_FROM,
21
23
  CHECK_SPRING,
@@ -30,28 +32,17 @@ import { useReducedMotion } from "../motion/useReducedMotion";
30
32
 
31
33
  const easeWire = Easing.bezier(...WIRE_BEZIER);
32
34
 
33
- const optionObjectSchema = z.object({
34
- value: z.string().describe("Option value"),
35
- label: z.string().describe("Option display label"),
36
- });
37
-
38
35
  const schema = z.object({
39
36
  title: z.string().describe("Question or prompt for the user"),
40
- options: z
41
- .preprocess(
42
- (arr) =>
43
- Array.isArray(arr)
44
- ? arr.map((item) => (typeof item === "string" ? { value: item, label: item } : item))
45
- : arr,
46
- z.array(optionObjectSchema).min(1),
47
- )
48
- .describe("List of choices"),
37
+ options: optionsField("List of choices"),
49
38
  submitLabel: z.string().optional().describe("Submit button label, default: Continue"),
50
39
  multiSelect: z.boolean().optional().describe("Allow multiple selections"),
51
40
  });
52
41
 
53
- type Props = z.infer<typeof schema> &
42
+ type Props = Omit<z.infer<typeof schema>, "options"> &
54
43
  InjectedProps & {
44
+ /** Bare strings stay valid (normalized on the way in) — see optionSchema.ts. */
45
+ options: ReadonlyArray<string | CardOption>;
55
46
  onSelect?: (selected: string | string[]) => void;
56
47
  /** Fires as the live selection changes (before Continue), so the flow can prefetch the
57
48
  * next turn. Same value shape as onSelect. Optional — injected by OnboardingFlow. */
@@ -59,7 +50,7 @@ type Props = z.infer<typeof schema> &
59
50
  };
60
51
 
61
52
  type OptionRowProps = {
62
- opt: z.infer<typeof optionObjectSchema>;
53
+ opt: CardOption;
63
54
  isSelected: boolean;
64
55
  multiSelect: boolean;
65
56
  onToggle: (value: string) => void;
@@ -182,7 +173,11 @@ const _OptionRow: React.FC<OptionRowProps> = ({ opt, isSelected, multiSelect, on
182
173
  ) : null}
183
174
  </View>
184
175
  {/* Accent discipline: the label keeps the text color in both states; the
185
- accent lives on the border, fill, and dot only. */}
176
+ accent lives on the border, fill, and dot only. The icon obeys the same rule —
177
+ it holds `text` in BOTH states rather than flipping to the accent, because an
178
+ icon is not an excuse to invent a second accent. Decorative + unresolvable-safe:
179
+ an unknown name renders null, so the row lays out exactly as an icon-less one. */}
180
+ <WireIcon name={opt.icon} size={20} color={t.colors.text} />
186
181
  <Text style={[labelStyle(t.fonts), { color: t.colors.text }]}>{opt.label}</Text>
187
182
  </Animated.View>
188
183
  </Pressable>
@@ -199,6 +194,7 @@ const _SelectionCard: React.FC<Props> = ({
199
194
  onSelectionChange,
200
195
  }) => {
201
196
  const t = useOnboardingTheme();
197
+ const opts = React.useMemo(() => normalizeOptions(options), [options]);
202
198
  const [selected, setSelected] = useState<string[]>([]);
203
199
  const [submitted, setSubmitted] = useState(false);
204
200
 
@@ -239,7 +235,7 @@ const _SelectionCard: React.FC<Props> = ({
239
235
  }
240
236
  >
241
237
  <View style={{ gap: t.spacing.sm + t.spacing.xs }}>
242
- {options.map((opt) => (
238
+ {opts.map((opt) => (
243
239
  <OptionRow
244
240
  key={opt.value}
245
241
  opt={opt}
@@ -256,7 +252,7 @@ const _SelectionCard: React.FC<Props> = ({
256
252
  export const SelectionCard: WireAIComponent = {
257
253
  name: "SelectionCard",
258
254
  description:
259
- "Use for mutually exclusive (radio) or multi-select (checkbox) choices from 2–8 options. Use ChipSelectCard instead when options are short single words.",
255
+ "Use for mutually exclusive (radio) or multi-select (checkbox) choices from 2–8 options, as a vertical list of rows. Best when options are wordy or need reading. Options may carry an optional `icon` from the vocabulary to make the list scannable. Use ChipSelectCard instead when options are short single words; use CardGridSelectCard when the options are visual and each has an obvious icon (occasions, channels, categories).",
260
256
  component: React.memo(_SelectionCard) as WireAIComponent["component"],
261
257
  propsSchema: schema,
262
258
  defaultProps: { submitLabel: "Continue", multiSelect: false },
@@ -7,16 +7,28 @@ import type { WireAIComponent } from "wireai-rn";
7
7
  import { ChipSelectCard } from "./ChipSelectCard";
8
8
  import { TextInputCard } from "./TextInputCard";
9
9
  import { SelectionCard } from "./SelectionCard";
10
+ import { CardGridSelectCard } from "./CardGridSelectCard";
10
11
  import { StatusCard } from "./StatusCard";
11
12
  import { NumberStepperCard } from "./NumberStepperCard";
12
13
  import { InterstitialCard } from "./InterstitialCard";
13
14
 
14
- export { ChipSelectCard, TextInputCard, SelectionCard, StatusCard, NumberStepperCard, InterstitialCard };
15
+ export {
16
+ ChipSelectCard,
17
+ TextInputCard,
18
+ SelectionCard,
19
+ CardGridSelectCard,
20
+ StatusCard,
21
+ NumberStepperCard,
22
+ InterstitialCard,
23
+ };
24
+ export { normalizeOptions, optionsField, optionObjectSchema } from "./optionSchema";
25
+ export type { CardOption } from "./optionSchema";
15
26
 
16
27
  export const onboardingComponents: WireAIComponent[] = [
17
28
  ChipSelectCard,
18
29
  TextInputCard,
19
30
  SelectionCard,
31
+ CardGridSelectCard,
20
32
  StatusCard,
21
33
  NumberStepperCard,
22
34
  InterstitialCard,
@@ -0,0 +1,65 @@
1
+ /**
2
+ * The shared option shape for the choice cards (SelectionCard, CardGridSelectCard).
3
+ *
4
+ * ── BACK-COMPAT, IN BOTH DIRECTIONS (the hard gate) ──────────────────────────────────────
5
+ *
6
+ * NEW option → OLD kit: `z.object` STRIPS unknown keys, and the SDK renderer mounts
7
+ * `parsed.data` (not the raw props — see wireai-rn `renderer/ComponentRenderer.tsx`). So a
8
+ * client on kit 0.7.x that receives `{value, label, icon}` silently drops `icon` and renders
9
+ * the plain row it always did. No throw, no fallback card. This is what makes it safe to teach
10
+ * the server `icon` before every host has re-pinned — and it MUST stay true, so never make
11
+ * this schema `.strict()`.
12
+ *
13
+ * OLD option → NEW kit: the `preprocess` still coerces a bare `"Spring"` into
14
+ * `{value:"Spring", label:"Spring"}`, exactly as before. A bare string and a bare
15
+ * `{value,label}` both keep working untouched.
16
+ *
17
+ * `icon` is a plain `z.string()`, NOT a z.enum of the vocabulary. Deliberate: an unknown name
18
+ * must degrade to no icon (WireIcon does that), never fail validation. If it were an enum, one
19
+ * hallucinated or newer-than-this-client icon name would turn the whole card into a
20
+ * LLM_SCHEMA_ERROR and cost the user their question — a catastrophic trade for a decoration.
21
+ */
22
+ import { z } from "zod";
23
+
24
+ import { WIRE_ICON_NAMES } from "../icons/vocabulary";
25
+
26
+ /**
27
+ * The icon `.describe()` — an LLM ROUTING INSTRUCTION, not a type label (frequent_rules #4).
28
+ * The vocabulary is inlined so the model reads the allowed values right where it fills the
29
+ * field, and the list stays generated from the SSOT rather than drifting in a hand-typed copy.
30
+ */
31
+ export const ICON_FIELD_DESCRIPTION =
32
+ "Optional icon for this option, making the choice scannable at a glance. Pick the ONE name " +
33
+ "whose meaning matches the option (e.g. 'instagram' for an Instagram answer, 'friend' for " +
34
+ "'A friend told me', 'wedding' for a wedding). Omit it when no name fits — a wrong icon is " +
35
+ `worse than none. Allowed values: ${WIRE_ICON_NAMES.join(", ")}.`;
36
+
37
+ export const optionObjectSchema = z.object({
38
+ value: z.string().describe("Option value"),
39
+ label: z.string().describe("Option display label"),
40
+ icon: z.string().optional().describe(ICON_FIELD_DESCRIPTION),
41
+ });
42
+
43
+ export type CardOption = z.infer<typeof optionObjectSchema>;
44
+
45
+ /** A bare string stays a valid option: coerce it to `{value, label}` before validating. */
46
+ const coerceOptions = (arr: unknown) =>
47
+ Array.isArray(arr)
48
+ ? arr.map((item) => (typeof item === "string" ? { value: item, label: item } : item))
49
+ : arr;
50
+
51
+ /** The `options` field, with its card-specific routing description. */
52
+ export const optionsField = (description: string) =>
53
+ z.preprocess(coerceOptions, z.array(optionObjectSchema).min(1)).describe(description);
54
+
55
+ /**
56
+ * Component-level tolerance for the SAME coercion the schema does.
57
+ *
58
+ * WHY BOTH: in production the SDK renderer re-parses props and hands the card `parsed.data`, so
59
+ * options arrive normalized. But a card is a plain exported component — the canary suite and any
60
+ * host composing it directly mount it with raw props, where a bare string would otherwise read
61
+ * `opt.value === undefined` and render an empty, unkeyed row. Cheap, total, and keeps the
62
+ * component honest on its own contract instead of relying on a caller that may not exist.
63
+ */
64
+ export const normalizeOptions = (options: ReadonlyArray<string | CardOption>): CardOption[] =>
65
+ options.map((opt) => (typeof opt === "string" ? { value: opt, label: opt } : opt));
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Shared types for the coachmark / guided-tour overlay engine.
3
3
  *
4
- * Lifted from the Myelino app engine and made kit-generic: the app supplies the
4
+ * Lifted from a host app engine and made kit-generic: the app supplies the
5
5
  * anchors + catalog + copy, the kit owns the animation/measure/queue mechanics.
6
6
  * Dependency-free on purpose so the type surface can be imported anywhere.
7
7
  */
@@ -12,6 +12,16 @@
12
12
  * - "fade": loaders — enter/exit as a plain 120ms fade so the "thinking"
13
13
  * state hands off to the first card without a hard cut.
14
14
  *
15
+ * `direction` mirrors the whole slide for BACK navigation, so a step the user
16
+ * returns to comes back from the side it left toward. Spatial consistency: if
17
+ * "next" pushes left, "back" must pull right, or the stack loses its geography.
18
+ *
19
+ * `layout` picks how the moving surface is sized. "fill" (default) is the
20
+ * full-screen flow, where the pane owns the viewport. "auto" is for a pane
21
+ * inside an auto-height container (the CenteredModal card): `flex: 1` there
22
+ * resolves to a ZERO-height child under Yoga — flex-basis 0 with no free space
23
+ * to grow into — so the in-flow layer must size to its own content instead.
24
+ *
15
25
  * Reduce motion: every handoff becomes a 120ms crossfade, no translate/scale.
16
26
  * All values run on the native driver (transform/opacity only); in-flight
17
27
  * animations are stopped when superseded and on unmount.
@@ -40,11 +50,21 @@ const easeWire = Easing.bezier(...WIRE_BEZIER);
40
50
 
41
51
  export type CardHandoffVariant = "slide" | "spring" | "fade";
42
52
 
53
+ /** Which way the stack is moving. "backward" mirrors the slide (see the header note). */
54
+ export type CardHandoffDirection = "forward" | "backward";
55
+
56
+ /** How the moving surface is sized. "auto" = a pane in an auto-height container. */
57
+ export type CardHandoffLayout = "fill" | "auto";
58
+
43
59
  export type CardHandoffProps = {
44
60
  /** Identity of the current child (e.g. the message id). A change runs the handoff. */
45
61
  transitionKey: string;
46
62
  /** How the CURRENT child enters (and, snapshotted, how it will later exit). */
47
63
  variant?: CardHandoffVariant;
64
+ /** Travel direction of THIS transition. Default "forward". */
65
+ direction?: CardHandoffDirection;
66
+ /** Sizing mode. Default "fill" (the full-screen flow). */
67
+ layout?: CardHandoffLayout;
48
68
  children: React.ReactNode;
49
69
  };
50
70
 
@@ -53,6 +73,8 @@ type Snapshot = { key: string; node: React.ReactNode; variant: CardHandoffVarian
53
73
  const _CardHandoff: React.FC<CardHandoffProps> = ({
54
74
  transitionKey,
55
75
  variant = "slide",
76
+ direction = "forward",
77
+ layout = "fill",
56
78
  children,
57
79
  }) => {
58
80
  const reduced = useReducedMotion();
@@ -118,7 +140,7 @@ const _CardHandoff: React.FC<CardHandoffProps> = ({
118
140
 
119
141
  const enterStyle = {
120
142
  opacity: enterT,
121
- transform: transformFor(variant, enterT, reduced, "enter"),
143
+ transform: transformFor(variant, enterT, reduced, "enter", direction),
122
144
  };
123
145
 
124
146
  return (
@@ -130,14 +152,17 @@ const _CardHandoff: React.FC<CardHandoffProps> = ({
130
152
  StyleSheet.absoluteFill,
131
153
  {
132
154
  opacity: exitT.interpolate({ inputRange: [0, 1], outputRange: [1, 0] }),
133
- transform: transformFor(leaving.variant, exitT, reduced, "exit"),
155
+ // The CURRENT direction drives BOTH layers: one transition, one geography.
156
+ transform: transformFor(leaving.variant, exitT, reduced, "exit", direction),
134
157
  },
135
158
  ]}
136
159
  >
137
160
  {leaving.node}
138
161
  </Animated.View>
139
162
  ) : null}
140
- <Animated.View key={variant} style={[styles.fill, enterStyle]}>{children}</Animated.View>
163
+ <Animated.View key={variant} style={[layout === "fill" ? styles.fill : null, enterStyle]}>
164
+ {children}
165
+ </Animated.View>
141
166
  </>
142
167
  );
143
168
  };
@@ -180,22 +205,29 @@ const startEnter = (
180
205
  return anim;
181
206
  };
182
207
 
183
- /** Transform track per variant/direction; reduce motion = opacity-only crossfade. */
208
+ /**
209
+ * Transform track per variant/layer; reduce motion = opacity-only crossfade.
210
+ *
211
+ * `travel` mirrors the X axis: forward pushes the stack left (new arrives from the
212
+ * right), backward pulls it right (new arrives from the left).
213
+ */
184
214
  const transformFor = (
185
215
  variant: CardHandoffVariant,
186
216
  t: Animated.Value,
187
217
  reduced: boolean,
188
- direction: "enter" | "exit",
218
+ layer: "enter" | "exit",
219
+ travel: CardHandoffDirection,
189
220
  ) => {
190
221
  if (reduced || variant === "fade") return [];
191
- if (variant === "spring" && direction === "enter") {
222
+ if (variant === "spring" && layer === "enter") {
192
223
  return [{ scale: t.interpolate({ inputRange: [0, 1], outputRange: [CARD_ENTER_SCALE_FROM, 1] }) }];
193
224
  }
225
+ const slide = travel === "forward" ? CARD_SLIDE_PX : -CARD_SLIDE_PX;
194
226
  return [
195
227
  {
196
228
  translateX: t.interpolate({
197
229
  inputRange: [0, 1],
198
- outputRange: direction === "enter" ? [CARD_SLIDE_PX, 0] : [0, -CARD_SLIDE_PX],
230
+ outputRange: layer === "enter" ? [slide, 0] : [0, -slide],
199
231
  }),
200
232
  },
201
233
  ];
@@ -0,0 +1,94 @@
1
+ /**
2
+ * DoneCheck — the animated "done" payoff glyph shared by the review gate's and the
3
+ * questionnaire's thank-you panes. One component, two call sites, one feel.
4
+ *
5
+ * The beat is the house success beat, lifted from StatusCard (design-refs/
6
+ * EXTRACTED-SPEC.md rows 14/16): the glyph springs in with a slight overshoot
7
+ * (STATUS_POP_SPRING, scale STATUS_POP_SCALE_FROM→1) while the ring settles behind it.
8
+ * No new motion values are invented here — every number comes from motionSpec.
9
+ *
10
+ * It is a Text glyph in a themed circle, NOT an SVG path: `react-native-svg` is not a
11
+ * peer of this kit, and adding a NATIVE dependency to draw a checkmark would force every
12
+ * host into a rebuild. The five review stars are already glyphs, so this matches the
13
+ * house idiom.
14
+ *
15
+ * Reduce motion: the final frame, instantly — never a frozen mid-pop.
16
+ *
17
+ * Accessibility: purely decorative. The thank-you copy next to it carries the meaning,
18
+ * so the glyph is hidden from screen readers rather than announced as "heavy check mark".
19
+ */
20
+ import React, { useEffect, useRef } from "react";
21
+ import { Animated, StyleSheet } from "react-native";
22
+
23
+ import { STATUS_POP_SCALE_FROM, STATUS_POP_SPRING } from "../motion/motionSpec";
24
+ import { useReducedMotion } from "../motion/useReducedMotion";
25
+ import type { OnboardingTheme } from "../theme/types";
26
+
27
+ export type DoneCheckProps = {
28
+ /** The (already merged) active theme — the ring reads `colors.success`. */
29
+ theme: OnboardingTheme;
30
+ /** Diameter of the ring. Defaults to a size that balances the modal heading. */
31
+ size?: number;
32
+ };
33
+
34
+ const _DoneCheck: React.FC<DoneCheckProps> = ({ theme, size = 48 }) => {
35
+ const reduced = useReducedMotion();
36
+ const pop = useRef(new Animated.Value(reduced ? 1 : 0)).current;
37
+
38
+ useEffect(() => {
39
+ if (reduced) {
40
+ pop.setValue(1);
41
+ return;
42
+ }
43
+ const anim = Animated.spring(pop, {
44
+ toValue: 1,
45
+ friction: STATUS_POP_SPRING.friction,
46
+ tension: STATUS_POP_SPRING.tension,
47
+ useNativeDriver: true,
48
+ });
49
+ anim.start();
50
+ return () => anim.stop();
51
+ }, [pop, reduced]);
52
+
53
+ const scale = pop.interpolate({
54
+ inputRange: [0, 1],
55
+ outputRange: [STATUS_POP_SCALE_FROM, 1],
56
+ });
57
+
58
+ return (
59
+ <Animated.View
60
+ accessibilityElementsHidden
61
+ importantForAccessibility="no-hide-descendants"
62
+ style={[
63
+ styles.ring,
64
+ {
65
+ width: size,
66
+ height: size,
67
+ borderRadius: theme.radius.full,
68
+ backgroundColor: theme.colors.success,
69
+ opacity: pop,
70
+ transform: [{ scale }],
71
+ },
72
+ ]}
73
+ >
74
+ <Animated.Text
75
+ style={[
76
+ styles.glyph,
77
+ { color: theme.colors.onPrimary, fontSize: Math.round(size * 0.5), fontFamily: theme.fonts.bold },
78
+ ]}
79
+ >
80
+
81
+ </Animated.Text>
82
+ </Animated.View>
83
+ );
84
+ };
85
+
86
+ export const DoneCheck = React.memo(_DoneCheck);
87
+ DoneCheck.displayName = "DoneCheck";
88
+
89
+ const styles = StyleSheet.create({
90
+ ring: { alignItems: "center", justifyContent: "center", alignSelf: "center" },
91
+ glyph: { includeFontPadding: false, textAlign: "center" },
92
+ });
93
+
94
+ export default DoneCheck;
@@ -0,0 +1,141 @@
1
+ /**
2
+ * ModalPaneHost — "slide between panes inside a CenteredModal", the shared primitive
3
+ * behind BOTH the review gate's phase changes and the questionnaire's step changes.
4
+ * One primitive, two call sites: the two gates must never drift apart on feel.
5
+ *
6
+ * It does NOT reimplement the slide. `CardHandoff` already owns the design's
7
+ * "cards hand off, they don't cut" motion (exit left 180ms / enter right 220ms, 80ms
8
+ * lead, reduce-motion crossfade), so this delegates to it in `layout="auto"` mode and
9
+ * adds the ONE thing a modal needs that a full-screen flow does not: a card that
10
+ * resizes instead of snapping.
11
+ *
12
+ * WHY the height animation exists: the CenteredModal card is auto-height, so the moment
13
+ * a taller/shorter pane mounts, the card would jump to its new height in a single frame
14
+ * while the old pane is still sliding out — the exact "jarring change" the slide was
15
+ * added to remove. Measuring the pane and easing the card to it keeps one continuous
16
+ * surface.
17
+ *
18
+ * WHY it is the only non-native-driven value in the kit: height is a layout prop, so it
19
+ * cannot ride the native driver. That is a deliberate, bounded exception — ONE property
20
+ * on ONE small card, for ~220ms, while nothing is scrolling. The panes' own opacity and
21
+ * translate (inside CardHandoff) stay on the native driver, and the two values live on
22
+ * different views, so they never contend. Everything else in the kit stays transform/
23
+ * opacity-only.
24
+ *
25
+ * Height is eased ONLY on a pane change. A multiline box growing as the user types also
26
+ * fires onLayout, and animating that would make typing feel laggy — those resize
27
+ * instantly. Reduce motion: the height snaps too (movement removed, crossfade kept).
28
+ *
29
+ * `header` is the STATIC slot: chrome that belongs to the card rather than to any one pane
30
+ * (the questionnaire's back icon + step dots). It sits inside the measured container but
31
+ * OUTSIDE the CardHandoff, which buys two things a caller cannot get by rendering it above
32
+ * the host:
33
+ * 1. It never slides, so it is never DUPLICATED mid-handoff. Anything inside CardHandoff
34
+ * exists twice for ~300ms (the outgoing layer and the incoming one) — fine for a
35
+ * question that is genuinely changing, wrong for chrome that is not. Two dot rows
36
+ * cross-fading past each other is a bug, not a transition.
37
+ * 2. Its size is part of the measured height, so dropping the header (form → thanks)
38
+ * rides the same eased resize as the pane swap instead of snapping the card.
39
+ */
40
+ import React, { useCallback, useEffect, useRef, useState } from "react";
41
+ import { Animated, Easing, StyleSheet, View, type LayoutChangeEvent } from "react-native";
42
+
43
+ import { CARD_ENTER_MS, WIRE_BEZIER, scaledMs } from "../motion/motionSpec";
44
+ import { useReducedMotion } from "../motion/useReducedMotion";
45
+ import { CardHandoff, type CardHandoffDirection } from "./CardHandoff";
46
+
47
+ const easeWire = Easing.bezier(...WIRE_BEZIER);
48
+
49
+ export type ModalPaneHostProps = {
50
+ /** Identity of the pane on screen (phase name / step key). A change runs the handoff. */
51
+ paneKey: string;
52
+ /** Travel direction — "backward" mirrors the slide when the user steps back. */
53
+ direction?: CardHandoffDirection;
54
+ /**
55
+ * Card-level chrome pinned above the sliding pane: measured with it, but never moved
56
+ * and never duplicated by the handoff. Omit it (or pass null) and nothing renders —
57
+ * dropping it between pane changes animates the card's height rather than snapping it.
58
+ * The node owns its own bottom spacing; the host adds none (it has no theme).
59
+ */
60
+ header?: React.ReactNode;
61
+ children: React.ReactNode;
62
+ };
63
+
64
+ const _ModalPaneHost: React.FC<ModalPaneHostProps> = ({
65
+ paneKey,
66
+ direction = "forward",
67
+ header,
68
+ children,
69
+ }) => {
70
+ const reduced = useReducedMotion();
71
+ const height = useRef(new Animated.Value(0)).current;
72
+ /** Last height we settled on. null until the first real measurement lands. */
73
+ const measured = useRef<number | null>(null);
74
+ /** Drives the height style on only AFTER a real measurement, so nothing renders at 0. */
75
+ const [sized, setSized] = useState(false);
76
+ /** Set when the pane changed, cleared by the next measurement it causes. */
77
+ const paneChanged = useRef(false);
78
+ const running = useRef<Animated.CompositeAnimation | null>(null);
79
+
80
+ // Runs on mount and on every pane change, always BEFORE the resulting onLayout (which
81
+ // arrives from native after the commit) — so the next measurement knows why it fired.
82
+ useEffect(() => {
83
+ paneChanged.current = true;
84
+ }, [paneKey]);
85
+
86
+ useEffect(() => () => running.current?.stop(), []);
87
+
88
+ const onLayout = useCallback(
89
+ (event: LayoutChangeEvent) => {
90
+ const next = Math.round(event.nativeEvent.layout.height);
91
+ if (!next) return;
92
+ const previous = measured.current;
93
+ if (previous !== null && Math.abs(previous - next) < 1) return;
94
+
95
+ const first = previous === null;
96
+ const ease = !first && !reduced && paneChanged.current;
97
+ paneChanged.current = false;
98
+ measured.current = next;
99
+ running.current?.stop();
100
+
101
+ // First measurement, a reduce-motion user, or a plain content resize (typing):
102
+ // take the new height immediately.
103
+ if (!ease) {
104
+ height.setValue(next);
105
+ if (first) setSized(true);
106
+ return;
107
+ }
108
+
109
+ const anim = Animated.timing(height, {
110
+ toValue: next,
111
+ duration: scaledMs(CARD_ENTER_MS),
112
+ easing: easeWire,
113
+ // height is layout, not transform — see the header note.
114
+ useNativeDriver: false,
115
+ });
116
+ running.current = anim;
117
+ anim.start();
118
+ },
119
+ [height, reduced],
120
+ );
121
+
122
+ return (
123
+ <Animated.View style={sized ? { height, overflow: "hidden" } : undefined}>
124
+ <View onLayout={onLayout} style={styles.pane}>
125
+ {header}
126
+ <CardHandoff transitionKey={paneKey} direction={direction} layout="auto" variant="slide">
127
+ {children}
128
+ </CardHandoff>
129
+ </View>
130
+ </Animated.View>
131
+ );
132
+ };
133
+
134
+ export const ModalPaneHost = React.memo(_ModalPaneHost);
135
+ ModalPaneHost.displayName = "ModalPaneHost";
136
+
137
+ const styles = StyleSheet.create({
138
+ pane: { width: "100%" },
139
+ });
140
+
141
+ export default ModalPaneHost;
@@ -0,0 +1,112 @@
1
+ /**
2
+ * StepDots — the "how many steps, and which one am I on" row for a multi-step modal.
3
+ *
4
+ * It replaces the questionnaire's textual counter ("Question 2 of 3"). Malik's device
5
+ * feedback 2026-07-16: "instead of 1 out of 3 or 2-3, had points on top to explain the
6
+ * number of steps". A dot row says the same two things — how long this is, how far in you
7
+ * are — without spending a line of copy next to the question the user should be reading.
8
+ *
9
+ * NOT the same component as `StepProgress`: that one is the ONBOARDING flow's bar, and it
10
+ * deliberately refuses to show a count because the flow's length is model-decided and a
11
+ * number would be a promise the model may not keep. The questionnaire is the opposite
12
+ * case — a fixed 3-field server contract — so here the count is knowable and honest.
13
+ *
14
+ * Motion: a dot fills by crossfading a `colors.primary` layer over a `colors.border` base,
15
+ * at the design system's selection-fill duration (SELECT_FILL_MS, spec rows 2/3/5/6 — the
16
+ * same token every other "this is now selected" fill in the kit uses). Opacity only, on the
17
+ * native driver: no color interpolation (that would force `useNativeDriver: false`, and the
18
+ * kit keeps its one non-native exception in ModalPaneHost's height).
19
+ *
20
+ * Reduce motion: `stateChangeDuration` collapses the fill to 0ms, so the state snaps —
21
+ * the house convention for state changes (see StepProgress).
22
+ *
23
+ * Accessibility: sighted users read the dots; screen readers must not be handed three
24
+ * unlabelled circles. The ROW is one progressbar node carrying "Step 2 of 3" plus the
25
+ * numeric value, and the dots themselves are hidden as decoration.
26
+ */
27
+ import React, { useEffect, useRef } from "react";
28
+ import { Animated, Easing, StyleSheet, View } from "react-native";
29
+
30
+ import { SELECT_FILL_MS, WIRE_BEZIER, scaledMs, stateChangeDuration } from "../motion/motionSpec";
31
+ import { useReducedMotion } from "../motion/useReducedMotion";
32
+ import type { OnboardingTheme } from "../theme/types";
33
+
34
+ const easeWire = Easing.bezier(...WIRE_BEZIER);
35
+
36
+ export type StepDotsProps = {
37
+ /** The (already merged) active theme — dots read `colors.border` / `colors.primary`. */
38
+ theme: OnboardingTheme;
39
+ /** How many steps there are (how many dots to draw). */
40
+ count: number;
41
+ /** 0-based index of the step on screen. */
42
+ index: number;
43
+ };
44
+
45
+ type DotProps = {
46
+ theme: OnboardingTheme;
47
+ /** True for the current step and every step behind it — the row reads as progress. */
48
+ filled: boolean;
49
+ size: number;
50
+ };
51
+
52
+ const Dot: React.FC<DotProps> = ({ theme, filled, size }) => {
53
+ const reduced = useReducedMotion();
54
+ const fill = useRef(new Animated.Value(filled ? 1 : 0)).current;
55
+
56
+ useEffect(() => {
57
+ const anim = Animated.timing(fill, {
58
+ toValue: filled ? 1 : 0,
59
+ duration: scaledMs(stateChangeDuration(reduced, SELECT_FILL_MS)),
60
+ easing: easeWire,
61
+ useNativeDriver: true,
62
+ });
63
+ anim.start();
64
+ return () => anim.stop();
65
+ }, [fill, filled, reduced]);
66
+
67
+ return (
68
+ <View
69
+ style={{
70
+ width: size,
71
+ height: size,
72
+ borderRadius: theme.radius.full,
73
+ backgroundColor: theme.colors.border,
74
+ }}
75
+ >
76
+ <Animated.View
77
+ style={[
78
+ StyleSheet.absoluteFill,
79
+ {
80
+ borderRadius: theme.radius.full,
81
+ backgroundColor: theme.colors.primary,
82
+ opacity: fill,
83
+ },
84
+ ]}
85
+ />
86
+ </View>
87
+ );
88
+ };
89
+
90
+ const _StepDots: React.FC<StepDotsProps> = ({ theme, count, index }) => (
91
+ <View
92
+ style={[styles.row, { gap: theme.spacing.sm }]}
93
+ accessibilityRole="progressbar"
94
+ accessibilityLabel={`Step ${index + 1} of ${count}`}
95
+ accessibilityValue={{ min: 1, max: count, now: index + 1 }}
96
+ >
97
+ {Array.from({ length: count }, (_, i) => (
98
+ <View key={i} accessibilityElementsHidden importantForAccessibility="no-hide-descendants">
99
+ <Dot theme={theme} filled={i <= index} size={theme.spacing.sm} />
100
+ </View>
101
+ ))}
102
+ </View>
103
+ );
104
+
105
+ export const StepDots = React.memo(_StepDots);
106
+ StepDots.displayName = "StepDots";
107
+
108
+ const styles = StyleSheet.create({
109
+ row: { flexDirection: "row", alignItems: "center", justifyContent: "center" },
110
+ });
111
+
112
+ export default StepDots;