@wireai/activation 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/AGENTS.md +29 -5
  2. package/CHANGELOG.md +198 -6
  3. package/README.md +105 -10
  4. package/dist/analytics/index.d.mts +12 -8
  5. package/dist/analytics/index.d.ts +12 -8
  6. package/dist/analytics/index.js +525 -55
  7. package/dist/analytics/index.js.map +1 -1
  8. package/dist/analytics/index.mjs +525 -55
  9. package/dist/analytics/index.mjs.map +1 -1
  10. package/dist/coachmarks/index.d.mts +2 -2
  11. package/dist/coachmarks/index.d.ts +2 -2
  12. package/dist/coachmarks/index.js.map +1 -1
  13. package/dist/coachmarks/index.mjs.map +1 -1
  14. package/dist/{currentSession-CxnP7gAa.d.ts → currentSession-61dcm3V-.d.ts} +19 -3
  15. package/dist/{currentSession-BJBB7i4-.d.mts → currentSession-Bs2JfTJ8.d.mts} +19 -3
  16. package/dist/{decision-BzbiKwk3.d.mts → decision-Bl_M2y3r.d.mts} +1 -1
  17. package/dist/{decision-plDEOCkt.d.ts → decision-Cau5KmP6.d.ts} +1 -1
  18. package/dist/index.d.mts +620 -13
  19. package/dist/index.d.ts +620 -13
  20. package/dist/index.js +1194 -369
  21. package/dist/index.js.map +1 -1
  22. package/dist/index.mjs +1015 -201
  23. package/dist/index.mjs.map +1 -1
  24. package/dist/questionnaire/index.d.mts +69 -23
  25. package/dist/questionnaire/index.d.ts +69 -23
  26. package/dist/questionnaire/index.js +643 -172
  27. package/dist/questionnaire/index.js.map +1 -1
  28. package/dist/questionnaire/index.mjs +608 -137
  29. package/dist/questionnaire/index.mjs.map +1 -1
  30. package/dist/reviews/index.d.mts +24 -12
  31. package/dist/reviews/index.d.ts +24 -12
  32. package/dist/reviews/index.js +507 -173
  33. package/dist/reviews/index.js.map +1 -1
  34. package/dist/reviews/index.mjs +462 -129
  35. package/dist/reviews/index.mjs.map +1 -1
  36. package/dist/showcase/index.d.mts +2 -2
  37. package/dist/showcase/index.d.ts +2 -2
  38. package/dist/showcase/index.js.map +1 -1
  39. package/dist/showcase/index.mjs.map +1 -1
  40. package/dist/transport-DzU-TqZz.d.ts +86 -0
  41. package/dist/transport-f5VVB5hH.d.mts +86 -0
  42. package/dist/{types-GL_hQ0TN.d.mts → types-BcmagF6K.d.mts} +1 -1
  43. package/dist/{types-GL_hQ0TN.d.ts → types-BcmagF6K.d.ts} +1 -1
  44. package/dist/{types-BhpXJGlg.d.ts → types-Buj9Lw9t.d.ts} +15 -3
  45. package/dist/{types-CMuOexw0.d.mts → types-CKFhyrMu.d.mts} +1 -1
  46. package/dist/{types-CMuOexw0.d.ts → types-CKFhyrMu.d.ts} +1 -1
  47. package/dist/{types-A6pTxIZV.d.mts → types-CNUqMK0D.d.mts} +15 -3
  48. package/metro/index.d.ts +18 -1
  49. package/metro/index.js +80 -0
  50. package/package.json +9 -1
  51. package/src/WireOnboarding.tsx +4 -0
  52. package/src/analytics/analyticsFacade.ts +55 -10
  53. package/src/analytics/currentSession.ts +1 -1
  54. package/src/cards/CardGridSelectCard.tsx +253 -0
  55. package/src/cards/ChipSelectCard.tsx +1 -1
  56. package/src/cards/SelectionCard.tsx +15 -19
  57. package/src/cards/index.ts +13 -1
  58. package/src/cards/optionSchema.ts +65 -0
  59. package/src/coachmarks/types.ts +1 -1
  60. package/src/components/CardHandoff.tsx +39 -7
  61. package/src/components/DoneCheck.tsx +94 -0
  62. package/src/components/ModalPaneHost.tsx +141 -0
  63. package/src/components/StepDots.tsx +112 -0
  64. package/src/context/deviceId.ts +43 -0
  65. package/src/device/appVersion.ts +29 -8
  66. package/src/device/deviceContext.ts +14 -1
  67. package/src/device/deviceModel.ts +106 -0
  68. package/src/features/cache.ts +1 -1
  69. package/src/haptics/expo-haptics.d.ts +31 -0
  70. package/src/haptics/haptics.ts +58 -0
  71. package/src/icons/IconRegistry.tsx +53 -0
  72. package/src/icons/WireIcon.tsx +85 -0
  73. package/src/icons/expoIcons.ts +165 -0
  74. package/src/icons/index.ts +18 -0
  75. package/src/icons/vocabulary.ts +154 -0
  76. package/src/index.ts +22 -0
  77. package/src/questionnaire/QuestionnaireGate.tsx +315 -164
  78. package/src/questionnaire/decision.ts +4 -2
  79. package/src/questionnaire/index.ts +3 -2
  80. package/src/questionnaire/steps.ts +261 -0
  81. package/src/questionnaire/types.ts +33 -12
  82. package/src/reviews/ReviewGate.tsx +250 -152
  83. package/src/reviews/index.ts +7 -3
  84. package/src/reviews/transport.ts +77 -3
  85. package/src/reviews/types.ts +13 -0
  86. package/src/session/persistedSession.ts +1 -1
  87. package/src/session-analytics/lifecycle.ts +1 -1
  88. package/src/session-analytics/useSessionStart.ts +2 -2
  89. package/src/showcase/FeatureShowcase.tsx +1 -1
  90. package/src/showcase/showcaseColors.ts +1 -1
  91. package/src/theme/mergeThemeOver.ts +27 -0
  92. package/src/types.ts +8 -0
  93. package/dist/transport-BeO_Brcu.d.mts +0 -40
  94. package/dist/transport-DLpd1v5_.d.ts +0 -40
