@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.
- package/AGENTS.md +29 -5
- package/CHANGELOG.md +163 -6
- package/README.md +105 -10
- package/dist/analytics/index.d.mts +6 -6
- package/dist/analytics/index.d.ts +6 -6
- package/dist/analytics/index.js +447 -49
- package/dist/analytics/index.js.map +1 -1
- package/dist/analytics/index.mjs +447 -49
- package/dist/analytics/index.mjs.map +1 -1
- package/dist/coachmarks/index.d.mts +2 -2
- package/dist/coachmarks/index.d.ts +2 -2
- package/dist/coachmarks/index.js.map +1 -1
- package/dist/coachmarks/index.mjs.map +1 -1
- package/dist/{currentSession-f7LWcdWG.d.ts → currentSession-61dcm3V-.d.ts} +10 -2
- package/dist/{currentSession-d9CrBxwe.d.mts → currentSession-Bs2JfTJ8.d.mts} +10 -2
- package/dist/{decision-BzbiKwk3.d.mts → decision-Bl_M2y3r.d.mts} +1 -1
- package/dist/{decision-plDEOCkt.d.ts → decision-Cau5KmP6.d.ts} +1 -1
- package/dist/index.d.mts +558 -15
- package/dist/index.d.ts +558 -15
- package/dist/index.js +1144 -375
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +969 -207
- package/dist/index.mjs.map +1 -1
- package/dist/questionnaire/index.d.mts +69 -23
- package/dist/questionnaire/index.d.ts +69 -23
- package/dist/questionnaire/index.js +643 -172
- package/dist/questionnaire/index.js.map +1 -1
- package/dist/questionnaire/index.mjs +608 -137
- package/dist/questionnaire/index.mjs.map +1 -1
- package/dist/reviews/index.d.mts +24 -12
- package/dist/reviews/index.d.ts +24 -12
- package/dist/reviews/index.js +507 -173
- package/dist/reviews/index.js.map +1 -1
- package/dist/reviews/index.mjs +462 -129
- package/dist/reviews/index.mjs.map +1 -1
- package/dist/showcase/index.d.mts +2 -2
- package/dist/showcase/index.d.ts +2 -2
- package/dist/showcase/index.js.map +1 -1
- package/dist/showcase/index.mjs.map +1 -1
- package/dist/transport-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/currentSession.ts +1 -1
- package/src/cards/CardGridSelectCard.tsx +253 -0
- package/src/cards/ChipSelectCard.tsx +1 -1
- package/src/cards/SelectionCard.tsx +15 -19
- package/src/cards/index.ts +13 -1
- package/src/cards/optionSchema.ts +65 -0
- package/src/coachmarks/types.ts +1 -1
- package/src/components/CardHandoff.tsx +39 -7
- package/src/components/DoneCheck.tsx +94 -0
- package/src/components/ModalPaneHost.tsx +141 -0
- package/src/components/StepDots.tsx +112 -0
- package/src/device/appVersion.ts +29 -8
- package/src/device/deviceModel.ts +21 -8
- package/src/features/cache.ts +1 -1
- package/src/haptics/expo-haptics.d.ts +31 -0
- package/src/haptics/haptics.ts +58 -0
- package/src/icons/IconRegistry.tsx +53 -0
- package/src/icons/WireIcon.tsx +85 -0
- package/src/icons/expoIcons.ts +165 -0
- package/src/icons/index.ts +18 -0
- package/src/icons/vocabulary.ts +154 -0
- package/src/index.ts +20 -0
- package/src/questionnaire/QuestionnaireGate.tsx +315 -164
- package/src/questionnaire/decision.ts +4 -2
- package/src/questionnaire/index.ts +3 -2
- package/src/questionnaire/steps.ts +261 -0
- package/src/questionnaire/types.ts +33 -12
- package/src/reviews/ReviewGate.tsx +250 -152
- package/src/reviews/index.ts +7 -3
- package/src/reviews/transport.ts +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
|
@@ -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:
|
|
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-
|
|
3
|
-
import { W as WireFeatures, a as WireFeaturesConfig } from './types-
|
|
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,
|
|
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
|
|
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.
|
|
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
|
},
|
package/src/WireOnboarding.tsx
CHANGED
|
@@ -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
|
|
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:
|
|
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,
|