@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.
- package/AGENTS.md +29 -5
- package/CHANGELOG.md +202 -6
- package/README.md +105 -10
- package/dist/analytics/index.d.mts +6 -6
- package/dist/analytics/index.d.ts +6 -6
- package/dist/analytics/index.js +447 -49
- package/dist/analytics/index.js.map +1 -1
- package/dist/analytics/index.mjs +447 -49
- package/dist/analytics/index.mjs.map +1 -1
- package/dist/coachmarks/index.d.mts +2 -2
- package/dist/coachmarks/index.d.ts +2 -2
- package/dist/coachmarks/index.js.map +1 -1
- package/dist/coachmarks/index.mjs.map +1 -1
- package/dist/{currentSession-f7LWcdWG.d.ts → currentSession-61dcm3V-.d.ts} +10 -2
- package/dist/{currentSession-d9CrBxwe.d.mts → currentSession-Bs2JfTJ8.d.mts} +10 -2
- package/dist/{decision-BzbiKwk3.d.mts → decision-Bl_M2y3r.d.mts} +1 -1
- package/dist/{decision-plDEOCkt.d.ts → decision-Cau5KmP6.d.ts} +1 -1
- package/dist/index.d.mts +558 -15
- package/dist/index.d.ts +558 -15
- package/dist/index.js +1144 -375
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +969 -207
- package/dist/index.mjs.map +1 -1
- package/dist/questionnaire/index.d.mts +69 -23
- package/dist/questionnaire/index.d.ts +69 -23
- package/dist/questionnaire/index.js +643 -172
- package/dist/questionnaire/index.js.map +1 -1
- package/dist/questionnaire/index.mjs +608 -137
- package/dist/questionnaire/index.mjs.map +1 -1
- package/dist/reviews/index.d.mts +24 -12
- package/dist/reviews/index.d.ts +24 -12
- package/dist/reviews/index.js +507 -173
- package/dist/reviews/index.js.map +1 -1
- package/dist/reviews/index.mjs +462 -129
- package/dist/reviews/index.mjs.map +1 -1
- package/dist/showcase/index.d.mts +2 -2
- package/dist/showcase/index.d.ts +2 -2
- package/dist/showcase/index.js.map +1 -1
- package/dist/showcase/index.mjs.map +1 -1
- package/dist/transport-B31G0Cib.d.ts +128 -0
- package/dist/transport-Bzb-bcB2.d.mts +128 -0
- package/dist/{types-GL_hQ0TN.d.mts → types-BcmagF6K.d.mts} +1 -1
- package/dist/{types-GL_hQ0TN.d.ts → types-BcmagF6K.d.ts} +1 -1
- package/dist/{types-BhpXJGlg.d.ts → types-Buj9Lw9t.d.ts} +15 -3
- package/dist/{types-CMuOexw0.d.mts → types-CKFhyrMu.d.mts} +1 -1
- package/dist/{types-CMuOexw0.d.ts → types-CKFhyrMu.d.ts} +1 -1
- package/dist/{types-A6pTxIZV.d.mts → types-CNUqMK0D.d.mts} +15 -3
- package/metro/index.d.ts +18 -1
- package/metro/index.js +80 -0
- package/package.json +9 -1
- package/src/WireOnboarding.tsx +4 -0
- package/src/analytics/currentSession.ts +1 -1
- package/src/cards/CardGridSelectCard.tsx +253 -0
- package/src/cards/ChipSelectCard.tsx +1 -1
- package/src/cards/SelectionCard.tsx +15 -19
- package/src/cards/index.ts +13 -1
- package/src/cards/optionSchema.ts +65 -0
- package/src/coachmarks/types.ts +1 -1
- package/src/components/CardHandoff.tsx +39 -7
- package/src/components/DoneCheck.tsx +94 -0
- package/src/components/ModalPaneHost.tsx +141 -0
- package/src/components/StepDots.tsx +112 -0
- package/src/device/appVersion.ts +29 -8
- package/src/device/deviceModel.ts +21 -8
- package/src/features/cache.ts +1 -1
- package/src/haptics/expo-haptics.d.ts +31 -0
- package/src/haptics/haptics.ts +58 -0
- package/src/icons/IconRegistry.tsx +53 -0
- package/src/icons/WireIcon.tsx +85 -0
- package/src/icons/expoIcons.ts +165 -0
- package/src/icons/index.ts +18 -0
- package/src/icons/vocabulary.ts +154 -0
- package/src/index.ts +20 -0
- package/src/questionnaire/QuestionnaireGate.tsx +315 -164
- package/src/questionnaire/decision.ts +4 -2
- package/src/questionnaire/index.ts +3 -2
- package/src/questionnaire/steps.ts +261 -0
- package/src/questionnaire/types.ts +33 -12
- package/src/reviews/ReviewGate.tsx +250 -152
- package/src/reviews/index.ts +7 -3
- package/src/reviews/transport.ts +119 -3
- package/src/reviews/types.ts +13 -0
- package/src/session/persistedSession.ts +1 -1
- package/src/session-analytics/lifecycle.ts +1 -1
- package/src/session-analytics/useSessionStart.ts +2 -2
- package/src/showcase/FeatureShowcase.tsx +1 -1
- package/src/showcase/showcaseColors.ts +1 -1
- package/src/theme/mergeThemeOver.ts +27 -0
- package/src/types.ts +8 -0
- package/dist/transport-BeO_Brcu.d.mts +0 -40
- 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:
|
|
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:
|
|
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
|
-
{
|
|
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 },
|
package/src/cards/index.ts
CHANGED
|
@@ -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 {
|
|
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));
|
package/src/coachmarks/types.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Shared types for the coachmark / guided-tour overlay engine.
|
|
3
3
|
*
|
|
4
|
-
* Lifted from
|
|
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
|
-
|
|
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]}>
|
|
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
|
-
/**
|
|
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
|
-
|
|
218
|
+
layer: "enter" | "exit",
|
|
219
|
+
travel: CardHandoffDirection,
|
|
189
220
|
) => {
|
|
190
221
|
if (reduced || variant === "fade") return [];
|
|
191
|
-
if (variant === "spring" &&
|
|
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:
|
|
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;
|