@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.
- package/AGENTS.md +29 -5
- package/CHANGELOG.md +198 -6
- package/README.md +105 -10
- package/dist/analytics/index.d.mts +12 -8
- package/dist/analytics/index.d.ts +12 -8
- package/dist/analytics/index.js +525 -55
- package/dist/analytics/index.js.map +1 -1
- package/dist/analytics/index.mjs +525 -55
- 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-CxnP7gAa.d.ts → currentSession-61dcm3V-.d.ts} +19 -3
- package/dist/{currentSession-BJBB7i4-.d.mts → currentSession-Bs2JfTJ8.d.mts} +19 -3
- 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 +620 -13
- package/dist/index.d.ts +620 -13
- package/dist/index.js +1194 -369
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1015 -201
- 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-DzU-TqZz.d.ts +86 -0
- package/dist/transport-f5VVB5hH.d.mts +86 -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/analyticsFacade.ts +55 -10
- 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/context/deviceId.ts +43 -0
- package/src/device/appVersion.ts +29 -8
- package/src/device/deviceContext.ts +14 -1
- package/src/device/deviceModel.ts +106 -0
- 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 +22 -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 +77 -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
|
@@ -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;
|
|
@@ -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;
|