@wireai/activation 0.8.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 (91) hide show
  1. package/AGENTS.md +29 -5
  2. package/CHANGELOG.md +163 -6
  3. package/README.md +105 -10
  4. package/dist/analytics/index.d.mts +6 -6
  5. package/dist/analytics/index.d.ts +6 -6
  6. package/dist/analytics/index.js +447 -49
  7. package/dist/analytics/index.js.map +1 -1
  8. package/dist/analytics/index.mjs +447 -49
  9. package/dist/analytics/index.mjs.map +1 -1
  10. package/dist/coachmarks/index.d.mts +2 -2
  11. package/dist/coachmarks/index.d.ts +2 -2
  12. package/dist/coachmarks/index.js.map +1 -1
  13. package/dist/coachmarks/index.mjs.map +1 -1
  14. package/dist/{currentSession-f7LWcdWG.d.ts → currentSession-61dcm3V-.d.ts} +10 -2
  15. package/dist/{currentSession-d9CrBxwe.d.mts → currentSession-Bs2JfTJ8.d.mts} +10 -2
  16. package/dist/{decision-BzbiKwk3.d.mts → decision-Bl_M2y3r.d.mts} +1 -1
  17. package/dist/{decision-plDEOCkt.d.ts → decision-Cau5KmP6.d.ts} +1 -1
  18. package/dist/index.d.mts +558 -15
  19. package/dist/index.d.ts +558 -15
  20. package/dist/index.js +1144 -375
  21. package/dist/index.js.map +1 -1
  22. package/dist/index.mjs +969 -207
  23. package/dist/index.mjs.map +1 -1
  24. package/dist/questionnaire/index.d.mts +69 -23
  25. package/dist/questionnaire/index.d.ts +69 -23
  26. package/dist/questionnaire/index.js +643 -172
  27. package/dist/questionnaire/index.js.map +1 -1
  28. package/dist/questionnaire/index.mjs +608 -137
  29. package/dist/questionnaire/index.mjs.map +1 -1
  30. package/dist/reviews/index.d.mts +24 -12
  31. package/dist/reviews/index.d.ts +24 -12
  32. package/dist/reviews/index.js +507 -173
  33. package/dist/reviews/index.js.map +1 -1
  34. package/dist/reviews/index.mjs +462 -129
  35. package/dist/reviews/index.mjs.map +1 -1
  36. package/dist/showcase/index.d.mts +2 -2
  37. package/dist/showcase/index.d.ts +2 -2
  38. package/dist/showcase/index.js.map +1 -1
  39. package/dist/showcase/index.mjs.map +1 -1
  40. package/dist/transport-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/currentSession.ts +1 -1
  53. package/src/cards/CardGridSelectCard.tsx +253 -0
  54. package/src/cards/ChipSelectCard.tsx +1 -1
  55. package/src/cards/SelectionCard.tsx +15 -19
  56. package/src/cards/index.ts +13 -1
  57. package/src/cards/optionSchema.ts +65 -0
  58. package/src/coachmarks/types.ts +1 -1
  59. package/src/components/CardHandoff.tsx +39 -7
  60. package/src/components/DoneCheck.tsx +94 -0
  61. package/src/components/ModalPaneHost.tsx +141 -0
  62. package/src/components/StepDots.tsx +112 -0
  63. package/src/device/appVersion.ts +29 -8
  64. package/src/device/deviceModel.ts +21 -8
  65. package/src/features/cache.ts +1 -1
  66. package/src/haptics/expo-haptics.d.ts +31 -0
  67. package/src/haptics/haptics.ts +58 -0
  68. package/src/icons/IconRegistry.tsx +53 -0
  69. package/src/icons/WireIcon.tsx +85 -0
  70. package/src/icons/expoIcons.ts +165 -0
  71. package/src/icons/index.ts +18 -0
  72. package/src/icons/vocabulary.ts +154 -0
  73. package/src/index.ts +20 -0
  74. package/src/questionnaire/QuestionnaireGate.tsx +315 -164
  75. package/src/questionnaire/decision.ts +4 -2
  76. package/src/questionnaire/index.ts +3 -2
  77. package/src/questionnaire/steps.ts +261 -0
  78. package/src/questionnaire/types.ts +33 -12
  79. package/src/reviews/ReviewGate.tsx +250 -152
  80. package/src/reviews/index.ts +7 -3
  81. package/src/reviews/transport.ts +77 -3
  82. package/src/reviews/types.ts +13 -0
  83. package/src/session/persistedSession.ts +1 -1
  84. package/src/session-analytics/lifecycle.ts +1 -1
  85. package/src/session-analytics/useSessionStart.ts +2 -2
  86. package/src/showcase/FeatureShowcase.tsx +1 -1
  87. package/src/showcase/showcaseColors.ts +1 -1
  88. package/src/theme/mergeThemeOver.ts +27 -0
  89. package/src/types.ts +8 -0
  90. package/dist/transport-BeO_Brcu.d.mts +0 -40
  91. package/dist/transport-DLpd1v5_.d.ts +0 -40
