@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
@@ -0,0 +1,261 @@
1
+ /**
2
+ * steps.ts — the pure multi-step machine behind the questionnaire popup.
3
+ *
4
+ * The gate asks ONE question per step. Skippability is per-step (`QuestionnaireStep.required`)
5
+ * and, after Malik's device test 2026-07-16, it is the INVERSE of what this kit shipped for a
6
+ * year: `opinion` (step 1) is SKIPPABLE — the old mandatory-opinion `canSend` gate is gone —
7
+ * and `suggestions` (the LAST step) is REQUIRED, because "i want at least something forced".
8
+ * The user picks WHICH thing they say; they do not get to say nothing at all.
9
+ *
10
+ * The step logic is the interesting part of the component, so it lives here as pure
11
+ * functions: no React, no react-native, no JSX. The gate is then a thin renderer over this
12
+ * state, and `steps.test.ts` can verify advance / skip / back / the required gate under
13
+ * node:test without mounting anything.
14
+ *
15
+ * The wire shape is NOT this module's business: it builds `QuestionnaireAnswers`, and
16
+ * `buildQuestionnaireSubmission` (decision.ts) still owns the POST body exactly as before.
17
+ *
18
+ * The step ORDER is the server contract's field order (opinion → improve → suggestions),
19
+ * so `toAnswers` can stay a plain projection and the submission never depends on how the
20
+ * user navigated.
21
+ */
22
+ import type { QuestionnaireAnswers, QuestionnaireDefinition } from "./types";
23
+
24
+ /** The three answer fields, which are also the three steps. */
25
+ export type QuestionnaireStepKey = "opinion" | "improve" | "suggestions";
26
+
27
+ /** One question, fully resolved for rendering. */
28
+ export interface QuestionnaireStep {
29
+ /** The answer field this step fills. */
30
+ key: QuestionnaireStepKey;
31
+ /** The question shown to the user (tenant-configurable). */
32
+ label: string;
33
+ /** The greyed-out example inside the input. */
34
+ placeholder: string;
35
+ /**
36
+ * When true the step cannot be skipped and cannot be committed blank — the gate hides
37
+ * Skip and disables Send until there is non-whitespace text. Only `suggestions` (the
38
+ * last step) carries it; see `REQUIRED_STEPS`.
39
+ */
40
+ required: boolean;
41
+ }
42
+
43
+ /**
44
+ * Which steps are mandatory. Malik, after testing on device 2026-07-16: "The any
45
+ * suggestions or feedback <the last step> is mandatory, i want at least something forced."
46
+ *
47
+ * Exactly one step is forced, and it is the LAST one, which is the only placement that
48
+ * works: the user has already been offered two cheap ways to say something and declined
49
+ * them, so the mandatory box lands as "then tell us anything" rather than as a toll gate
50
+ * in front of a popup they never asked for. Forcing step 1 (the old behaviour) taxed the
51
+ * arrival; forcing step 3 taxes the exit, after the cost is sunk.
52
+ *
53
+ * ⚠️ SERVER DEPENDENCY: this is the exact INVERSE of what the live server enforces today.
54
+ * `POST /v1/questionnaires/{id}/responses` validates `answers.opinion` with minLength=1 and
55
+ * 422s an empty one. Since step 1 is now skippable, a skipped step 1 produces `opinion: ""`
56
+ * and the server rejects the whole body — INCLUDING the suggestions text this module just
57
+ * forced out of the user. The transport is fire-and-forget (it never reads the response),
58
+ * so that rejection is SILENT: the user sees the thank-you either way. This client is
59
+ * therefore correct only once the server drops `opinion`'s minLength. Do not paper over it
60
+ * here — no placeholder opinion, no field reordering, no client-side guard.
61
+ */
62
+ export const REQUIRED_STEPS: Record<QuestionnaireStepKey, boolean> = {
63
+ opinion: false,
64
+ improve: false,
65
+ suggestions: true,
66
+ };
67
+
68
+ /**
69
+ * Fallback questions used when the tenant configures no labels.
70
+ *
71
+ * NOTE the copy change: the old defaults tagged `improve`/`suggestions` "(optional)".
72
+ * That was already wrong once step 1 stopped being mandatory, and it is wronger now that
73
+ * `suggestions` is the one REQUIRED step. Requiredness is carried by the CONTROLS — Skip
74
+ * is absent and Send is disabled on a required step — not by label text, which is tenant-
75
+ * configurable and would drift out of sync with `REQUIRED_STEPS` the moment it changed.
76
+ */
77
+ export const DEFAULT_STEP_LABELS: Record<QuestionnaireStepKey, string> = {
78
+ opinion: "What do you think about the app?",
79
+ improve: "How can we improve?",
80
+ suggestions: "Any suggestions?",
81
+ };
82
+
83
+ /** Per-field placeholders, carried over unchanged from the single-screen form. */
84
+ export const STEP_PLACEHOLDERS: Record<QuestionnaireStepKey, string> = {
85
+ opinion: "e.g. it feels fast and clear",
86
+ improve: "e.g. fewer steps to start",
87
+ suggestions: "e.g. a dark theme",
88
+ };
89
+
90
+ /** Which way the last move went — drives the slide direction (spatial consistency). */
91
+ export type StepDirection = "forward" | "backward";
92
+
93
+ /** Answers captured so far, keyed by step. A skipped step records "". */
94
+ export type StepDrafts = Partial<Record<QuestionnaireStepKey, string>>;
95
+
96
+ /** The whole machine state. Immutable: every transition returns a new object. */
97
+ export interface QuestionnaireStepState {
98
+ /** Index into the step list. */
99
+ index: number;
100
+ /** What has been typed/skipped so far. */
101
+ drafts: StepDrafts;
102
+ /** Direction of the last transition, for the pane slide. */
103
+ direction: StepDirection;
104
+ /** True once the LAST step is answered or skipped — the gate submits and thanks. */
105
+ done: boolean;
106
+ }
107
+
108
+ /**
109
+ * Resolve the tenant's labels into the ordered step list. All three steps always render:
110
+ * the server contract is a fixed 3-field capture, not an arbitrary question builder.
111
+ */
112
+ export const buildQuestionnaireSteps = (
113
+ questionnaire: Pick<
114
+ QuestionnaireDefinition,
115
+ "opinion_label" | "improve_label" | "suggestions_label"
116
+ >,
117
+ ): QuestionnaireStep[] => [
118
+ {
119
+ key: "opinion",
120
+ label: questionnaire.opinion_label ?? DEFAULT_STEP_LABELS.opinion,
121
+ placeholder: STEP_PLACEHOLDERS.opinion,
122
+ required: REQUIRED_STEPS.opinion,
123
+ },
124
+ {
125
+ key: "improve",
126
+ label: questionnaire.improve_label ?? DEFAULT_STEP_LABELS.improve,
127
+ placeholder: STEP_PLACEHOLDERS.improve,
128
+ required: REQUIRED_STEPS.improve,
129
+ },
130
+ {
131
+ key: "suggestions",
132
+ label: questionnaire.suggestions_label ?? DEFAULT_STEP_LABELS.suggestions,
133
+ placeholder: STEP_PLACEHOLDERS.suggestions,
134
+ required: REQUIRED_STEPS.suggestions,
135
+ },
136
+ ];
137
+
138
+ /** The starting state: first question, nothing answered. */
139
+ export const initialStepState = (): QuestionnaireStepState => ({
140
+ index: 0,
141
+ drafts: {},
142
+ direction: "forward",
143
+ done: false,
144
+ });
145
+
146
+ /** The step currently on screen, or undefined once the list is exhausted. */
147
+ export const currentStep = (
148
+ steps: readonly QuestionnaireStep[],
149
+ state: QuestionnaireStepState,
150
+ ): QuestionnaireStep | undefined => steps[state.index];
151
+
152
+ /** Whether `index` is the final step (the one whose commit submits). */
153
+ export const isLastStep = (steps: readonly QuestionnaireStep[], index: number): boolean =>
154
+ index >= steps.length - 1;
155
+
156
+ /** Back navigation is available on every step except the first. */
157
+ export const canGoBack = (state: QuestionnaireStepState): boolean => state.index > 0;
158
+
159
+ /**
160
+ * Whether the step on screen may be skipped — false on a required step, which is how the
161
+ * gate knows to render no Skip control at all rather than a Skip that silently does nothing.
162
+ */
163
+ export const canSkipStep = (
164
+ steps: readonly QuestionnaireStep[],
165
+ state: QuestionnaireStepState,
166
+ ): boolean => !(steps[state.index]?.required ?? false);
167
+
168
+ /**
169
+ * Whether `value` satisfies the step on screen — i.e. whether committing it will actually
170
+ * move. Optional steps accept anything (including ""); a required step needs non-whitespace
171
+ * text, because " " is not "at least something". The gate reads this to disable Send.
172
+ *
173
+ * The SAME predicate `commitStep` enforces, so a disabled Send and a refused commit can
174
+ * never disagree about what counts as answered.
175
+ */
176
+ export const canCommitStep = (
177
+ steps: readonly QuestionnaireStep[],
178
+ state: QuestionnaireStepState,
179
+ value: string,
180
+ ): boolean => {
181
+ const step = steps[state.index];
182
+ if (!step) return false;
183
+ return !step.required || value.trim().length > 0;
184
+ };
185
+
186
+ /**
187
+ * Record `value` for the CURRENT step WITHOUT navigating.
188
+ *
189
+ * This is the shared "the text on screen is now part of the answers" primitive: `commitStep`
190
+ * is exactly this plus the forward move, and the gate's Back does this before `goBack`. It
191
+ * exists because the gate's inputs are UNCONTROLLED, so the step on screen holds its text
192
+ * only in the component's draft ref until something folds it in here — meaning EVERY way of
193
+ * leaving a step has to stash first or the text is silently destroyed.
194
+ */
195
+ export const stashDraft = (
196
+ steps: readonly QuestionnaireStep[],
197
+ state: QuestionnaireStepState,
198
+ value: string,
199
+ ): QuestionnaireStepState => {
200
+ const step = steps[state.index];
201
+ if (!step) return state;
202
+ return { ...state, drafts: { ...state.drafts, [step.key]: value } };
203
+ };
204
+
205
+ /**
206
+ * Record `value` for the CURRENT step and move on. On the last step this sets `done`
207
+ * (the gate reads that as "submit now") and leaves the index parked, so `toAnswers` is
208
+ * called with every draft present.
209
+ *
210
+ * A required step refuses a blank `value` and returns the state UNCHANGED. That is the
211
+ * single enforcement point for the mandatory last step: `skipStep` is defined as committing
212
+ * "", so it becomes a no-op there for free, and the gate's disabled Send is belt-and-braces
213
+ * over a machine that already cannot be walked past.
214
+ */
215
+ export const commitStep = (
216
+ steps: readonly QuestionnaireStep[],
217
+ state: QuestionnaireStepState,
218
+ value: string,
219
+ ): QuestionnaireStepState => {
220
+ const step = steps[state.index];
221
+ if (!step) return state;
222
+ if (!canCommitStep(steps, state, value)) return state;
223
+ const { drafts } = stashDraft(steps, state, value);
224
+ const last = isLastStep(steps, state.index);
225
+ return {
226
+ index: last ? state.index : state.index + 1,
227
+ drafts,
228
+ direction: "forward",
229
+ done: last,
230
+ };
231
+ };
232
+
233
+ /**
234
+ * Skip the current step. Deliberately the SAME transition as answering with nothing —
235
+ * "skipped" and "left blank" are one concept, so there is one code path and no way for
236
+ * the two to drift. On a REQUIRED step that identity is what makes skipping impossible:
237
+ * committing "" is refused, so this returns the state unchanged.
238
+ */
239
+ export const skipStep = (
240
+ steps: readonly QuestionnaireStep[],
241
+ state: QuestionnaireStepState,
242
+ ): QuestionnaireStepState => commitStep(steps, state, "");
243
+
244
+ /** Go back one step, keeping every draft so the user's earlier text is still there. */
245
+ export const goBack = (state: QuestionnaireStepState): QuestionnaireStepState =>
246
+ state.index <= 0 ? state : { ...state, index: state.index - 1, direction: "backward" };
247
+
248
+ /**
249
+ * Project the drafts onto the answers contract. Missing/skipped steps become "" — the
250
+ * exact input `buildQuestionnaireSubmission` already expects, which trims and maps the
251
+ * two optional fields to null. The wire shape is unchanged by the multi-step rework.
252
+ *
253
+ * `opinion` can now legitimately be "" (step 1 is skippable), which the live server still
254
+ * rejects with a 422 — see the REQUIRED_STEPS note above. That is a server fix, not a
255
+ * reason to fabricate a value here.
256
+ */
257
+ export const toAnswers = (drafts: StepDrafts): QuestionnaireAnswers => ({
258
+ opinion: drafts.opinion ?? "",
259
+ improve: drafts.improve ?? "",
260
+ suggestions: drafts.suggestions ?? "",
261
+ });
@@ -1,9 +1,11 @@
1
1
  /**
2
2
  * Shared types for the questionnaire module (the skippable pre-onboarding popup).
3
3
  *
4
- * The gate asks a short set of questions in a centered popup: ONE mandatory free-text
5
- * "what do you think about the app" (the opinion), plus two OPTIONAL boxes ("help us
6
- * improve" + "suggestions"). WHEN the gate shows is decided by local rules OR a
4
+ * The gate asks a short set of questions in a centered popup, ONE per step: a SKIPPABLE
5
+ * "what do you think about the app" (the opinion), a SKIPPABLE "help us improve", and a
6
+ * REQUIRED "suggestions" as the last step (Malik 2026-07-16: "i want at least something
7
+ * forced"). Which step is forced lives in `steps.ts` (`REQUIRED_STEPS`), not here.
8
+ * WHEN the gate shows is decided by local rules OR a
7
9
  * server-provided decision (the AI seam), gated by the `questionnaire` master switch and
8
10
  * shown at most once per user per questionnaire `id`. Dependency-free type surface.
9
11
  */