@@ -1,46 +1,76 @@
1
1
  /**
2
- * QuestionnaireGate - the skippable pre-onboarding questionnaire popup.
2
+ * QuestionnaireGate - the multi-step questionnaire popup.
3
3
  *
4
- * A centered popup (over the shared `CenteredModal` shell) with three inputs:
5
- * a MANDATORY multiline "what do you think about the app" box (~4 lines), and
6
- * two OPTIONAL smaller boxes ("help us improve" + "suggestions").
7
- * Send is disabled ONLY until the opinion box has text; the two optional boxes never gate it.
8
- * The gate is ALWAYS skippable (a "Not now"/"Skip" link plays the popup exit), so it is never
9
- * a dead end. On send it builds the contract body and fire-and-forgets it to the Wire server,
10
- * then shows a warm thank-you until the user taps the backdrop to close.
4
+ * ONE question per step. Steps 1-2 are skippable; the LAST step is REQUIRED (Malik's
5
+ * device test 2026-07-16: "i want at least something forced"). The user chooses which
6
+ * thing they tell us, not whether they tell us anything. `steps.ts` owns that rule and
7
+ * every transition as pure functions, unit-tested under node:test without mounting React;
8
+ * this component is a thin renderer over that machine.
9
+ *
10
+ * The CHROME (back icon + step dots) is a static header, deliberately NOT part of the
11
+ * sliding pane - see the four notes below. Only the question, its box and its controls move.
12
+ *
13
+ * - No title. The gate used to render `questionnaire.title` as a heading ON EVERY STEP,
14
+ * directly above the step's own question. Two asking-texts stacked, one of them the same
15
+ * string three times, and mid-slide the outgoing and incoming panes each drew their own
16
+ * copy so it was briefly on screen TWICE. Malik saw it on the last step, where the
17
+ * forced question most needs the focus. The question IS the heading now; it renders once.
18
+ * - No "Question 2 of 3" counter. `StepDots` says it without spending a line next to the
19
+ * question, which is what the user is supposed to be reading.
20
+ * - Back is a top-left icon, not a link in the button stack, and it is simply absent on
21
+ * step 1 rather than present-but-dead.
22
+ * - No "Not now". Per-step Skip is the only in-card opt-out. `questionnaire_dismissed`
23
+ * moved to the modal's dismiss path (backdrop tap / Android back) - see `handleDismissed`.
24
+ *
25
+ * The wire shape is UNCHANGED. `buildQuestionnaireSubmission` still builds the exact same
26
+ * `POST /v1/questionnaires/{id}/responses` body from the same three answers; a skipped
27
+ * step is simply an empty answer, which is what the builder already handled.
28
+ *
29
+ * ⚠️ This gate now REQUIRES the server to accept an empty `answers.opinion` - step 1 is
30
+ * skippable and the live endpoint still 422s a blank opinion, silently (the transport is
31
+ * fire-and-forget). See the REQUIRED_STEPS note in steps.ts. Server fix, not a client one.
32
+ *
33
+ * Motion: `ModalPaneHost` slides step→step (and mirrors the slide on Back) and resizes the
34
+ * card; `StepDots` fills; `DoneCheck` plays the submit payoff. All shared, all reduce-motion
35
+ * aware. Haptics are lazy + optional (`haptics/haptics.ts`) and silently absent otherwise.
11
36
  *
12
37
  * The form NEVER asks for identity (no name / email / contact field): the host already
13
38
  * identifies the user via `sessionId` + `meta` (user_id, device_key, session_count), which is
14
39
  * all the POST body carries alongside the answers. Every visual value comes from the
15
40
  * OnboardingTheme; analytics are callback-based (no PII - answers go ONLY in the POST body).
16
41
  */