@@ -13,7 +13,7 @@ type WireOnboardingStorage = {
13
13
  * this can't be resumed server-side anyway, so the client mints fresh past it.
14
14
  */
15
15
  declare const DEFAULT_SESSION_TTL_MS = 3600000;
16
- /** Storage key for an app's cached session, e.g. `wireai:session:myelino`. */
16
+ /** Storage key for an app's cached session, e.g. `wireai:session:acme`. */
17
17
  declare const sessionStorageKey: (appId: string) => string;
18
18
  /** Resolved session for a mount: the id to use + whether it was restored from storage. */
19
19
  type LoadedSession = {
@@ -1,6 +1,6 @@
1
1
  import { O as OnboardingTheme } from './types-BKfpdZzX.mjs';
2
- import { C as CoachmarkStorage } from './types-GL_hQ0TN.mjs';
3
- import { W as WireFeatures, a as WireFeaturesConfig } from './types-CMuOexw0.mjs';
2
+ import { C as CoachmarkStorage } from './types-BcmagF6K.mjs';
3
+ import { W as WireFeatures, a as WireFeaturesConfig } from './types-CKFhyrMu.mjs';
4
4
 
5
5
  /**
6
6
  * The firing verdict — the SAME contract on both sides of the seam. `decideReview`
@@ -11,6 +11,18 @@ interface ReviewDecision {
11
11
  fire: boolean;
12
12
  reason?: string;
13
13
  }
14
+ /**
15
+ * The `GET /v1/reviews/decision` response: `ReviewDecision` plus the additive `arm`.
16
+ *
17
+ * `arm` is the firing experiment's sticky assignment for this device/session. Echo it back in
18
+ * the submission's `meta.firing_arm` and the server keeps it verbatim (`_stamp_firing_arm`:
19
+ * "an arm the kit already echoed is left as-is"); omit it and the server recomputes from the
20
+ * sticky hash. Echoing is what makes per-arm attribution survive a later reweighting of the
21
+ * experiment, so a host running one should pass it through.
22
+ */
23
+ interface ReviewDecisionResponse extends ReviewDecision {
24
+ arm?: string | null;
25
+ }
14
26
  /** Where to route a rating: 5 stars → the store, 1-4 → the feedback form. */
15
27
  type RatingRoute = "store" | "feedback";
16
28
  /**
@@ -149,4 +161,4 @@ interface ReviewGateController {
149
161
  markResolved: () => void;
150
162
  }
151
163
 
152
- export type { ReviewGateProps as R, UseReviewGateOptions as U, ReviewPresentation as a, ReviewGateController as b, ReviewDecision as c, ReviewStoreConfig as d, RatingRoute as e, ReviewConfig as f, ReviewGateEvent as g, ReviewGateEventName as h, ReviewSubmission as i, ReviewTarget as j };
164
+ export type { ReviewGateProps as R, UseReviewGateOptions as U, ReviewPresentation as a, ReviewGateController as b, ReviewDecision as c, ReviewStoreConfig as d, RatingRoute as e, ReviewConfig as f, ReviewDecisionResponse as g, ReviewGateEvent as h, ReviewGateEventName as i, ReviewSubmission as j, ReviewTarget as k };
package/metro/index.d.ts CHANGED
@@ -16,12 +16,20 @@ export interface WithWireOnboardingOptions {
16
16
  * When omitted, the kit resolves from the installed package.
17
17
  */
18
18
  source?: string;
19
+ /**
20
+ * Extra specifiers to treat as optional, i.e. resolve to an EMPTY module instead of
21
+ * failing the build when they are not installed. Added to the kit's own
22
+ * `OPTIONAL_MODULES` list, never replacing it.
23
+ */
24
+ optionalModules?: string[];
19
25
  }
20
26
 
21
27
  /**
22
28
  * Non-destructively wire the @wireai/activation kit into an existing Metro config:
23
29
  * pins single-instance deps to the app's copy, optionally maps the kit specifier
24
- * to source + watches it. Mutates and returns the same config object.
30
+ * to source + watches it, and makes the kit's optional modules genuinely optional
31
+ * (an absent one resolves to an empty module instead of breaking the build).
32
+ * Mutates and returns the same config object.
25
33
  */
26
34
  export function withWireOnboarding<TConfig extends Record<string, any>>(
27
35
  config: TConfig,
@@ -30,3 +38,12 @@ export function withWireOnboarding<TConfig extends Record<string, any>>(
30
38
 
31
39
  /** The deps pinned to the app's single copy. */
32
40
  export const SINGLE_INSTANCE_DEPS: readonly ['react', 'react-native', 'wireai-rn', 'zod'];
41
+
42
+ /**
43
+ * The modules the kit reads through a guarded require and can live without. When one of
44
+ * these does not resolve, `withWireOnboarding` maps it to an empty module so the host
45
+ * still bundles and the feature degrades (icon → null, analytics field omitted, native
46
+ * review → store-URL fallback). Modules the kit imports statically are deliberately NOT
47
+ * in this list — stubbing those would hide a real build error.
48
+ */
49
+ export const OPTIONAL_MODULES: readonly string[];
package/metro/index.js CHANGED
@@ -26,6 +26,8 @@
26
26
  * @param {string} [options.source] Absolute path to the kit's `src/` dir. When
27
27
  * set, the kit specifier maps here (source consumption) and it's added to
28
28
  * watchFolders; when omitted, the kit resolves from the installed package.
29
+ * @param {string[]} [options.optionalModules] Extra specifiers to treat as
30
+ * optional (see OPTIONAL_MODULES). Added to the kit's list, never replacing it.
29
31
  * @returns {object} The same config object, mutated in place and returned.
30
32
  */
31
33
  'use strict';
@@ -35,6 +37,60 @@ const path = require('path');
35
37
  /** Deps that MUST resolve to the app's single copy (a 2nd React instance crashes RN). */
36
38
  const SINGLE_INSTANCE_DEPS = ['react', 'react-native', 'wireai-rn', 'zod'];
37
39
 
40
+ /**
41
+ * Modules the kit reads through a GUARDED require/import and is designed to live without.
42
+ *
43
+ * ── HOW OPTIONAL DEPS ACTUALLY WORK UNDER METRO (verified, not folklore) ────────────────
44
+ *
45
+ * Metro has NO runtime module resolution: every specifier is resolved when the bundle is BUILT.
46
+ * But it DOES have a first-class optional-dependency mechanism, and it needs two things:
47
+ *
48
+ * 1. a STRING-LITERAL specifier — Metro's collector only matches `require("literal")` /
49
+ * `import("literal")`. A variable specifier collects NOTHING, so the module never enters
50
+ * the bundle at all (that was the 0.8.0 bug; see src/icons/expoIcons.ts), and
51
+ * 2. the call sitting inside a TRY/CATCH — that is literally how Metro decides a dependency
52
+ * is optional (`isOptional`), and it only counts when the transformer has
53
+ * `allowOptionalDependencies` enabled. Expo's `getDefaultConfig` enables it; plain
54
+ * `metro-config` defaults it to FALSE.
55
+ *
56
+ * When both hold and the module is absent, Metro puts `null` in the module's dependencyMap, and
57
+ * `metroRequire(null, "name")` throws "Cannot find module" — synchronously, from metroRequire
58
+ * itself, so the caller's own catch swallows it. Verified in a real bundle: haptics' dependencyMap
59
+ * is `[355,null,1300]` with `expo-haptics` uninstalled. So DO NOT "simplify" a try/catch away from
60
+ * around a guarded require: the try/catch is load-bearing, and removing it turns an absent peer
61
+ * into a hard build failure.
62
+ *
63
+ * ── WHY THIS LIST STILL EXISTS ─────────────────────────────────────────────────────────
64
+ *
65
+ * Mechanism (2) is off by default in bare Metro (`allowOptionalDependencies: false`), and a host
66
+ * can turn it off. There, an absent optional module fails the BUILD with "Unable to resolve
67
+ * module". So `resolveRequest` below maps an absent module on this list to an EMPTY module, which
68
+ * lands on each caller's existing "peer absent" degradation path (no exports on the namespace →
69
+ * undefined → icon renders null / analytics field omitted / native review → store-URL fallback).
70
+ * It is a safety net that makes optionality independent of the host's Metro settings — not the
71
+ * only thing holding it up.
72
+ *
73
+ * ONLY modules read through a guarded require/import belong here. A module the kit imports
74
+ * STATICALLY at module scope (react-native-reanimated, expo-blur,
75
+ * @blazejkustra/react-native-onboarding) must NOT be stubbed: an empty module would turn a loud
76
+ * build-time "Unable to resolve module" into a silent `undefined` component that crashes at
77
+ * render, which is strictly worse. `test/canary/metroResolution.test.tsx` keeps this list in sync
78
+ * with the source by asking Metro which deps it marks optional.
79
+ */
80
+ const OPTIONAL_MODULES = [
81
+ // src/icons/expoIcons.ts — icon families; absent → WireIcon renders null.
82
+ '@expo/vector-icons',
83
+ // src/device/appVersion.ts — analytics `by_app_version`; absent → field omitted.
84
+ 'expo-constants',
85
+ 'expo-application',
86
+ // src/device/deviceModel.ts — analytics iOS `by_model`; absent → field omitted.
87
+ 'expo-device',
88
+ // src/reviews/storeReview.ts — native review prompt; absent → store-URL fallback.
89
+ 'expo-store-review',
90
+ // src/haptics/haptics.ts — haptic feedback; absent → silent no-op.
91
+ 'expo-haptics',
92
+ ];
93
+
38
94
  function withWireOnboarding(config, options) {
39
95
  const opts = options || {};
40
96
  const appRoot = opts.appRoot || process.cwd();
@@ -67,6 +123,29 @@ function withWireOnboarding(config, options) {
67
123
  ...existingExtra,
68
124
  };
69
125
 
126
+ // ── resolveRequest: make the optional modules genuinely optional ────────────
127
+ // Non-destructive by construction: the app's own resolveRequest (or Metro's default) is asked
128
+ // FIRST and keeps full authority. We only act on the failure of an OPTIONAL specifier, turning
129
+ // "Unable to resolve module" into an empty module so a host that never installed the peer still
130
+ // bundles. A non-optional module's failure is rethrown untouched — a missing `react` must stay
131
+ // a hard, loud build error.
132
+ const optional = new Set([...OPTIONAL_MODULES, ...(opts.optionalModules || [])]);
133
+ const upstream = resolver.resolveRequest;
134
+
135
+ resolver.resolveRequest = (context, moduleName, platform) => {
136
+ const resolve = upstream || context.resolveRequest;
137
+ try {
138
+ return resolve(context, moduleName, platform);
139
+ } catch (error) {
140
+ // Match the package root and its subpaths ('@expo/vector-icons/build/Icons').
141
+ const isOptional = [...optional].some(
142
+ (name) => moduleName === name || moduleName.startsWith(name + '/'),
143
+ );
144
+ if (!isOptional) throw error;
145
+ return { type: 'empty' };
146
+ }
147
+ };
148
+
70
149
  // ── watchFolders: add the kit source so Metro reads + HMRs it ───────────────
71
150
  if (source) {
72
151
  const existingWatch = Array.isArray(config.watchFolders) ? config.watchFolders : [];
@@ -81,3 +160,4 @@ function withWireOnboarding(config, options) {
81
160
  module.exports = { withWireOnboarding };
82
161
  module.exports.withWireOnboarding = withWireOnboarding;
83
162
  module.exports.SINGLE_INSTANCE_DEPS = SINGLE_INSTANCE_DEPS;
163
+ module.exports.OPTIONAL_MODULES = OPTIONAL_MODULES;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wireai/activation",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "private": false,
5
5
  "description": "Premium, fully-themable drop-in AI onboarding kit for React Native / Expo, on top of the open-source wireai-rn SDK.",
6
6
  "author": "Malik Chohra <malik@getwireai.com>",
@@ -143,7 +143,9 @@
143
143
  },
144
144
  "peerDependencies": {
145
145
  "@blazejkustra/react-native-onboarding": ">=1",
146
+ "@expo/vector-icons": ">=14",
146
147
  "expo-blur": ">=13",
148
+ "expo-haptics": ">=13",
147
149
  "expo-store-review": ">=6",
148
150
  "react": ">=18.0.0",
149
151
  "react-native": ">=0.73.0",
@@ -156,12 +158,18 @@
156
158
  "zod": {
157
159
  "optional": false
158
160
  },
161
+ "@expo/vector-icons": {
162
+ "optional": true
163
+ },
159
164
  "react-native-reanimated": {
160
165
  "optional": true
161
166
  },
162
167
  "expo-blur": {
163
168
  "optional": true
164
169
  },
170
+ "expo-haptics": {
171
+ "optional": true
172
+ },
165
173
  "expo-store-review": {
166
174
  "optional": true
167
175
  },
@@ -16,6 +16,7 @@ import { WireAIProvider, type LocalLLMConfig } from "wireai-rn";
16
16
  import { OnboardingThemeProvider } from "./theme/ThemeContext";
17
17
  import { IllustrationProvider } from "./components/Illustration";
18
18
  import { defaultIllustrations } from "./illustrations/defaultIllustrations";
19
+ import { IconRegistryProvider } from "./icons/IconRegistry";
19
20
  import { LoadingScreen } from "./components/LoadingScreen";
20
21
  import { OnboardingFlow, DEFAULT_COPY } from "./OnboardingFlow";
21
22
  import { onboardingComponents } from "./cards";
@@ -36,6 +37,7 @@ export const WireOnboarding: React.FC<WireOnboardingProps> = ({
36
37
  theme,
37
38
  components,
38
39
  illustrations,
40
+ icons,
39
41
  validators,
40
42
  onComplete,
41
43
  onSkip,
@@ -229,6 +231,7 @@ export const WireOnboarding: React.FC<WireOnboardingProps> = ({
229
231
  return (
230
232
  <OnboardingThemeProvider theme={theme}>
231
233
  <IllustrationProvider registry={{ ...defaultIllustrations, ...illustrations }}>
234
+ <IconRegistryProvider registry={icons}>
232
235
  <WireAIProvider llm={llm} components={cards}>
233
236
  <OnboardingFlow
234
237
  validators={validators}
@@ -248,6 +251,7 @@ export const WireOnboarding: React.FC<WireOnboardingProps> = ({
248
251
  clientContext={clientContext}
249
252
  />
250
253
  </WireAIProvider>
254
+ </IconRegistryProvider>
251
255
  </IllustrationProvider>
252
256
  </OnboardingThemeProvider>
253
257
  );
@@ -6,7 +6,7 @@
6
6
  * post `app.session_started` with it — so the SERVER knows that id. But other client paths
7
7
  * (`identify`, host `app_event`s through the analytics façade) used to reference a DIFFERENT id
8
8
  * (a frozen per-instance id), which the server had never seen, so it back-filled a synthetic
9
- * `session_started` — inflating session counts (the Morrow/Myelino "phantom-session" bug).
9
+ * `session_started` — inflating session counts (the "phantom-session" bug).
10
10
  *
11
11
  * This registry is the single seam that lets those paths reuse the LIVE per-open session id the
12
12
  * server already ingested. `reportSessionStart` writes the current id here on every open; the façade
@@ -0,0 +1,253 @@
1
+ /**
2
+ * CardGridSelectCard — icon-led choices as a two-column grid of small cards.
3
+ *
4
+ * The shape Malik asked for (2026-07-16): "the multi select needs to be a small card like this
5
+ * one, with an icon on top, it is much better." Icon top-left, short label beneath, the whole
6
+ * card is the tap target, selection lives on the card itself.
7
+ *
8
+ * ┌──────────────┐ ┌──────────────┐
9
+ * │ ◇ │ │ ▣ │ ← icon on top
10
+ * │ Wedding day │ │ Birthday │ ← label beneath
11
+ * └──────────────┘ └──────────────┘
12
+ *
13
+ * WHY A NEW CARD RATHER THAN A `display` MODE ON SelectionCard: the card's `description` IS the
14
+ * router — it is the only thing the model reads when choosing (frequent_rules #4). SelectionCard
15
+ * already carries two modes behind one description; a third ("a list, but sometimes a grid, and
16
+ * then the icons matter") cannot be stated honestly in one string, and a muddy description makes
17
+ * the model pick the wrong card. The kit already has the precedent: ChipSelectCard exists ONLY
18
+ * because "compact vs. descriptive" is a presentation difference worth its own name, over the
19
+ * very same pick-from-options job. Back-compat costs nothing here — `supportedComponents`
20
+ * (WireOnboarding.tsx) means an already-shipped client never advertises this name, so the server
21
+ * intersects it away and can never emit it to the apps already in the stores. See the CHANGELOG.
22
+ *
23
+ * Layout is percentage flex, never a measured width: `flexBasis` ~47% + `flexGrow` makes exactly
24
+ * two per row that fill the width, with no Dimensions read, no onLayout, and no re-render on
25
+ * rotation. An odd last card stretches the row — deliberate, and the same thing every native
26
+ * grid does.
27
+ *
28
+ * Motion mirrors SelectionCard's OptionRow exactly (press tick spring + a native-driver opacity
29
+ * crossfade of the selected overlay), so the two cards feel like one system.
30
+ */
31
+ import React, { useCallback, useEffect, useRef, useState } from "react";
32
+ import { Animated, Easing, Pressable, StyleSheet, Text, View } from "react-native";
33
+ import { z } from "zod";
34
+ import type { InjectedProps, WireAIComponent } from "wireai-rn";
35
+ import { useOnboardingTheme } from "../theme/ThemeContext";
36
+ import { labelStyle } from "../theme/typography";
37
+ import { Button } from "../components/Button";
38
+ import { CardLayout } from "../components/CardLayout";
39
+ import { WireIcon } from "../icons/WireIcon";
40
+ import { normalizeOptions, optionsField, type CardOption } from "./optionSchema";
41
+ import {
42
+ PRESS_SPRING,
43
+ ROW_PRESS_SCALE,
44
+ SELECT_FILL_MS,
45
+ WIRE_BEZIER,
46
+ scaledMs,
47
+ stateChangeDuration,
48
+ } from "../motion/motionSpec";
49
+ import { useReducedMotion } from "../motion/useReducedMotion";
50
+
51
+ const easeWire = Easing.bezier(...WIRE_BEZIER);
52
+
53
+ const schema = z.object({
54
+ title: z.string().describe("Question or prompt for the user, e.g. 'What brings you here?'"),
55
+ options: optionsField(
56
+ "The choices, 2-6 of them. Give EVERY option an `icon` from the vocabulary — the icon is " +
57
+ "the point of this card, and an option without one renders as a bare label next to " +
58
+ "icon-bearing neighbours. Keep labels short (1-3 words) so they fit a half-width card.",
59
+ ),
60
+ submitLabel: z.string().optional().describe("Submit button label, default: Continue"),
61
+ multiSelect: z
62
+ .boolean()
63
+ .optional()
64
+ .describe("Allow multiple cards selected at once, default true"),
65
+ });
66
+
67
+ type Props = Omit<z.infer<typeof schema>, "options"> &
68
+ InjectedProps & {
69
+ /** Bare strings stay valid (normalized on the way in) — see optionSchema.ts. */
70
+ options: ReadonlyArray<string | CardOption>;
71
+ onSelect?: (selected: string | string[]) => void;
72
+ /** Fires as the live selection changes (before Continue), so the flow can prefetch the
73
+ * next turn. Same value shape as onSelect. Optional — injected by OnboardingFlow. */
74
+ onSelectionChange?: (selected: string | string[]) => void;
75
+ };
76
+
77
+ type OptionCardProps = {
78
+ opt: CardOption;
79
+ isSelected: boolean;
80
+ multiSelect: boolean;
81
+ onToggle: (value: string) => void;
82
+ };
83
+
84
+ const _OptionCard: React.FC<OptionCardProps> = ({ opt, isSelected, multiSelect, onToggle }) => {
85
+ const t = useOnboardingTheme();
86
+ const reduced = useReducedMotion();
87
+ const handlePress = useCallback(() => onToggle(opt.value), [opt.value, onToggle]);
88
+
89
+ // Press tick: scale 1→0.975, spring, native driver. Skipped under reduce motion.
90
+ const pressScale = useRef(new Animated.Value(1)).current;
91
+ const pressTo = useCallback(
92
+ (to: number) => {
93
+ if (reduced) return;
94
+ Animated.spring(pressScale, {
95
+ toValue: to,
96
+ friction: PRESS_SPRING.friction,
97
+ tension: PRESS_SPRING.tension,
98
+ useNativeDriver: true,
99
+ }).start();
100
+ },
101
+ [pressScale, reduced],
102
+ );
103
+ const handlePressIn = useCallback(() => pressTo(ROW_PRESS_SCALE), [pressTo]);
104
+ const handlePressOut = useCallback(() => pressTo(1), [pressTo]);
105
+
106
+ // Selected fill: accent border + tinted wash on an absolute overlay whose opacity crossfades
107
+ // (native driver — see EXTRACTED-SPEC.md D7). Reduce motion: snap.
108
+ const selectT = useRef(new Animated.Value(isSelected ? 1 : 0)).current;
109
+ useEffect(() => {
110
+ const anim = Animated.timing(selectT, {
111
+ toValue: isSelected ? 1 : 0,
112
+ duration: scaledMs(stateChangeDuration(reduced, SELECT_FILL_MS)),
113
+ easing: easeWire,
114
+ useNativeDriver: true,
115
+ });
116
+ anim.start();
117
+ return () => anim.stop();
118
+ }, [isSelected, reduced, selectT]);
119
+
120
+ return (
121
+ <Pressable
122
+ onPress={handlePress}
123
+ onPressIn={handlePressIn}
124
+ onPressOut={handlePressOut}
125
+ accessibilityRole={multiSelect ? "checkbox" : "radio"}
126
+ accessibilityState={multiSelect ? { checked: isSelected } : { selected: isSelected }}
127
+ accessibilityLabel={opt.label}
128
+ style={styles.cell}
129
+ >
130
+ <Animated.View
131
+ style={[
132
+ styles.card,
133
+ {
134
+ padding: t.spacing.md,
135
+ borderRadius: t.radius.lg,
136
+ gap: t.spacing.sm,
137
+ borderColor: t.colors.border,
138
+ backgroundColor: t.colors.surface,
139
+ transform: [{ scale: pressScale }],
140
+ },
141
+ ]}
142
+ >
143
+ <Animated.View
144
+ pointerEvents="none"
145
+ style={[
146
+ StyleSheet.absoluteFill,
147
+ styles.selectedOverlay,
148
+ {
149
+ borderRadius: t.radius.lg,
150
+ borderColor: t.colors.primary,
151
+ backgroundColor: t.colors.primarySoft,
152
+ opacity: selectT,
153
+ },
154
+ ]}
155
+ />
156
+ {/* Accent discipline (same rule as SelectionCard's rows): label AND icon keep the text
157
+ color in both states; the accent lives on the border and fill only. An unresolvable
158
+ icon renders null, so a card with a bad name lays out like one with no icon. */}
159
+ <WireIcon name={opt.icon} size={24} color={t.colors.text} />
160
+ <Text style={[labelStyle(t.fonts), { color: t.colors.text }]}>{opt.label}</Text>
161
+ </Animated.View>
162
+ </Pressable>
163
+ );
164
+ };
165
+ const OptionCard = React.memo(_OptionCard);
166
+
167
+ const _CardGridSelectCard: React.FC<Props> = ({
168
+ title,
169
+ options,
170
+ submitLabel = "Continue",
171
+ multiSelect = true,
172
+ onSelect,
173
+ onSelectionChange,
174
+ }) => {
175
+ const t = useOnboardingTheme();
176
+ const opts = React.useMemo(() => normalizeOptions(options), [options]);
177
+ const [selected, setSelected] = useState<string[]>([]);
178
+ const [submitted, setSubmitted] = useState(false);
179
+
180
+ // Report the settled selection upward (before Continue) so the flow can prefetch the next
181
+ // turn. Debouncing lives in the flow; here we just emit on every change once something's picked.
182
+ useEffect(() => {
183
+ if (!submitted && selected.length) onSelectionChange?.(multiSelect ? selected : selected[0]!);
184
+ }, [selected, submitted, multiSelect, onSelectionChange]);
185
+
186
+ const toggle = useCallback(
187
+ (value: string) => {
188
+ if (submitted) return;
189
+ if (multiSelect) {
190
+ setSelected((prev) =>
191
+ prev.includes(value) ? prev.filter((v) => v !== value) : [...prev, value],
192
+ );
193
+ } else {
194
+ setSelected([value]);
195
+ }
196
+ },
197
+ [submitted, multiSelect],
198
+ );
199
+
200
+ const handleSubmit = useCallback(() => {
201
+ if (!selected.length || !onSelect || submitted) return;
202
+ setSubmitted(true);
203
+ onSelect(multiSelect ? selected : selected[0]!);
204
+ }, [selected, onSelect, submitted, multiSelect]);
205
+
206
+ return (
207
+ <CardLayout
208
+ title={title}
209
+ footer={
210
+ <Button
211
+ title={submitLabel}
212
+ onPress={handleSubmit}
213
+ variant="primary"
214
+ disabled={selected.length === 0 || submitted}
215
+ />
216
+ }
217
+ >
218
+ <View style={[styles.grid, { gap: t.spacing.sm }]}>
219
+ {opts.map((opt) => (
220
+ <OptionCard
221
+ key={opt.value}
222
+ opt={opt}
223
+ isSelected={selected.includes(opt.value)}
224
+ multiSelect={multiSelect}
225
+ onToggle={toggle}
226
+ />
227
+ ))}
228
+ </View>
229
+ </CardLayout>
230
+ );
231
+ };
232
+
233
+ export const CardGridSelectCard: WireAIComponent = {
234
+ name: "CardGridSelectCard",
235
+ description:
236
+ "Use for VISUAL, icon-led choices shown as a 2-column grid of tappable cards (icon on top, " +
237
+ "short label beneath). Ideal for 2–6 concrete options that each have an obvious icon — " +
238
+ "occasions, channels, goals, categories — e.g. 'What brings you here?' or 'Where did you " +
239
+ "hear about us?'. Multi-select by default. Every option should carry an `icon`. Use " +
240
+ "SelectionCard instead when options need descriptions or have no natural icon; use " +
241
+ "ChipSelectCard for many short word-like tags.",
242
+ component: React.memo(_CardGridSelectCard) as WireAIComponent["component"],
243
+ propsSchema: schema,
244
+ defaultProps: { submitLabel: "Continue", multiSelect: true },
245
+ };
246
+
247
+ const styles = StyleSheet.create({
248
+ grid: { flexDirection: "row", flexWrap: "wrap" },
249
+ // Two per row: ~47% basis leaves room for the gap, flexGrow fills the remainder exactly.
250
+ cell: { flexBasis: "47%", flexGrow: 1 },
251
+ card: { borderWidth: 1.5, alignItems: "flex-start", overflow: "hidden" },
252
+ selectedOverlay: { borderWidth: 1.5 },
253
+ });
@@ -250,7 +250,7 @@ const _ChipSelectCard: React.FC<Props> = ({
250
250
  export const ChipSelectCard: WireAIComponent = {
251
251
  name: "ChipSelectCard",
252
252
  description:
253
- "Use for compact short labels: seasons, activities, travel styles, moods, categories. Ideal for 4–12 single-word or two-word options. Use SelectionCard instead when options need descriptions.",
253
+ "Use for compact short labels: seasons, activities, travel styles, moods, categories. Ideal for 4–12 single-word or two-word options. Use SelectionCard instead when options need descriptions; use CardGridSelectCard for a smaller set (2–6) of visual choices that each deserve an icon.",
254
254
  component: React.memo(_ChipSelectCard) as WireAIComponent["component"],
255
255
  propsSchema: schema,
256
256
  defaultProps: { multiSelect: true, submitLabel: "Continue" },
@@ -16,6 +16,8 @@ import { useOnboardingTheme } from "../theme/ThemeContext";
16
16
  import { labelStyle } from "../theme/typography";
17
17
  import { Button } from "../components/Button";
18
18
  import { CardLayout } from "../components/CardLayout";
19
+ import { WireIcon } from "../icons/WireIcon";
20
+ import { normalizeOptions, optionsField, type CardOption } from "./optionSchema";
19
21
  import {
20
22
  CHECK_SCALE_FROM,
21
23
  CHECK_SPRING,
@@ -30,28 +32,17 @@ import { useReducedMotion } from "../motion/useReducedMotion";
30
32
 
31
33
  const easeWire = Easing.bezier(...WIRE_BEZIER);
32
34
 
33
- const optionObjectSchema = z.object({
34
- value: z.string().describe("Option value"),
35
- label: z.string().describe("Option display label"),
36
- });
37
-
38
35
  const schema = z.object({
39
36
  title: z.string().describe("Question or prompt for the user"),
40
- options: z
41
- .preprocess(
42
- (arr) =>
43
- Array.isArray(arr)
44
- ? arr.map((item) => (typeof item === "string" ? { value: item, label: item } : item))
45
- : arr,
46
- z.array(optionObjectSchema).min(1),
47
- )
48
- .describe("List of choices"),
37
+ options: optionsField("List of choices"),
49
38
  submitLabel: z.string().optional().describe("Submit button label, default: Continue"),
50
39
  multiSelect: z.boolean().optional().describe("Allow multiple selections"),
51
40
  });
52
41
 
53
- type Props = z.infer<typeof schema> &
42
+ type Props = Omit<z.infer<typeof schema>, "options"> &
54
43
  InjectedProps & {
44
+ /** Bare strings stay valid (normalized on the way in) — see optionSchema.ts. */
45
+ options: ReadonlyArray<string | CardOption>;
55
46
  onSelect?: (selected: string | string[]) => void;
56
47
  /** Fires as the live selection changes (before Continue), so the flow can prefetch the
57
48
  * next turn. Same value shape as onSelect. Optional — injected by OnboardingFlow. */
@@ -59,7 +50,7 @@ type Props = z.infer<typeof schema> &
59
50
  };
60
51
 
61
52
  type OptionRowProps = {
62
- opt: z.infer<typeof optionObjectSchema>;
53
+ opt: CardOption;
63
54
  isSelected: boolean;
64
55
  multiSelect: boolean;
65
56
  onToggle: (value: string) => void;
@@ -182,7 +173,11 @@ const _OptionRow: React.FC<OptionRowProps> = ({ opt, isSelected, multiSelect, on
182
173
  ) : null}
183
174
  </View>
184
175
  {/* Accent discipline: the label keeps the text color in both states; the
185
- accent lives on the border, fill, and dot only. */}
176
+ accent lives on the border, fill, and dot only. The icon obeys the same rule —
177
+ it holds `text` in BOTH states rather than flipping to the accent, because an
178
+ icon is not an excuse to invent a second accent. Decorative + unresolvable-safe:
179
+ an unknown name renders null, so the row lays out exactly as an icon-less one. */}
180
+ <WireIcon name={opt.icon} size={20} color={t.colors.text} />
186
181
  <Text style={[labelStyle(t.fonts), { color: t.colors.text }]}>{opt.label}</Text>
187
182
  </Animated.View>
188
183
  </Pressable>
@@ -199,6 +194,7 @@ const _SelectionCard: React.FC<Props> = ({
199
194
  onSelectionChange,
200
195
  }) => {
201
196
  const t = useOnboardingTheme();
197
+ const opts = React.useMemo(() => normalizeOptions(options), [options]);
202
198
  const [selected, setSelected] = useState<string[]>([]);
203
199
  const [submitted, setSubmitted] = useState(false);
204
200
 
@@ -239,7 +235,7 @@ const _SelectionCard: React.FC<Props> = ({
239
235
  }
240
236
  >
241
237
  <View style={{ gap: t.spacing.sm + t.spacing.xs }}>
242
- {options.map((opt) => (
238
+ {opts.map((opt) => (
243
239
  <OptionRow
244
240
  key={opt.value}
245
241
  opt={opt}
@@ -256,7 +252,7 @@ const _SelectionCard: React.FC<Props> = ({
256
252
  export const SelectionCard: WireAIComponent = {
257
253
  name: "SelectionCard",
258
254
  description:
259
- "Use for mutually exclusive (radio) or multi-select (checkbox) choices from 2–8 options. Use ChipSelectCard instead when options are short single words.",
255
+ "Use for mutually exclusive (radio) or multi-select (checkbox) choices from 2–8 options, as a vertical list of rows. Best when options are wordy or need reading. Options may carry an optional `icon` from the vocabulary to make the list scannable. Use ChipSelectCard instead when options are short single words; use CardGridSelectCard when the options are visual and each has an obvious icon (occasions, channels, categories).",
260
256
  component: React.memo(_SelectionCard) as WireAIComponent["component"],
261
257
  propsSchema: schema,
262
258
  defaultProps: { submitLabel: "Continue", multiSelect: false },
@@ -7,16 +7,28 @@ import type { WireAIComponent } from "wireai-rn";
7
7
  import { ChipSelectCard } from "./ChipSelectCard";
8
8
  import { TextInputCard } from "./TextInputCard";
9
9
  import { SelectionCard } from "./SelectionCard";
10
+ import { CardGridSelectCard } from "./CardGridSelectCard";
10
11
  import { StatusCard } from "./StatusCard";
11
12
  import { NumberStepperCard } from "./NumberStepperCard";
12
13
  import { InterstitialCard } from "./InterstitialCard";
13
14
 
14
- export { ChipSelectCard, TextInputCard, SelectionCard, StatusCard, NumberStepperCard, InterstitialCard };
15
+ export {
16
+ ChipSelectCard,
17
+ TextInputCard,
18
+ SelectionCard,
19
+ CardGridSelectCard,
20
+ StatusCard,
21
+ NumberStepperCard,
22
+ InterstitialCard,
23
+ };
24
+ export { normalizeOptions, optionsField, optionObjectSchema } from "./optionSchema";
25
+ export type { CardOption } from "./optionSchema";
15
26
 
16
27
  export const onboardingComponents: WireAIComponent[] = [
17
28
  ChipSelectCard,
18
29
  TextInputCard,
19
30
  SelectionCard,
31
+ CardGridSelectCard,
20
32
  StatusCard,
21
33
  NumberStepperCard,
22
34
  InterstitialCard,