@@ -30,13 +32,19 @@ export interface QuestionnaireDecision {
30
32
  export interface QuestionnaireDefinition {
31
33
  /** Gate key. Seen/last-shown/session keys derive from it (`wire_questionnaire_<id>_*`). */
32
34
  id: string;
33
- /** Popup heading. Defaults to a neutral prompt when absent. */
35
+ /**
36
+ * @deprecated NOT RENDERED since 2026-07-16. The gate is one-question-per-step, so each
37
+ * step's own question is its heading; a title above it was a second asking-text, repeated
38
+ * on all three steps and briefly drawn twice mid-slide. Malik cut it on device. The field
39
+ * stays in the type because tenants still send it and the server contract is unchanged —
40
+ * dropping it from the wire is a separate, server-side decision.
41
+ */
34
42
  title?: string;
35
- /** Label for the MANDATORY opinion box ("what do you think about the app"). */
43
+ /** Label for the SKIPPABLE opinion step ("what do you think about the app"). */
36
44
  opinion_label?: string;
37
- /** Label for the first OPTIONAL box ("help us improve"). */
45
+ /** Label for the SKIPPABLE "help us improve" step. */
38
46
  improve_label?: string;
39
- /** Label for the second OPTIONAL box ("suggestions"). */
47
+ /** Label for the REQUIRED "suggestions" step — the one answer the user cannot skip. */
40
48
  suggestions_label?: string;
41
49
 
42
50
  // ── Local fallback firing rules (used only when no server decision is passed) ──
@@ -59,13 +67,20 @@ export interface QuestionnaireDefinition {
59
67
  timeoutFallbackMs?: number | null;
60
68
  }
61
69
 
62
- /** The user's three answers. `opinion` is the only mandatory one. */
70
+ /**
71
+ * The user's three answers.
72
+ *
73
+ * ⚠️ `opinion` is TYPED as always-present because the wire shape says so, but since
74
+ * 2026-07-16 its step is skippable, so "" is a legitimate value. The live server still
75
+ * validates it with minLength=1 and 422s an empty one — a server-side fix this client
76
+ * depends on. See the REQUIRED_STEPS note in steps.ts.
77
+ */
63
78
  export interface QuestionnaireAnswers {
64
- /** The mandatory "what do you think about the app" free text. */
79
+ /** The "what do you think about the app" free text. Skippable → may be "". */
65
80
  opinion: string;
66
- /** The optional "help us improve" free text. */
81
+ /** The "help us improve" free text. Skippable → may be "" / absent. */
67
82
  improve?: string;
68
- /** The optional "suggestions" free text. */
83
+ /** The "any improvements or feedback" free text. REQUIRED of the user in the gate. */
69
84
  suggestions?: string;
70
85
  }
71
86
 
@@ -140,7 +155,13 @@ export interface QuestionnaireGateProps {
140
155
  onShown?: () => void;
141
156
  /** Partial theme merged over the active onboarding theme. */
142
157
  theme?: Partial<import("../theme/types").OnboardingTheme>;
143
- /** Show a Skip/Not now control. Default true (always skippable, never a dead end). */
158
+ /**
159
+ * Whether the WHOLE popup can be dismissed — backdrop tap and Android hardware back.
160
+ * Default true (never a dead end), and the only escape from the required last step, so
161
+ * turning it off traps a user who has nothing to say. That path is what fires
162
+ * `questionnaire_dismissed`; the in-card "Not now" link it used to name is gone.
163
+ * Per-step Skip is separate and is driven by `steps.ts`, not by this flag.
164
+ */
144
165
  dismissible?: boolean;
145
166
  }
146
167