@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.
Files changed (91) hide show
  1. package/AGENTS.md +29 -5
  2. package/CHANGELOG.md +202 -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-B31G0Cib.d.ts +128 -0
  41. package/dist/transport-Bzb-bcB2.d.mts +128 -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 +119 -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
@@ -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;
@@ -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, VARIABLE-specifier `require`. Passing a variable (not a string literal) keeps
13
- * Metro/esbuild from statically resolving the module, so a host that does NOT have it installed
14
- * (e.g. bare React Native) never fails to bundle the require simply throws at runtime and is
15
- * swallowed. Nothing is added to `package.json`; nothing is forced on the host.
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. `moduleName` is a VARIABLE (a parameter), so bundlers cannot statically
40
- * resolve it — a host without the module never fails to build; the call just throws and is caught.
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
- if (typeof require !== "function") return undefined;
45
- return require(moduleName);
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, VARIABLE-specifier `require` of the
9
- * common Expo `expo-device` module. If the host has it, iOS gets a model out of the box; if not, the
10
- * require simply throws and is swallowed and iOS `model` stays omitted nothing is forced, nothing
11
- * is added to `package.json`, and a host without `expo-device` never fails to bundle.
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. `moduleName` is a VARIABLE (a parameter), so bundlers cannot statically
39
- * resolve it — a host without the module never fails to build; the call just throws and is caught.
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
- if (typeof require !== "function") return undefined;
44
- return require(moduleName);
57
+ return require("expo-device");
45
58
  } catch {
46
59
  return undefined;
47
60
  }
@@ -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:myelino`. */
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
+ });