@wireai/activation 0.8.0 → 0.9.1
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 +202 -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-B31G0Cib.d.ts +128 -0
- package/dist/transport-Bzb-bcB2.d.mts +128 -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 +119 -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
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ModalPaneHost — "slide between panes inside a CenteredModal", the shared primitive
|
|
3
|
+
* behind BOTH the review gate's phase changes and the questionnaire's step changes.
|
|
4
|
+
* One primitive, two call sites: the two gates must never drift apart on feel.
|
|
5
|
+
*
|
|
6
|
+
* It does NOT reimplement the slide. `CardHandoff` already owns the design's
|
|
7
|
+
* "cards hand off, they don't cut" motion (exit left 180ms / enter right 220ms, 80ms
|
|
8
|
+
* lead, reduce-motion crossfade), so this delegates to it in `layout="auto"` mode and
|
|
9
|
+
* adds the ONE thing a modal needs that a full-screen flow does not: a card that
|
|
10
|
+
* resizes instead of snapping.
|
|
11
|
+
*
|
|
12
|
+
* WHY the height animation exists: the CenteredModal card is auto-height, so the moment
|
|
13
|
+
* a taller/shorter pane mounts, the card would jump to its new height in a single frame
|
|
14
|
+
* while the old pane is still sliding out — the exact "jarring change" the slide was
|
|
15
|
+
* added to remove. Measuring the pane and easing the card to it keeps one continuous
|
|
16
|
+
* surface.
|
|
17
|
+
*
|
|
18
|
+
* WHY it is the only non-native-driven value in the kit: height is a layout prop, so it
|
|
19
|
+
* cannot ride the native driver. That is a deliberate, bounded exception — ONE property
|
|
20
|
+
* on ONE small card, for ~220ms, while nothing is scrolling. The panes' own opacity and
|
|
21
|
+
* translate (inside CardHandoff) stay on the native driver, and the two values live on
|
|
22
|
+
* different views, so they never contend. Everything else in the kit stays transform/
|
|
23
|
+
* opacity-only.
|
|
24
|
+
*
|
|
25
|
+
* Height is eased ONLY on a pane change. A multiline box growing as the user types also
|
|
26
|
+
* fires onLayout, and animating that would make typing feel laggy — those resize
|
|
27
|
+
* instantly. Reduce motion: the height snaps too (movement removed, crossfade kept).
|
|
28
|
+
*
|
|
29
|
+
* `header` is the STATIC slot: chrome that belongs to the card rather than to any one pane
|
|
30
|
+
* (the questionnaire's back icon + step dots). It sits inside the measured container but
|
|
31
|
+
* OUTSIDE the CardHandoff, which buys two things a caller cannot get by rendering it above
|
|
32
|
+
* the host:
|
|
33
|
+
* 1. It never slides, so it is never DUPLICATED mid-handoff. Anything inside CardHandoff
|
|
34
|
+
* exists twice for ~300ms (the outgoing layer and the incoming one) — fine for a
|
|
35
|
+
* question that is genuinely changing, wrong for chrome that is not. Two dot rows
|
|
36
|
+
* cross-fading past each other is a bug, not a transition.
|
|
37
|
+
* 2. Its size is part of the measured height, so dropping the header (form → thanks)
|
|
38
|
+
* rides the same eased resize as the pane swap instead of snapping the card.
|
|
39
|
+
*/
|
|
40
|
+
import React, { useCallback, useEffect, useRef, useState } from "react";
|
|
41
|
+
import { Animated, Easing, StyleSheet, View, type LayoutChangeEvent } from "react-native";
|
|
42
|
+
|
|
43
|
+
import { CARD_ENTER_MS, WIRE_BEZIER, scaledMs } from "../motion/motionSpec";
|
|
44
|
+
import { useReducedMotion } from "../motion/useReducedMotion";
|
|
45
|
+
import { CardHandoff, type CardHandoffDirection } from "./CardHandoff";
|
|
46
|
+
|
|
47
|
+
const easeWire = Easing.bezier(...WIRE_BEZIER);
|
|
48
|
+
|
|
49
|
+
export type ModalPaneHostProps = {
|
|
50
|
+
/** Identity of the pane on screen (phase name / step key). A change runs the handoff. */
|
|
51
|
+
paneKey: string;
|
|
52
|
+
/** Travel direction — "backward" mirrors the slide when the user steps back. */
|
|
53
|
+
direction?: CardHandoffDirection;
|
|
54
|
+
/**
|
|
55
|
+
* Card-level chrome pinned above the sliding pane: measured with it, but never moved
|
|
56
|
+
* and never duplicated by the handoff. Omit it (or pass null) and nothing renders —
|
|
57
|
+
* dropping it between pane changes animates the card's height rather than snapping it.
|
|
58
|
+
* The node owns its own bottom spacing; the host adds none (it has no theme).
|
|
59
|
+
*/
|
|
60
|
+
header?: React.ReactNode;
|
|
61
|
+
children: React.ReactNode;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const _ModalPaneHost: React.FC<ModalPaneHostProps> = ({
|
|
65
|
+
paneKey,
|
|
66
|
+
direction = "forward",
|
|
67
|
+
header,
|
|
68
|
+
children,
|
|
69
|
+
}) => {
|
|
70
|
+
const reduced = useReducedMotion();
|
|
71
|
+
const height = useRef(new Animated.Value(0)).current;
|
|
72
|
+
/** Last height we settled on. null until the first real measurement lands. */
|
|
73
|
+
const measured = useRef<number | null>(null);
|
|
74
|
+
/** Drives the height style on only AFTER a real measurement, so nothing renders at 0. */
|
|
75
|
+
const [sized, setSized] = useState(false);
|
|
76
|
+
/** Set when the pane changed, cleared by the next measurement it causes. */
|
|
77
|
+
const paneChanged = useRef(false);
|
|
78
|
+
const running = useRef<Animated.CompositeAnimation | null>(null);
|
|
79
|
+
|
|
80
|
+
// Runs on mount and on every pane change, always BEFORE the resulting onLayout (which
|
|
81
|
+
// arrives from native after the commit) — so the next measurement knows why it fired.
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
paneChanged.current = true;
|
|
84
|
+
}, [paneKey]);
|
|
85
|
+
|
|
86
|
+
useEffect(() => () => running.current?.stop(), []);
|
|
87
|
+
|
|
88
|
+
const onLayout = useCallback(
|
|
89
|
+
(event: LayoutChangeEvent) => {
|
|
90
|
+
const next = Math.round(event.nativeEvent.layout.height);
|
|
91
|
+
if (!next) return;
|
|
92
|
+
const previous = measured.current;
|
|
93
|
+
if (previous !== null && Math.abs(previous - next) < 1) return;
|
|
94
|
+
|
|
95
|
+
const first = previous === null;
|
|
96
|
+
const ease = !first && !reduced && paneChanged.current;
|
|
97
|
+
paneChanged.current = false;
|
|
98
|
+
measured.current = next;
|
|
99
|
+
running.current?.stop();
|
|
100
|
+
|
|
101
|
+
// First measurement, a reduce-motion user, or a plain content resize (typing):
|
|
102
|
+
// take the new height immediately.
|
|
103
|
+
if (!ease) {
|
|
104
|
+
height.setValue(next);
|
|
105
|
+
if (first) setSized(true);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const anim = Animated.timing(height, {
|
|
110
|
+
toValue: next,
|
|
111
|
+
duration: scaledMs(CARD_ENTER_MS),
|
|
112
|
+
easing: easeWire,
|
|
113
|
+
// height is layout, not transform — see the header note.
|
|
114
|
+
useNativeDriver: false,
|
|
115
|
+
});
|
|
116
|
+
running.current = anim;
|
|
117
|
+
anim.start();
|
|
118
|
+
},
|
|
119
|
+
[height, reduced],
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
return (
|
|
123
|
+
<Animated.View style={sized ? { height, overflow: "hidden" } : undefined}>
|
|
124
|
+
<View onLayout={onLayout} style={styles.pane}>
|
|
125
|
+
{header}
|
|
126
|
+
<CardHandoff transitionKey={paneKey} direction={direction} layout="auto" variant="slide">
|
|
127
|
+
{children}
|
|
128
|
+
</CardHandoff>
|
|
129
|
+
</View>
|
|
130
|
+
</Animated.View>
|
|
131
|
+
);
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
export const ModalPaneHost = React.memo(_ModalPaneHost);
|
|
135
|
+
ModalPaneHost.displayName = "ModalPaneHost";
|
|
136
|
+
|
|
137
|
+
const styles = StyleSheet.create({
|
|
138
|
+
pane: { width: "100%" },
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
export default ModalPaneHost;
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* StepDots — the "how many steps, and which one am I on" row for a multi-step modal.
|
|
3
|
+
*
|
|
4
|
+
* It replaces the questionnaire's textual counter ("Question 2 of 3"). Malik's device
|
|
5
|
+
* feedback 2026-07-16: "instead of 1 out of 3 or 2-3, had points on top to explain the
|
|
6
|
+
* number of steps". A dot row says the same two things — how long this is, how far in you
|
|
7
|
+
* are — without spending a line of copy next to the question the user should be reading.
|
|
8
|
+
*
|
|
9
|
+
* NOT the same component as `StepProgress`: that one is the ONBOARDING flow's bar, and it
|
|
10
|
+
* deliberately refuses to show a count because the flow's length is model-decided and a
|
|
11
|
+
* number would be a promise the model may not keep. The questionnaire is the opposite
|
|
12
|
+
* case — a fixed 3-field server contract — so here the count is knowable and honest.
|
|
13
|
+
*
|
|
14
|
+
* Motion: a dot fills by crossfading a `colors.primary` layer over a `colors.border` base,
|
|
15
|
+
* at the design system's selection-fill duration (SELECT_FILL_MS, spec rows 2/3/5/6 — the
|
|
16
|
+
* same token every other "this is now selected" fill in the kit uses). Opacity only, on the
|
|
17
|
+
* native driver: no color interpolation (that would force `useNativeDriver: false`, and the
|
|
18
|
+
* kit keeps its one non-native exception in ModalPaneHost's height).
|
|
19
|
+
*
|
|
20
|
+
* Reduce motion: `stateChangeDuration` collapses the fill to 0ms, so the state snaps —
|
|
21
|
+
* the house convention for state changes (see StepProgress).
|
|
22
|
+
*
|
|
23
|
+
* Accessibility: sighted users read the dots; screen readers must not be handed three
|
|
24
|
+
* unlabelled circles. The ROW is one progressbar node carrying "Step 2 of 3" plus the
|
|
25
|
+
* numeric value, and the dots themselves are hidden as decoration.
|
|
26
|
+
*/
|
|
27
|
+
import React, { useEffect, useRef } from "react";
|
|
28
|
+
import { Animated, Easing, StyleSheet, View } from "react-native";
|
|
29
|
+
|
|
30
|
+
import { SELECT_FILL_MS, WIRE_BEZIER, scaledMs, stateChangeDuration } from "../motion/motionSpec";
|
|
31
|
+
import { useReducedMotion } from "../motion/useReducedMotion";
|
|
32
|
+
import type { OnboardingTheme } from "../theme/types";
|
|
33
|
+
|
|
34
|
+
const easeWire = Easing.bezier(...WIRE_BEZIER);
|
|
35
|
+
|
|
36
|
+
export type StepDotsProps = {
|
|
37
|
+
/** The (already merged) active theme — dots read `colors.border` / `colors.primary`. */
|
|
38
|
+
theme: OnboardingTheme;
|
|
39
|
+
/** How many steps there are (how many dots to draw). */
|
|
40
|
+
count: number;
|
|
41
|
+
/** 0-based index of the step on screen. */
|
|
42
|
+
index: number;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
type DotProps = {
|
|
46
|
+
theme: OnboardingTheme;
|
|
47
|
+
/** True for the current step and every step behind it — the row reads as progress. */
|
|
48
|
+
filled: boolean;
|
|
49
|
+
size: number;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const Dot: React.FC<DotProps> = ({ theme, filled, size }) => {
|
|
53
|
+
const reduced = useReducedMotion();
|
|
54
|
+
const fill = useRef(new Animated.Value(filled ? 1 : 0)).current;
|
|
55
|
+
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
const anim = Animated.timing(fill, {
|
|
58
|
+
toValue: filled ? 1 : 0,
|
|
59
|
+
duration: scaledMs(stateChangeDuration(reduced, SELECT_FILL_MS)),
|
|
60
|
+
easing: easeWire,
|
|
61
|
+
useNativeDriver: true,
|
|
62
|
+
});
|
|
63
|
+
anim.start();
|
|
64
|
+
return () => anim.stop();
|
|
65
|
+
}, [fill, filled, reduced]);
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<View
|
|
69
|
+
style={{
|
|
70
|
+
width: size,
|
|
71
|
+
height: size,
|
|
72
|
+
borderRadius: theme.radius.full,
|
|
73
|
+
backgroundColor: theme.colors.border,
|
|
74
|
+
}}
|
|
75
|
+
>
|
|
76
|
+
<Animated.View
|
|
77
|
+
style={[
|
|
78
|
+
StyleSheet.absoluteFill,
|
|
79
|
+
{
|
|
80
|
+
borderRadius: theme.radius.full,
|
|
81
|
+
backgroundColor: theme.colors.primary,
|
|
82
|
+
opacity: fill,
|
|
83
|
+
},
|
|
84
|
+
]}
|
|
85
|
+
/>
|
|
86
|
+
</View>
|
|
87
|
+
);
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const _StepDots: React.FC<StepDotsProps> = ({ theme, count, index }) => (
|
|
91
|
+
<View
|
|
92
|
+
style={[styles.row, { gap: theme.spacing.sm }]}
|
|
93
|
+
accessibilityRole="progressbar"
|
|
94
|
+
accessibilityLabel={`Step ${index + 1} of ${count}`}
|
|
95
|
+
accessibilityValue={{ min: 1, max: count, now: index + 1 }}
|
|
96
|
+
>
|
|
97
|
+
{Array.from({ length: count }, (_, i) => (
|
|
98
|
+
<View key={i} accessibilityElementsHidden importantForAccessibility="no-hide-descendants">
|
|
99
|
+
<Dot theme={theme} filled={i <= index} size={theme.spacing.sm} />
|
|
100
|
+
</View>
|
|
101
|
+
))}
|
|
102
|
+
</View>
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
export const StepDots = React.memo(_StepDots);
|
|
106
|
+
StepDots.displayName = "StepDots";
|
|
107
|
+
|
|
108
|
+
const styles = StyleSheet.create({
|
|
109
|
+
row: { flexDirection: "row", alignItems: "center", justifyContent: "center" },
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
export default StepDots;
|
package/src/device/appVersion.ts
CHANGED
|
@@ -9,10 +9,20 @@
|
|
|
9
9
|
* so the breakdown works out of the box. An explicit `config.appVersion` always WINS over this.
|
|
10
10
|
*
|
|
11
11
|
* WHY it adds NO dependency (the kit's hard rule): `expo-constants` / `expo-application` are read
|
|
12
|
-
* through a GUARDED
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
12
|
+
* through a GUARDED require with a STRING-LITERAL specifier, inside a try/catch. Both halves are
|
|
13
|
+
* required: the literal is what lets Metro COLLECT the dep, and the try/catch is what marks it
|
|
14
|
+
* OPTIONAL, so an absent module becomes a `null` dependencyMap entry whose require throws "Cannot
|
|
15
|
+
* find module" straight into the catch. (`withWireOnboarding` in metro/index.js adds a stub-to-
|
|
16
|
+
* empty-module safety net for hosts that disable Metro's `allowOptionalDependencies`.) Nothing is
|
|
17
|
+
* added to `package.json`; nothing is forced on the host.
|
|
18
|
+
*
|
|
19
|
+
* The specifier MUST stay a literal. A variable specifier (`const req = require; req(name)`) is not
|
|
20
|
+
* "lazier" — it is broken: Metro collects dependencies statically, so a variable collects NOTHING,
|
|
21
|
+
* the module never enters the bundle, and the aliased `require` is Metro's own numeric-id-keyed
|
|
22
|
+
* `metroRequire`, which can never resolve a package-name string. That shape is why this function
|
|
23
|
+
* returned `undefined` on every host despite `expo-constants` being installed, which silently
|
|
24
|
+
* emptied the `by_app_version` breakdown this module exists to fill. See icons/expoIcons.ts for
|
|
25
|
+
* the full autopsy, and reviews/storeReview.ts for the literal-specifier counter-example.
|
|
16
26
|
*
|
|
17
27
|
* PRIVACY: an app version string is not PII and identifies no user or device, so surfacing it
|
|
18
28
|
* changes no App Privacy / Data Safety declaration (same guarantee as the rest of deviceContext).
|
|
@@ -36,13 +46,24 @@ export const coerceVersion = (value: unknown): string | undefined => {
|
|
|
36
46
|
};
|
|
37
47
|
|
|
38
48
|
/**
|
|
39
|
-
* Guarded runtime require.
|
|
40
|
-
*
|
|
49
|
+
* Guarded runtime require.
|
|
50
|
+
*
|
|
51
|
+
* Every specifier below is a STRING LITERAL, because that is the only shape Metro's dependency
|
|
52
|
+
* collector matches (callee literally `require`, argument literally a string). The `moduleName`
|
|
53
|
+
* parameter exists only to keep the `OptionalRequire` seam shape for tests; an unrecognised name
|
|
54
|
+
* resolves to undefined. See the header for why the old variable-specifier shape could not work.
|
|
41
55
|
*/
|
|
42
56
|
const runtimeRequire: OptionalRequire = (moduleName) => {
|
|
57
|
+
if (typeof require !== "function") return undefined;
|
|
43
58
|
try {
|
|
44
|
-
|
|
45
|
-
|
|
59
|
+
switch (moduleName) {
|
|
60
|
+
case "expo-constants":
|
|
61
|
+
return require("expo-constants");
|
|
62
|
+
case "expo-application":
|
|
63
|
+
return require("expo-application");
|
|
64
|
+
default:
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
46
67
|
} catch {
|
|
47
68
|
return undefined;
|
|
48
69
|
}
|
|
@@ -5,10 +5,18 @@
|
|
|
5
5
|
* on Android straight from `Platform.constants.Model` (e.g. "SM-G991B"), but iOS `Platform.constants`
|
|
6
6
|
* exposes NO model — only `osVersion` / `interfaceIdiom`. So the analytics `by_model` breakdown was
|
|
7
7
|
* Android-only. This module fills the iOS gap with the SAME zero-dependency technique the kit already
|
|
8
|
-
* uses for `appVersion` (see device/appVersion.ts): a GUARDED,
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
8
|
+
* uses for `appVersion` (see device/appVersion.ts): a GUARDED, STRING-LITERAL `require` of the common
|
|
9
|
+
* Expo `expo-device` module, inside a try/catch — the literal is what lets Metro COLLECT the dep and
|
|
10
|
+
* the try/catch is what marks it OPTIONAL, so an absent module degrades instead of failing the build.
|
|
11
|
+
* (`withWireOnboarding` in metro/index.js adds a stub-to-empty-module safety net for hosts that
|
|
12
|
+
* disable Metro's `allowOptionalDependencies`.) If the host has it, iOS gets a model out of the box;
|
|
13
|
+
* if not, the read yields `undefined` and iOS `model` stays omitted — nothing is forced, and nothing
|
|
14
|
+
* is added to `package.json`.
|
|
15
|
+
*
|
|
16
|
+
* The specifier MUST stay a literal. A variable specifier (`const req = require; req(name)`) collects
|
|
17
|
+
* NOTHING under Metro's static dependency collector, so the module never enters the bundle and the
|
|
18
|
+
* aliased `require` — really Metro's numeric-id-keyed `metroRequire` — can never resolve a package
|
|
19
|
+
* name. See icons/expoIcons.ts for the full autopsy.
|
|
12
20
|
*
|
|
13
21
|
* WHY it is NOT PII: `expo-device`'s `modelName` ("iPhone 14 Pro") and `modelId` ("iPhone15,2") are a
|
|
14
22
|
* device CLASS shared by millions of units — the same privacy category as the Android `Model` the kit
|
|
@@ -35,13 +43,18 @@ const coerceModel = (value: unknown): string | undefined => {
|
|
|
35
43
|
};
|
|
36
44
|
|
|
37
45
|
/**
|
|
38
|
-
* Guarded runtime require.
|
|
39
|
-
*
|
|
46
|
+
* Guarded runtime require.
|
|
47
|
+
*
|
|
48
|
+
* The specifier is a STRING LITERAL, because that is the only shape Metro's dependency collector
|
|
49
|
+
* matches (callee literally `require`, argument literally a string). The `moduleName` parameter
|
|
50
|
+
* exists only to keep the `OptionalRequire` seam shape for tests; an unrecognised name resolves to
|
|
51
|
+
* undefined. (See the twin guard in ./appVersion.ts.)
|
|
40
52
|
*/
|
|
41
53
|
const runtimeRequire: OptionalRequire = (moduleName) => {
|
|
54
|
+
if (moduleName !== "expo-device") return undefined;
|
|
55
|
+
if (typeof require !== "function") return undefined;
|
|
42
56
|
try {
|
|
43
|
-
|
|
44
|
-
return require(moduleName);
|
|
57
|
+
return require("expo-device");
|
|
45
58
|
} catch {
|
|
46
59
|
return undefined;
|
|
47
60
|
}
|
package/src/features/cache.ts
CHANGED
|
@@ -21,7 +21,7 @@ export const DEFAULT_FEATURES_TTL_MS = 300_000;
|
|
|
21
21
|
/** Ceiling on the storage read so a hung adapter degrades to defaults, never a stuck resolve. */
|
|
22
22
|
const READ_TIMEOUT_MS = 1_500;
|
|
23
23
|
|
|
24
|
-
/** Storage key for a tenant's cached flags, e.g. `wireai:features:
|
|
24
|
+
/** Storage key for a tenant's cached flags, e.g. `wireai:features:acme`. */
|
|
25
25
|
export const featuresCacheKey = (appId?: string): string =>
|
|
26
26
|
`wireai:features:${appId && appId.length > 0 ? appId : "default"}`;
|
|
27
27
|
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ambient type for the OPTIONAL peer `expo-haptics`.
|
|
3
|
+
*
|
|
4
|
+
* The kit never hard-depends on it: it is loaded lazily via a guarded dynamic import
|
|
5
|
+
* and externalized from the build, so the core kit installs nothing. This declaration
|
|
6
|
+
* lets the module typecheck whether or not the host has the package installed. When the
|
|
7
|
+
* host does install it, Metro/the bundler resolves the real module at runtime; when it
|
|
8
|
+
* doesn't, the guarded import rejects and every haptic becomes a silent no-op.
|
|
9
|
+
*
|
|
10
|
+
* Mirrors `src/reviews/expo-store-review.d.ts` exactly — same optional-peer contract.
|
|
11
|
+
*/
|
|
12
|
+
declare module "expo-haptics" {
|
|
13
|
+
/** Impact strengths (a physical "tap" against the UI). */
|
|
14
|
+
export enum ImpactFeedbackStyle {
|
|
15
|
+
Light = "light",
|
|
16
|
+
Medium = "medium",
|
|
17
|
+
Heavy = "heavy",
|
|
18
|
+
}
|
|
19
|
+
/** Notification tones (the outcome of a task). */
|
|
20
|
+
export enum NotificationFeedbackType {
|
|
21
|
+
Success = "success",
|
|
22
|
+
Warning = "warning",
|
|
23
|
+
Error = "error",
|
|
24
|
+
}
|
|
25
|
+
/** The "value changed" tick — used for picking a star. */
|
|
26
|
+
export function selectionAsync(): Promise<void>;
|
|
27
|
+
/** A physical impact tap of the given strength. */
|
|
28
|
+
export function impactAsync(style?: ImpactFeedbackStyle): Promise<void>;
|
|
29
|
+
/** The task-outcome tone — used for the submit payoff. */
|
|
30
|
+
export function notificationAsync(type?: NotificationFeedbackType): Promise<void>;
|
|
31
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* haptics.ts — the physical half of the kit's feedback layer: a tap you feel.
|
|
3
|
+
*
|
|
4
|
+
* `expo-haptics` is an OPTIONAL peer, loaded lazily via a guarded dynamic import so the
|
|
5
|
+
* core kit never depends on it — the exact contract `reviews/storeReview.ts` uses for
|
|
6
|
+
* `expo-store-review`. A host that has not installed it (or a platform without a taptic
|
|
7
|
+
* engine) simply feels nothing: every call resolves to `false` and NOTHING throws. A
|
|
8
|
+
* missed haptic must never break a gate.
|
|
9
|
+
*
|
|
10
|
+
* The module is deliberately fire-and-forget. Callers `void play(...)` from a press
|
|
11
|
+
* handler and never await it, so a slow/absent native module can't delay a tap.
|
|
12
|
+
*
|
|
13
|
+
* Tones map to intent, not to a device API:
|
|
14
|
+
* • "selection" — a value changed (picking a star).
|
|
15
|
+
* • "light" — a light impact (moving to the next question).
|
|
16
|
+
* • "success" — a task completed (the submit payoff, under the done check).
|
|
17
|
+
*
|
|
18
|
+
* NOT gated on reduce-motion: `prefers-reduced-motion` is a VESTIBULAR accommodation about
|
|
19
|
+
* on-screen movement. Haptics are a non-visual channel and are often the accessibility
|
|
20
|
+
* affordance a reduce-motion user relies on, so silencing them here would be the wrong
|
|
21
|
+
* trade. Hosts that want no haptics leave `expo-haptics` uninstalled.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** What a haptic is for, in product terms. Mapped to the native API inside `play`. */
|
|
25
|
+
export type HapticTone = "selection" | "light" | "success";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Play a haptic when the optional peer is available. Resolves `true` when a haptic was
|
|
29
|
+
* actually requested, `false` when the peer is absent or the native call failed. NEVER
|
|
30
|
+
* throws and never rejects — the result is advisory (tests/analytics), not control flow.
|
|
31
|
+
*/
|
|
32
|
+
export const play = async (tone: HapticTone): Promise<boolean> => {
|
|
33
|
+
try {
|
|
34
|
+
const mod = await import("expo-haptics");
|
|
35
|
+
if (!mod) return false;
|
|
36
|
+
if (tone === "selection") {
|
|
37
|
+
await mod.selectionAsync();
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
if (tone === "light") {
|
|
41
|
+
await mod.impactAsync(mod.ImpactFeedbackStyle.Light);
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
await mod.notificationAsync(mod.NotificationFeedbackType.Success);
|
|
45
|
+
return true;
|
|
46
|
+
} catch {
|
|
47
|
+
// Peer not installed, unsupported platform, or the native call failed — stay silent.
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Fire-and-forget `play`. The call site reads as the intent ("a star was tapped"), and a
|
|
54
|
+
* rejected/absent native module can never surface as an unhandled rejection.
|
|
55
|
+
*/
|
|
56
|
+
export const playHaptic = (tone: HapticTone): void => {
|
|
57
|
+
void play(tone);
|
|
58
|
+
};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Icon registry — the host's escape hatch, and the FIRST step of icon resolution.
|
|
3
|
+
*
|
|
4
|
+
* Deliberately the same shape and philosophy as the `illustrations` registry
|
|
5
|
+
* (components/Illustration.tsx): name → ReactNode, held in context, so an app can ship brand
|
|
6
|
+
* SVG / Lottie / Image nodes the kit never imports. Here it means a host can:
|
|
7
|
+
*
|
|
8
|
+
* • override any vocabulary name with its own brand mark (`{ instagram: <MyIgGlyph/> }`), or
|
|
9
|
+
* • supply icons WITHOUT installing `@expo/vector-icons` at all, or
|
|
10
|
+
* • extend the vocabulary with names of its own — the AI emits `"my-thing"`, the host renders
|
|
11
|
+
* it, and the kit needs no release.
|
|
12
|
+
*
|
|
13
|
+
* <WireOnboarding icons={{ instagram: <BrandIg/> }} ... />
|
|
14
|
+
*
|
|
15
|
+
* A name the registry doesn't have falls through to `@expo/vector-icons`, then to no icon at
|
|
16
|
+
* all. See `WireIcon`.
|
|
17
|
+
*/
|
|
18
|
+
import React, { createContext, useContext, useMemo } from "react";
|
|
19
|
+
|
|
20
|
+
export type WireIconRegistry = Record<string, React.ReactNode>;
|
|
21
|
+
|
|
22
|
+
/** One shared empty registry so an unconfigured provider never allocates a fresh {} per render. */
|
|
23
|
+
const EMPTY_REGISTRY: WireIconRegistry = {};
|
|
24
|
+
|
|
25
|
+
const IconContext = createContext<WireIconRegistry>(EMPTY_REGISTRY);
|
|
26
|
+
|
|
27
|
+
export type IconRegistryProviderProps = {
|
|
28
|
+
registry?: WireIconRegistry;
|
|
29
|
+
children: React.ReactNode;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export const IconRegistryProvider: React.FC<IconRegistryProviderProps> = ({
|
|
33
|
+
registry,
|
|
34
|
+
children,
|
|
35
|
+
}) => {
|
|
36
|
+
const value = useMemo(() => registry ?? EMPTY_REGISTRY, [registry]);
|
|
37
|
+
return <IconContext.Provider value={value}>{children}</IconContext.Provider>;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Look up a host-supplied icon node by name. Returns undefined if absent.
|
|
42
|
+
*
|
|
43
|
+
* The OWN-property check matters here for the same reason it does in `vocabulary.ts`: the name
|
|
44
|
+
* is LLM-authored, and a bare `registry[name]` answers `"toString"` with an inherited function
|
|
45
|
+
* — which is truthy, so it would be handed to React as a child and blow up the card. An icon
|
|
46
|
+
* name must never be able to do that.
|
|
47
|
+
*/
|
|
48
|
+
export const useHostIcon = (name?: string): React.ReactNode | undefined => {
|
|
49
|
+
const registry = useContext(IconContext);
|
|
50
|
+
if (!name) return undefined;
|
|
51
|
+
if (!Object.prototype.hasOwnProperty.call(registry, name)) return undefined;
|
|
52
|
+
return registry[name];
|
|
53
|
+
};
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WireIcon — render one semantic icon name, or nothing at all.
|
|
3
|
+
*
|
|
4
|
+
* RESOLUTION ORDER (each step falls through to the next, never throws):
|
|
5
|
+
* 1. the host `icons` registry — brand artwork, zero kit deps (IconRegistry.tsx)
|
|
6
|
+
* 2. `@expo/vector-icons` — if the optional peer resolves (expoIcons.ts)
|
|
7
|
+
* 3. NOTHING — render null
|
|
8
|
+
*
|
|
9
|
+
* Step 3 is a FEATURE, not a failure mode. The AI emits icon names from a vocabulary that will
|
|
10
|
+
* keep growing, against production apps that ship on their own schedule (new store builds only,
|
|
11
|
+
* never OTA, so they cannot be force-updated). So an unknown, misspelled, hallucinated, or
|
|
12
|
+
* simply newer-than-this-client name MUST be a no-op: no crash, no broken-glyph tofu box, and
|
|
13
|
+
* no layout shift. Returning `null` (rather than a spacer or a placeholder) is what gives the
|
|
14
|
+
* last property — a row with an unresolvable icon lays out EXACTLY like a row with no icon,
|
|
15
|
+
* because the parent's flex `gap` adds no space for a null child.
|
|
16
|
+
*
|
|
17
|
+
* An icon is always DECORATIVE: the label carries the meaning, so the icon is hidden from
|
|
18
|
+
* assistive tech and never becomes the accessible name of its option.
|
|
19
|
+
*/
|
|
20
|
+
import React from "react";
|
|
21
|
+
import { StyleSheet, View } from "react-native";
|
|
22
|
+
|
|
23
|
+
import { resolveIconFamily, type OptionalRequire } from "./expoIcons";
|
|
24
|
+
import { useHostIcon } from "./IconRegistry";
|
|
25
|
+
import { lookupIconGlyph } from "./vocabulary";
|
|
26
|
+
|
|
27
|
+
export type WireIconProps = {
|
|
28
|
+
/** A semantic name from the vocabulary (see vocabulary.ts). Unknown → renders nothing. */
|
|
29
|
+
name?: string;
|
|
30
|
+
/** Glyph size in px. */
|
|
31
|
+
size?: number;
|
|
32
|
+
/** Glyph color. Callers pass a THEME token — never a raw hex. */
|
|
33
|
+
color?: string;
|
|
34
|
+
/** Injectable resolver. TEST-ONLY seam; production uses the guarded runtime require. */
|
|
35
|
+
requireModule?: OptionalRequire;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const _WireIcon: React.FC<WireIconProps> = ({ name, size = 20, color, requireModule }) => {
|
|
39
|
+
// 1) Host artwork wins — it can also cover names outside the vocabulary entirely.
|
|
40
|
+
const hostIcon = useHostIcon(name);
|
|
41
|
+
if (hostIcon) {
|
|
42
|
+
return (
|
|
43
|
+
<View
|
|
44
|
+
style={styles.decorative}
|
|
45
|
+
accessible={false}
|
|
46
|
+
importantForAccessibility="no-hide-descendants"
|
|
47
|
+
>
|
|
48
|
+
{hostIcon}
|
|
49
|
+
</View>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// 2) The vocabulary → a concrete glyph, then the optional peer. Both may miss; both degrade.
|
|
54
|
+
const glyph = lookupIconGlyph(name);
|
|
55
|
+
if (!glyph) return null;
|
|
56
|
+
|
|
57
|
+
const Family = resolveIconFamily(glyph.family, requireModule);
|
|
58
|
+
if (!Family) return null;
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<View
|
|
62
|
+
style={styles.decorative}
|
|
63
|
+
accessible={false}
|
|
64
|
+
importantForAccessibility="no-hide-descendants"
|
|
65
|
+
>
|
|
66
|
+
<Family
|
|
67
|
+
name={glyph.name}
|
|
68
|
+
size={size}
|
|
69
|
+
color={color}
|
|
70
|
+
// Icons are sized by the layout, not the text scale — a large accessibility font must
|
|
71
|
+
// not blow up a card grid. The LABEL still scales, which is what carries the meaning.
|
|
72
|
+
allowFontScaling={false}
|
|
73
|
+
/>
|
|
74
|
+
</View>
|
|
75
|
+
);
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
export const WireIcon = React.memo(_WireIcon);
|
|
79
|
+
WireIcon.displayName = "WireIcon";
|
|
80
|
+
|
|
81
|
+
const styles = StyleSheet.create({
|
|
82
|
+
// No size/margin of its own: the glyph decides its footprint, so an absent icon and a
|
|
83
|
+
// present one differ by exactly the glyph — never by a wrapper.
|
|
84
|
+
decorative: {},
|
|
85
|
+
});
|