@wireai/activation 0.8.0 → 0.9.1

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 (91) hide show
  1. package/AGENTS.md +29 -5
  2. package/CHANGELOG.md +202 -6
  3. package/README.md +105 -10
  4. package/dist/analytics/index.d.mts +6 -6
  5. package/dist/analytics/index.d.ts +6 -6
  6. package/dist/analytics/index.js +447 -49
  7. package/dist/analytics/index.js.map +1 -1
  8. package/dist/analytics/index.mjs +447 -49
  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-f7LWcdWG.d.ts → currentSession-61dcm3V-.d.ts} +10 -2
  15. package/dist/{currentSession-d9CrBxwe.d.mts → currentSession-Bs2JfTJ8.d.mts} +10 -2
  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 +558 -15
  19. package/dist/index.d.ts +558 -15
  20. package/dist/index.js +1144 -375
  21. package/dist/index.js.map +1 -1
  22. package/dist/index.mjs +969 -207
  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-B31G0Cib.d.ts +128 -0
  41. package/dist/transport-Bzb-bcB2.d.mts +128 -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/currentSession.ts +1 -1
  53. package/src/cards/CardGridSelectCard.tsx +253 -0
  54. package/src/cards/ChipSelectCard.tsx +1 -1
  55. package/src/cards/SelectionCard.tsx +15 -19
  56. package/src/cards/index.ts +13 -1
  57. package/src/cards/optionSchema.ts +65 -0
  58. package/src/coachmarks/types.ts +1 -1
  59. package/src/components/CardHandoff.tsx +39 -7
  60. package/src/components/DoneCheck.tsx +94 -0
  61. package/src/components/ModalPaneHost.tsx +141 -0
  62. package/src/components/StepDots.tsx +112 -0
  63. package/src/device/appVersion.ts +29 -8
  64. package/src/device/deviceModel.ts +21 -8
  65. package/src/features/cache.ts +1 -1
  66. package/src/haptics/expo-haptics.d.ts +31 -0
  67. package/src/haptics/haptics.ts +58 -0
  68. package/src/icons/IconRegistry.tsx +53 -0
  69. package/src/icons/WireIcon.tsx +85 -0
  70. package/src/icons/expoIcons.ts +165 -0
  71. package/src/icons/index.ts +18 -0
  72. package/src/icons/vocabulary.ts +154 -0
  73. package/src/index.ts +20 -0
  74. package/src/questionnaire/QuestionnaireGate.tsx +315 -164
  75. package/src/questionnaire/decision.ts +4 -2
  76. package/src/questionnaire/index.ts +3 -2
  77. package/src/questionnaire/steps.ts +261 -0
  78. package/src/questionnaire/types.ts +33 -12
  79. package/src/reviews/ReviewGate.tsx +250 -152
  80. package/src/reviews/index.ts +7 -3
  81. package/src/reviews/transport.ts +119 -3
  82. package/src/reviews/types.ts +13 -0
  83. package/src/session/persistedSession.ts +1 -1
  84. package/src/session-analytics/lifecycle.ts +1 -1
  85. package/src/session-analytics/useSessionStart.ts +2 -2
  86. package/src/showcase/FeatureShowcase.tsx +1 -1
  87. package/src/showcase/showcaseColors.ts +1 -1
  88. package/src/theme/mergeThemeOver.ts +27 -0
  89. package/src/types.ts +8 -0
  90. package/dist/transport-BeO_Brcu.d.mts +0 -40
  91. package/dist/transport-DLpd1v5_.d.ts +0 -40