17
- import React, { useCallback, useMemo, useState } from "react";
42
+ import React, { useCallback, useMemo, useRef, useState } from "react";
18
43
  import { Pressable, StyleSheet, Text, TextInput, View } from "react-native";
19
44
 
20
45
  import { Button } from "../components/Button";
21
46
  import { CenteredModal, type CenteredModalHandle } from "../components/CenteredModal";
47
+ import { DoneCheck } from "../components/DoneCheck";
48
+ import { ModalPaneHost } from "../components/ModalPaneHost";
49
+ import { StepDots } from "../components/StepDots";
50
+ import { playHaptic } from "../haptics/haptics";
22
51
  import { feedbackInputHeights } from "../reviews/feedbackForm";
52
+ import { mergeThemeOver } from "../theme/mergeThemeOver";
23
53
  import { useOnboardingTheme } from "../theme/ThemeContext";
24
- import type { OnboardingTheme } from "../theme/types";
25
54
  import { bodyStyle, captionStyle, headingStyle } from "../theme/typography";
26
55
  import { buildQuestionnaireSubmission } from "./decision";
56
+ import {
57
+ buildQuestionnaireSteps,
58
+ canGoBack,
59
+ canSkipStep,
60
+ commitStep,
61
+ currentStep,
62
+ goBack,
63
+ initialStepState,
64
+ isLastStep,
65
+ skipStep,
66
+ stashDraft,
67
+ toAnswers,
68
+ type QuestionnaireStepState,
69
+ type StepDrafts,
70
+ } from "./steps";
27
71
  import { submitQuestionnaireResponse } from "./transport";
28
72
  import type { QuestionnaireGateProps } from "./types";
29
73
 
30
- const mergeThemeOver = (
31
- base: OnboardingTheme,
32
- override?: Partial<OnboardingTheme>,
33
- ): OnboardingTheme => {
34
- if (!override) return base;
35
- return {
36
- colors: { ...base.colors, ...override.colors },
37
- fonts: { ...base.fonts, ...override.fonts },
38
- radius: { ...base.radius, ...override.radius },
39
- spacing: { ...base.spacing, ...override.spacing },
40
- button: { ...base.button, ...override.button },
41
- };
42
- };
43
-
44
74
  type Phase = "form" | "thanks";
45
75
 