@@ -0,0 +1,253 @@
1
+ /**
2
+ * CardGridSelectCard — icon-led choices as a two-column grid of small cards.
3
+ *
4
+ * The shape Malik asked for (2026-07-16): "the multi select needs to be a small card like this
5
+ * one, with an icon on top, it is much better." Icon top-left, short label beneath, the whole
6
+ * card is the tap target, selection lives on the card itself.
7
+ *
8
+ * ┌──────────────┐ ┌──────────────┐
9
+ * │ ◇ │ │ ▣ │ ← icon on top
10
+ * │ Wedding day │ │ Birthday │ ← label beneath
11
+ * └──────────────┘ └──────────────┘
12
+ *
13
+ * WHY A NEW CARD RATHER THAN A `display` MODE ON SelectionCard: the card's `description` IS the
14
+ * router — it is the only thing the model reads when choosing (frequent_rules #4). SelectionCard
15
+ * already carries two modes behind one description; a third ("a list, but sometimes a grid, and
16
+ * then the icons matter") cannot be stated honestly in one string, and a muddy description makes
17
+ * the model pick the wrong card. The kit already has the precedent: ChipSelectCard exists ONLY
18
+ * because "compact vs. descriptive" is a presentation difference worth its own name, over the
19
+ * very same pick-from-options job. Back-compat costs nothing here — `supportedComponents`
20
+ * (WireOnboarding.tsx) means an already-shipped client never advertises this name, so the server
21
+ * intersects it away and can never emit it to the apps already in the stores. See the CHANGELOG.
22
+ *
23
+ * Layout is percentage flex, never a measured width: `flexBasis` ~47% + `flexGrow` makes exactly
24
+ * two per row that fill the width, with no Dimensions read, no onLayout, and no re-render on
25
+ * rotation. An odd last card stretches the row — deliberate, and the same thing every native
26
+ * grid does.
27
+ *
28
+ * Motion mirrors SelectionCard's OptionRow exactly (press tick spring + a native-driver opacity
29
+ * crossfade of the selected overlay), so the two cards feel like one system.
30
+ */
31
+ import React, { useCallback, useEffect, useRef, useState } from "react";
32
+ import { Animated, Easing, Pressable, StyleSheet, Text, View } from "react-native";
33
+ import { z } from "zod";
34
+ import type { InjectedProps, WireAIComponent } from "wireai-rn";
35
+ import { useOnboardingTheme } from "../theme/ThemeContext";
36
+ import { labelStyle } from "../theme/typography";
37
+ import { Button } from "../components/Button";
38
+ import { CardLayout } from "../components/CardLayout";
39
+ import { WireIcon } from "../icons/WireIcon";
40
+ import { normalizeOptions, optionsField, type CardOption } from "./optionSchema";
41
+ import {
42
+ PRESS_SPRING,
43
+ ROW_PRESS_SCALE,
44
+ SELECT_FILL_MS,
45
+ WIRE_BEZIER,
46
+ scaledMs,
47
+ stateChangeDuration,
48
+ } from "../motion/motionSpec";
49
+ import { useReducedMotion } from "../motion/useReducedMotion";
50
+
51
+ const easeWire = Easing.bezier(...WIRE_BEZIER);
52
+
53
+ const schema = z.object({
54
+ title: z.string().describe("Question or prompt for the user, e.g. 'What brings you here?'"),
55
+ options: optionsField(
56
+ "The choices, 2-6 of them. Give EVERY option an `icon` from the vocabulary — the icon is " +
57
+ "the point of this card, and an option without one renders as a bare label next to " +
58
+ "icon-bearing neighbours. Keep labels short (1-3 words) so they fit a half-width card.",
59
+ ),
60
+ submitLabel: z.string().optional().describe("Submit button label, default: Continue"),
61
+ multiSelect: z
62
+ .boolean()
63
+ .optional()
64
+ .describe("Allow multiple cards selected at once, default true"),
65
+ });
66
+
67
+ type Props = Omit<z.infer<typeof schema>, "options"> &
68
+ InjectedProps & {
69
+ /** Bare strings stay valid (normalized on the way in) — see optionSchema.ts. */
70
+ options: ReadonlyArray<string | CardOption>;
71
+ onSelect?: (selected: string | string[]) => void;
72
+ /** Fires as the live selection changes (before Continue), so the flow can prefetch the
73
+ * next turn. Same value shape as onSelect. Optional — injected by OnboardingFlow. */
74
+ onSelectionChange?: (selected: string | string[]) => void;
75
+ };
76
+
77
+ type OptionCardProps = {
78
+ opt: CardOption;
79
+ isSelected: boolean;
80
+ multiSelect: boolean;
81
+ onToggle: (value: string) => void;
82
+ };
83
+
84
+ const _OptionCard: React.FC<OptionCardProps> = ({ opt, isSelected, multiSelect, onToggle }) => {
85
+ const t = useOnboardingTheme();
86
+ const reduced = useReducedMotion();
87
+ const handlePress = useCallback(() => onToggle(opt.value), [opt.value, onToggle]);
88
+
89
+ // Press tick: scale 1→0.975, spring, native driver. Skipped under reduce motion.
90
+ const pressScale = useRef(new Animated.Value(1)).current;
91
+ const pressTo = useCallback(
92
+ (to: number) => {
93
+ if (reduced) return;
94
+ Animated.spring(pressScale, {
95
+ toValue: to,
96
+ friction: PRESS_SPRING.friction,
97
+ tension: PRESS_SPRING.tension,
98
+ useNativeDriver: true,
99
+ }).start();
100
+ },
101
+ [pressScale, reduced],
102
+ );
103
+ const handlePressIn = useCallback(() => pressTo(ROW_PRESS_SCALE), [pressTo]);
104
+ const handlePressOut = useCallback(() => pressTo(1), [pressTo]);
105
+
106
+ // Selected fill: accent border + tinted wash on an absolute overlay whose opacity crossfades
107
+ // (native driver — see EXTRACTED-SPEC.md D7). Reduce motion: snap.
108
+ const selectT = useRef(new Animated.Value(isSelected ? 1 : 0)).current;
109
+ useEffect(() => {
110
+ const anim = Animated.timing(selectT, {
111
+ toValue: isSelected ? 1 : 0,
112
+ duration: scaledMs(stateChangeDuration(reduced, SELECT_FILL_MS)),
113
+ easing: easeWire,
114
+ useNativeDriver: true,
115
+ });
116
+ anim.start();
117
+ return () => anim.stop();
118
+ }, [isSelected, reduced, selectT]);
119
+
120
+ return (
121
+ <Pressable
122
+ onPress={handlePress}
123
+ onPressIn={handlePressIn}
124
+ onPressOut={handlePressOut}
125
+ accessibilityRole={multiSelect ? "checkbox" : "radio"}
126
+ accessibilityState={multiSelect ? { checked: isSelected } : { selected: isSelected }}
127
+ accessibilityLabel={opt.label}
128
+ style={styles.cell}
129
+ >
130
+ <Animated.View
131
+ style={[
132
+ styles.card,
133
+ {
134
+ padding: t.spacing.md,
135
+ borderRadius: t.radius.lg,
136
+ gap: t.spacing.sm,
137
+ borderColor: t.colors.border,
138
+ backgroundColor: t.colors.surface,
139
+ transform: [{ scale: pressScale }],
140
+ },
141
+ ]}
142
+ >
143
+ <Animated.View
144
+ pointerEvents="none"
145
+ style={[
146
+ StyleSheet.absoluteFill,
147
+ styles.selectedOverlay,
148
+ {
149
+ borderRadius: t.radius.lg,
150
+ borderColor: t.colors.primary,
151
+ backgroundColor: t.colors.primarySoft,
152
+ opacity: selectT,
153
+ },
154
+ ]}
155
+ />
156
+ {/* Accent discipline (same rule as SelectionCard's rows): label AND icon keep the text
157
+ color in both states; the accent lives on the border and fill only. An unresolvable
158
+ icon renders null, so a card with a bad name lays out like one with no icon. */}
159
+ <WireIcon name={opt.icon} size={24} color={t.colors.text} />
160
+ <Text style={[labelStyle(t.fonts), { color: t.colors.text }]}>{opt.label}</Text>
161
+ </Animated.View>
162
+ </Pressable>
163
+ );
164
+ };
165
+ const OptionCard = React.memo(_OptionCard);
166
+
167
+ const _CardGridSelectCard: React.FC<Props> = ({
168
+ title,
169
+ options,
170
+ submitLabel = "Continue",
171
+ multiSelect = true,
172
+ onSelect,
173
+ onSelectionChange,
174
+ }) => {
175
+ const t = useOnboardingTheme();
176
+ const opts = React.useMemo(() => normalizeOptions(options), [options]);
177
+ const [selected, setSelected] = useState<string[]>([]);
178
+ const [submitted, setSubmitted] = useState(false);
179
+
180
+ // Report the settled selection upward (before Continue) so the flow can prefetch the next
181
+ // turn. Debouncing lives in the flow; here we just emit on every change once something's picked.
182
+ useEffect(() => {
183
+ if (!submitted && selected.length) onSelectionChange?.(multiSelect ? selected : selected[0]!);
184
+ }, [selected, submitted, multiSelect, onSelectionChange]);
185
+
186
+ const toggle = useCallback(
187
+ (value: string) => {
188
+ if (submitted) return;
189
+ if (multiSelect) {
190
+ setSelected((prev) =>
191
+ prev.includes(value) ? prev.filter((v) => v !== value) : [...prev, value],
192
+ );
193
+ } else {
194
+ setSelected([value]);
195
+ }
196
+ },
197
+ [submitted, multiSelect],
198
+ );
199
+
200
+ const handleSubmit = useCallback(() => {
201
+ if (!selected.length || !onSelect || submitted) return;
202
+ setSubmitted(true);
203
+ onSelect(multiSelect ? selected : selected[0]!);
204
+ }, [selected, onSelect, submitted, multiSelect]);
205
+
206
+ return (
207
+ <CardLayout
208
+ title={title}
209
+ footer={
210
+ <Button
211
+ title={submitLabel}
212
+ onPress={handleSubmit}
213
+ variant="primary"
214
+ disabled={selected.length === 0 || submitted}
215
+ />
216
+ }
217
+ >
218
+ <View style={[styles.grid, { gap: t.spacing.sm }]}>
219
+ {opts.map((opt) => (
220
+ <OptionCard
221
+ key={opt.value}
222
+ opt={opt}
223
+ isSelected={selected.includes(opt.value)}
224
+ multiSelect={multiSelect}
225
+ onToggle={toggle}
226
+ />
227
+ ))}
228
+ </View>
229
+ </CardLayout>
230
+ );
231
+ };
232
+
233
+ export const CardGridSelectCard: WireAIComponent = {
234
+ name: "CardGridSelectCard",
235
+ description:
236
+ "Use for VISUAL, icon-led choices shown as a 2-column grid of tappable cards (icon on top, " +
237
+ "short label beneath). Ideal for 2–6 concrete options that each have an obvious icon — " +
238
+ "occasions, channels, goals, categories — e.g. 'What brings you here?' or 'Where did you " +
239
+ "hear about us?'. Multi-select by default. Every option should carry an `icon`. Use " +
240
+ "SelectionCard instead when options need descriptions or have no natural icon; use " +
241
+ "ChipSelectCard for many short word-like tags.",
242
+ component: React.memo(_CardGridSelectCard) as WireAIComponent["component"],
243
+ propsSchema: schema,
244
+ defaultProps: { submitLabel: "Continue", multiSelect: true },
245
+ };
246
+
247
+ const styles = StyleSheet.create({
248
+ grid: { flexDirection: "row", flexWrap: "wrap" },
249
+ // Two per row: ~47% basis leaves room for the gap, flexGrow fills the remainder exactly.
250
+ cell: { flexBasis: "47%", flexGrow: 1 },
251
+ card: { borderWidth: 1.5, alignItems: "flex-start", overflow: "hidden" },
252
+ selectedOverlay: { borderWidth: 1.5 },
253
+ });
@@ -250,7 +250,7 @@ const _ChipSelectCard: React.FC<Props> = ({
250
250
  export const ChipSelectCard: WireAIComponent = {
251
251
  name: "ChipSelectCard",
252
252
  description:
253
- "Use for compact short labels: seasons, activities, travel styles, moods, categories. Ideal for 4–12 single-word or two-word options. Use SelectionCard instead when options need descriptions.",
253
+ "Use for compact short labels: seasons, activities, travel styles, moods, categories. Ideal for 4–12 single-word or two-word options. Use SelectionCard instead when options need descriptions; use CardGridSelectCard for a smaller set (2–6) of visual choices that each deserve an icon.",
254
254
  component: React.memo(_ChipSelectCard) as WireAIComponent["component"],
255
255
  propsSchema: schema,
256
256
  defaultProps: { multiSelect: true, submitLabel: "Continue" },
@@ -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;