46
76
  const _QuestionnaireGate: React.FC<QuestionnaireGateProps> = ({
@@ -56,21 +86,64 @@ const _QuestionnaireGate: React.FC<QuestionnaireGateProps> = ({
56
86
  }) => {
57
87
  const active = useOnboardingTheme();
58
88
  const t = useMemo(() => mergeThemeOver(active, themeOverride), [active, themeOverride]);
59
- const modalRef = React.useRef<CenteredModalHandle>(null);
89
+ const modalRef = useRef<CenteredModalHandle>(null);
60
90
 
61
91
  const id = questionnaire.id;
92
+ const steps = useMemo(
93
+ () =>
94
+ buildQuestionnaireSteps({
95
+ opinion_label: questionnaire.opinion_label,
96
+ improve_label: questionnaire.improve_label,
97
+ suggestions_label: questionnaire.suggestions_label,
98
+ }),
99
+ [questionnaire.opinion_label, questionnaire.improve_label, questionnaire.suggestions_label],
100
+ );
101
+
62
102
  const [phase, setPhase] = useState<Phase>("form");
63
- const [opinion, setOpinion] = useState("");
64
- const [improve, setImprove] = useState("");
65
- const [suggestions, setSuggestions] = useState("");
103
+ const [state, setState] = useState<QuestionnaireStepState>(initialStepState);
104
+ // Mirrored so the press handlers read the live state without re-subscribing every render
105
+ // (the same ref-over-callback idiom the fire-once effect below uses).
106
+ const stateRef = useRef(state);
107
+ stateRef.current = state;
108
+
109
+ // The text for the step ON SCREEN. Deliberately a ref, not state: the inputs stay
110
+ // UNCONTROLLED (`defaultValue`), so a keystroke never round-trips through React and the
111
+ // box never lags or flickers on a low-end device. Each step's input is keyed, so it
112
+ // remounts per step and `defaultValue` restores the earlier answer when the user steps Back.
113
+ const draftRef = useRef("");
114
+
115
+ // ...but the REQUIRED last step does gate Send on the text, and a ref cannot re-render the
116
+ // button. So we mirror ONE bit - "is there non-whitespace text?" - into state, and set it
117
+ // only when that bit FLIPS. Typing a 200-character answer costs exactly one re-render (on
118
+ // the first character), not 200: the middle ground between an uncontrolled box and a
119
+ // control that has to know about its content.
120
+ const [answered, setAnswered] = useState(false);
121
+ const answeredRef = useRef(false);
122
+
123
+ /** Point the draft at `value` and resync the flag. Used on every step transition. */
124
+ const seedDraft = useCallback((value: string) => {
125
+ draftRef.current = value;
126
+ const has = value.trim().length > 0;
127
+ answeredRef.current = has;
128
+ setAnswered(has);
129
+ }, []);
130
+
131
+ /** Typing: always record, re-render only on the empty↔filled flip. */
132
+ const handleChangeText = useCallback((text: string) => {
133
+ draftRef.current = text;
134
+ const has = text.trim().length > 0;
135
+ if (has === answeredRef.current) return;
136
+ answeredRef.current = has;
137
+ setAnswered(has);
138
+ }, []);
66
139
 
67
140
  // Fire questionnaire_prompt_shown exactly ONCE per mount. The callbacks are read through refs
68
141
  // (not effect deps) so an inline `onShown`/`onEvent` - recreated every render, as real hosts
69
142
  // idiomatically pass - never re-runs the effect. Empty deps + the shownRef guard make this a
70
143
  // true fire-once, immune to prop-identity churn.
71
- const shownRef = React.useRef(false);
72
- const onShownRef = React.useRef(onShown);
73
- const onEventRef = React.useRef(onEvent);
144
+ const shownRef = useRef(false);
145
+ const onShownRef = useRef(onShown);
146
+ const onEventRef = useRef(onEvent);
74
147
  onShownRef.current = onShown;
75
148
  onEventRef.current = onEvent;
76
149
  React.useEffect(() => {
@@ -81,7 +154,7 @@ const _QuestionnaireGate: React.FC<QuestionnaireGateProps> = ({
81
154
  // eslint-disable-next-line react-hooks/exhaustive-deps
82
155
  }, []);
83
156
 
84
- const resolveTimer = React.useRef<ReturnType<typeof setTimeout> | null>(null);
157
+ const resolveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
85
158
 
86
159
  React.useEffect(() => {
87
160
  return () => {
@@ -89,149 +162,220 @@ const _QuestionnaireGate: React.FC<QuestionnaireGateProps> = ({
89
162
  };
90
163
  }, []);
91
164
 
92
- const resolve = useCallback(() => {
165
+ // Set the instant we POST, so the dismiss path below can tell "the user walked away"
166
+ // from "the modal closed because the run finished".
167
+ const submittedRef = useRef(false);
168
+
169
+ /**
170
+ * The modal finished its exit animation. This is now the ONLY source of
171
+ * `questionnaire_dismissed`.
172
+ *
173
+ * WHY it moved: the event used to fire from the in-card "Not now" link, which Malik cut.
174
+ * Deleting the link without moving the event would have quietly zeroed a metric the
175
+ * server counts. Dismissal is still very much reachable - `CenteredModal` dismisses on a
176
+ * backdrop tap and on Android hardware back whenever `dismissible` - so the event belongs
177
+ * on that path, not on one button that happened to trigger it.
178
+ *
179
+ * It also FIXES a hole: backdrop-tap and hardware-back dismissals never fired the event
180
+ * before, so "dismissed" only ever counted the users who found the link. Every real
181
+ * dismissal counts now, which means the number will legitimately RISE - it is a fuller
182
+ * count, not a regression.
183
+ *
184
+ * The submit path closes the modal too (after the thank-you), hence the guard: finishing
185
+ * is not dismissing. Read through `onEventRef` for the same prop-identity reason as the
186
+ * fire-once effect above.
187
+ */
188
+ const handleDismissed = useCallback(() => {
189
+ if (!submittedRef.current) {
190
+ onEventRef.current?.({ name: "questionnaire_dismissed", id });
191
+ }
93
192
  onResolved?.();
94
- }, [onResolved]);
95
-
96
- // The opinion box is the ONLY gate on Send. The two optional boxes never disable it.
97
- const canSend = opinion.trim().length > 0;
98
-
99
- const send = useCallback(() => {
100
- if (!canSend) return;
101
- // Identity is NEVER collected here - the host already sends user_id / device_key / session
102
- // via `sessionId` + `meta`. We POST only the three answers.
103
- const submission = buildQuestionnaireSubmission({
104
- answers: { opinion, improve, suggestions },
105
- sessionId,
106
- meta,
107
- });
108
- submitQuestionnaireResponse(target, id, submission);
109
- onEvent?.({ name: "questionnaire_submitted", id });
110
- setPhase("thanks");
111
- if (resolveTimer.current) clearTimeout(resolveTimer.current);
112
- resolveTimer.current = setTimeout(() => {
113
- modalRef.current?.close();
114
- }, 1500);
115
- }, [canSend, opinion, improve, suggestions, sessionId, meta, target, id, onEvent]);
116
-
117
- // The in-card "Not now"/"Skip" link plays the popup's exit animation (which resolves on
118
- // completion, the same path a backdrop tap takes). Emit the dismissed moment first.
119
- const handleDismissTap = useCallback(() => {
120
- onEvent?.({ name: "questionnaire_dismissed", id });
121
- modalRef.current?.close();
122
- }, [onEvent, id]);
123
-
124
- const title = questionnaire.title ?? "How's it going so far?";
125
- const opinionLabel = questionnaire.opinion_label ?? "What do you think about the app?";
126
- const improveLabel = questionnaire.improve_label ?? "How can we improve? (optional)";
127
- const suggestionsLabel = questionnaire.suggestions_label ?? "Any suggestions? (optional)";
193
+ // eslint-disable-next-line react-hooks/exhaustive-deps
194
+ }, [id, onResolved]);
195
+
196
+ // The run is over: POST the answers and play the payoff. Identity is NEVER collected here -
197
+ // the host already sends user_id / device_key / session via `sessionId` + `meta`.
198
+ const finish = useCallback(
199
+ (drafts: StepDrafts) => {
200
+ const submission = buildQuestionnaireSubmission({
201
+ answers: toAnswers(drafts),
202
+ sessionId,
203
+ meta,
204
+ });
205
+ submitQuestionnaireResponse(target, id, submission);
206
+ submittedRef.current = true;
207
+ onEvent?.({ name: "questionnaire_submitted", id });
208
+ playHaptic("success");
209
+ setPhase("thanks");
210
+ if (resolveTimer.current) clearTimeout(resolveTimer.current);
211
+ resolveTimer.current = setTimeout(() => {
212
+ modalRef.current?.close();
213
+ }, 1500);
214
+ },
215
+ [sessionId, meta, target, id, onEvent],
216
+ );
217
+
218
+ // Apply a machine transition: either submit (last step) or seed the next step's draft.
219
+ // A REFUSED transition (blank text on the required step) returns the same state object,
220
+ // so bail: no haptic, no re-render, nothing to tell the user that the disabled button
221
+ // already told them.
222
+ const applyTransition = useCallback(
223
+ (next: QuestionnaireStepState) => {
224
+ if (next === stateRef.current) return;
225
+ setState(next);
226
+ if (next.done) {
227
+ finish(next.drafts);
228
+ return;
229
+ }
230
+ seedDraft(next.drafts[steps[next.index]?.key ?? "opinion"] ?? "");
231
+ playHaptic("light");
232
+ },
233
+ [finish, seedDraft, steps],
234
+ );
235
+
236
+ const handleNext = useCallback(() => {
237
+ applyTransition(commitStep(steps, stateRef.current, draftRef.current));
238
+ }, [applyTransition, steps]);
239
+
240
+ const handleSkip = useCallback(() => {
241
+ applyTransition(skipStep(steps, stateRef.current));
242
+ }, [applyTransition, steps]);
243
+
244
+ const handleBack = useCallback(() => {
245
+ // Stash the CURRENT step's text before leaving it. The inputs are uncontrolled, so that
246
+ // text lives ONLY in draftRef until something folds it into the drafts; Back without this
247
+ // destroyed it, and the keyed input silently remounted blank when the user stepped
248
+ // forward again. Same primitive `commitStep` uses for Next — one way to record an answer.
249
+ const next = goBack(stashDraft(steps, stateRef.current, draftRef.current));
250
+ setState(next);
251
+ seedDraft(next.drafts[steps[next.index]?.key ?? "opinion"] ?? "");
252
+ playHaptic("light");
253
+ }, [seedDraft, steps]);
254
+
255
+ const step = currentStep(steps, state);
256
+ const last = isLastStep(steps, state.index);
257
+ // The mandatory last step: no Skip control at all, and Send stays disabled until there is
258
+ // text. `answered` tracks the live box; `canSkipStep` is the machine's own read of the
259
+ // same `required` flag it enforces in `commitStep`, so the two can never disagree.
260
+ const canSkip = canSkipStep(steps, state);
261
+ const sendDisabled = (step?.required ?? false) && !answered;
128
262
 
129
263
  // A multiline box ~4 lines tall (capped at ~5), derived from the theme's body line-height so
130
- // it scales with the app's font config; padding is folded in so the lines are visible.
131
- const opinionBox = feedbackInputHeights(t.fonts, t.spacing.md);
132
-
133
- const content = (
134
- <>
135
- <Text style={[headingStyle(t.fonts), { color: t.colors.text }]}>{title}</Text>
136
-
137
- {phase === "form" && (
138
- <>
139
- <Text style={[captionStyle(t.fonts), { color: t.colors.textMuted }]}>
140
- A few words is plenty. Only the first box is required.
141
- </Text>
142
-
143
- {/* MANDATORY opinion box - the only gate on Send. */}
144
- <Text style={[captionStyle(t.fonts), { color: t.colors.textMuted }]}>{opinionLabel}</Text>
145
- <TextInput
146
- value={opinion}
147
- onChangeText={setOpinion}
148
- placeholder="e.g. it feels fast and clear"
149
- placeholderTextColor={t.colors.textMuted}
150
- multiline
151
- textAlignVertical="top"
152
- style={[
153
- bodyStyle(t.fonts),
154
- styles.input,
155
- {
156
- color: t.colors.text,
157
- backgroundColor: t.colors.background,
158
- borderRadius: t.radius.md,
159
- borderColor: t.colors.border,
160
- padding: t.spacing.md,
161
- minHeight: opinionBox.minHeight,
162
- maxHeight: opinionBox.maxHeight,
163
- },
164
- ]}
165
- />
166
-
167
- {/* OPTIONAL "help us improve" box - never gates Send. */}
168
- <Text style={[captionStyle(t.fonts), { color: t.colors.textMuted }]}>{improveLabel}</Text>
169
- <TextInput
170
- value={improve}
171
- onChangeText={setImprove}
172
- placeholder="e.g. fewer steps to start"
173
- placeholderTextColor={t.colors.textMuted}
174
- style={[
175
- bodyStyle(t.fonts),
176
- styles.input,
177
- {
178
- color: t.colors.text,
179
- backgroundColor: t.colors.background,
180
- borderRadius: t.radius.md,
181
- borderColor: t.colors.border,
182
- paddingHorizontal: t.spacing.md,
183
- paddingVertical: t.spacing.sm,
184
- minHeight: t.button.height,
185
- },
186
- ]}
187
- />
188
-
189
- {/* OPTIONAL "suggestions" box - never gates Send. */}
190
- <Text style={[captionStyle(t.fonts), { color: t.colors.textMuted }]}>
191
- {suggestionsLabel}
192
- </Text>
193
- <TextInput
194
- value={suggestions}
195
- onChangeText={setSuggestions}
196
- placeholder="e.g. a dark theme"
197
- placeholderTextColor={t.colors.textMuted}
198
- style={[
199
- bodyStyle(t.fonts),
200
- styles.input,
201
- {
202
- color: t.colors.text,
203
- backgroundColor: t.colors.background,
204
- borderRadius: t.radius.md,
205
- borderColor: t.colors.border,
206
- paddingHorizontal: t.spacing.md,
207
- paddingVertical: t.spacing.sm,
208
- minHeight: t.button.height,
209
- },
210
- ]}
211
- />
212
-
213
- <Button title="Send" onPress={send} variant="primary" fullWidth disabled={!canSend} />
214
- {dismissible && (
215
- <Pressable onPress={handleDismissTap} hitSlop={8}>
216
- <Text style={[captionStyle(t.fonts), styles.link, { color: t.colors.textMuted }]}>
217
- Not now
264
+ // it scales with the app's font config; padding is folded in so the lines are visible. Every
265
+ // step uses the SAME box, which also keeps the card's height near-steady between steps.
266
+ const answerBox = feedbackInputHeights(t.fonts, t.spacing.md);
267
+
268
+ const linkStyle = [captionStyle(t.fonts), styles.link, { color: t.colors.textMuted }];
269
+
270
+ /**
271
+ * The card's chrome: back icon hard left, dots centred. Handed to `ModalPaneHost`'s
272
+ * static slot, so it is measured with the pane but never slides and never doubles
273
+ * mid-handoff. The two side spacers are equal-flex so the dots sit at the true centre
274
+ * whether or not Back is there the row must not shift between step 1 and step 2.
275
+ */
276
+ const header =
277
+ phase === "form" ? (
278
+ <View style={[styles.header, { marginBottom: t.spacing.md }]}>
279
+ <View style={styles.headerSide}>
280
+ {/* Absent on step 1 rather than disabled: there is nothing to go back to, and a
281
+ greyed-out control is just a question the user has to answer. */}
282
+ {canGoBack(state) && (
283
+ <Pressable
284
+ onPress={handleBack}
285
+ hitSlop={12}
286
+ accessibilityRole="button"
287
+ accessibilityLabel="Back to the previous question"
288
+ >
289
+ {/* A Text glyph, not an icon component: `react-native-svg` is not a peer of
290
+ this kit and no vector library is worth forcing every host into a native
291
+ rebuild for one chevron. Same call DoneCheck's ✓ and the review stars made. */}
292
+ <Text style={[headingStyle(t.fonts), styles.backGlyph, { color: t.colors.textMuted }]}>
293
+
218
294
  </Text>
219
295
  </Pressable>
220
296
  )}
221
- </>
222
- )}
297
+ </View>
298
+ <StepDots theme={t} count={steps.length} index={state.index} />
299
+ <View style={styles.headerSide} />
300
+ </View>
301
+ ) : null;
302
+
303
+ const formPane = step ? (
304
+ <View style={{ gap: t.spacing.md }}>
305
+ {/* The question is the heading. There is no separate title above it — see the file
306
+ header: that was the text Malik saw repeated. */}
307
+ <Text style={[headingStyle(t.fonts), { color: t.colors.text }]}>{step.label}</Text>
308
+ <TextInput
309
+ // Keyed per step so each question gets a fresh input seeded with its own answer.
310
+ key={step.key}
311
+ defaultValue={state.drafts[step.key] ?? ""}
312
+ onChangeText={handleChangeText}
313
+ placeholder={step.placeholder}
314
+ placeholderTextColor={t.colors.textMuted}
315
+ accessibilityLabel={step.label}
316
+ multiline
317
+ textAlignVertical="top"
318
+ style={[
319
+ bodyStyle(t.fonts),
320
+ styles.input,
321
+ {
322
+ color: t.colors.text,
323
+ backgroundColor: t.colors.background,
324
+ borderRadius: t.radius.md,
325
+ borderColor: t.colors.border,
326
+ padding: t.spacing.md,
327
+ minHeight: answerBox.minHeight,
328
+ maxHeight: answerBox.maxHeight,
329
+ },
330
+ ]}
331
+ />
223
332
 
224
- {phase === "thanks" && (
225
- <Text style={[bodyStyle(t.fonts), { color: t.colors.textMuted }]}>
226
- Thank you. This helps us make it better.
227
- </Text>
333
+ {/* Gated ONLY on the required step. Steps 1-2 send/advance whatever the box holds,
334
+ because skipping is a first-class answer there and there is nothing to gate on. */}
335
+ <Button
336
+ title={last ? "Send" : "Next"}
337
+ onPress={handleNext}
338
+ variant="primary"
339
+ fullWidth
340
+ disabled={sendDisabled}
341
+ />
342
+
343
+ {/* Skip step — the only in-card opt-out, and gone entirely on the required step
344
+ rather than rendered as a control that would refuse to do anything. */}
345
+ {canSkip && (
346
+ <Pressable
347
+ onPress={handleSkip}
348
+ hitSlop={8}
349
+ accessibilityRole="button"
350
+ accessibilityLabel="Skip this question"
351
+ >
352
+ <Text style={linkStyle}>Skip step</Text>
353
+ </Pressable>
228
354
  )}
229
- </>
355
+ </View>
356
+ ) : null;
357
+
358
+ const thanksPane = (
359
+ <View style={{ gap: t.spacing.md }}>
360
+ <DoneCheck theme={t} />
361
+ <Text style={[headingStyle(t.fonts), styles.centerText, { color: t.colors.text }]}>
362
+ Thank you
363
+ </Text>
364
+ <Text style={[bodyStyle(t.fonts), styles.centerText, { color: t.colors.textMuted }]}>
365
+ This helps us make it better.
366
+ </Text>
367
+ </View>
230
368
  );
231
369
 
232
370
  return (
233
- <CenteredModal ref={modalRef} theme={t} dismissible={dismissible} onDismiss={resolve}>
234
- {content}
371
+ <CenteredModal ref={modalRef} theme={t} dismissible={dismissible} onDismiss={handleDismissed}>
372
+ <ModalPaneHost
373
+ paneKey={phase === "thanks" ? "thanks" : (step?.key ?? "done")}
374
+ direction={state.direction}
375
+ header={header}
376
+ >
377
+ {phase === "thanks" ? thanksPane : formPane}
378
+ </ModalPaneHost>
235
379
  </CenteredModal>
236
380
  );
237
381
  };
@@ -242,6 +386,13 @@ QuestionnaireGate.displayName = "QuestionnaireGate";
242
386
  const styles = StyleSheet.create({
243
387
  input: { borderWidth: 1 },
244
388
  link: { textAlign: "center", textDecorationLine: "underline" },
389
+ centerText: { textAlign: "center" },
390
+ header: { flexDirection: "row", alignItems: "center" },
391
+ // Equal flex on both sides keeps the dots optically centred whether Back is drawn or not.
392
+ // `flex-start` matters: without it the back Pressable stretches across the whole left
393
+ // third of the card, so a tap anywhere near the dots would navigate back.
394
+ headerSide: { flex: 1, alignItems: "flex-start" },
395
+ backGlyph: { includeFontPadding: false },
245
396
  });
246
397
 
247
398
  export default QuestionnaireGate;
@@ -34,8 +34,10 @@ export const decideQuestionnaire = (
34
34
  ): QuestionnaireDecision => decideReview(local, decision);
35
35
 
36
36
  /**
37
- * Build the `POST /v1/questionnaires/{id}/responses` body. `opinion` is the only mandatory
38
- * answer (trimmed); `improve`/`suggestions` are trimmed and sent as `null` when empty. The
37
+ * Build the `POST /v1/questionnaires/{id}/responses` body. `opinion` is always sent as a
38
+ * string (trimmed, possibly "" now that its step is skippable the live server still
39
+ * rejects that, see steps.ts REQUIRED_STEPS); `improve`/`suggestions` are trimmed and sent
40
+ * as `null` when empty. The
39
41
  * `meta` bucket carries the non-PII correlation fields (user_id / device_key / session_count),
40
42
  * each defaulting to null. Free text lives ONLY here, never in analytics events.
41
43
  */
@@ -1,8 +1,9 @@
1
1
  /**
2
- * @wireai/activation/questionnaire - the skippable pre-onboarding questionnaire popup.
2
+ * @wireai/activation/questionnaire - the multi-step pre-onboarding questionnaire popup.
3
3
  *
4
4
  * Subpath entry, kept OUT of the main barrel so the core kit stays dependency-free. The gate
5
- * asks one mandatory question ("what do you think about the app") plus two optional boxes,
5
+ * asks three questions ONE PER STEP - two skippable, then one REQUIRED last step so every
6
+ * completed run carries at least one real answer -
6
7
  * POSTs the answers to the Wire server, and decides WHEN to show via local rules OR a
7
8
  * server-provided decision (the AI seam), gated by the `questionnaire` master switch and shown
8
9
  * at most once per user per questionnaire id. It reuses the shared `CenteredModal` shell (never