@wireai/activation 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/AGENTS.md +29 -5
  2. package/CHANGELOG.md +198 -6
  3. package/README.md +105 -10
  4. package/dist/analytics/index.d.mts +12 -8
  5. package/dist/analytics/index.d.ts +12 -8
  6. package/dist/analytics/index.js +525 -55
  7. package/dist/analytics/index.js.map +1 -1
  8. package/dist/analytics/index.mjs +525 -55
  9. package/dist/analytics/index.mjs.map +1 -1
  10. package/dist/coachmarks/index.d.mts +2 -2
  11. package/dist/coachmarks/index.d.ts +2 -2
  12. package/dist/coachmarks/index.js.map +1 -1
  13. package/dist/coachmarks/index.mjs.map +1 -1
  14. package/dist/{currentSession-CxnP7gAa.d.ts → currentSession-61dcm3V-.d.ts} +19 -3
  15. package/dist/{currentSession-BJBB7i4-.d.mts → currentSession-Bs2JfTJ8.d.mts} +19 -3
  16. package/dist/{decision-BzbiKwk3.d.mts → decision-Bl_M2y3r.d.mts} +1 -1
  17. package/dist/{decision-plDEOCkt.d.ts → decision-Cau5KmP6.d.ts} +1 -1
  18. package/dist/index.d.mts +620 -13
  19. package/dist/index.d.ts +620 -13
  20. package/dist/index.js +1194 -369
  21. package/dist/index.js.map +1 -1
  22. package/dist/index.mjs +1015 -201
  23. package/dist/index.mjs.map +1 -1
  24. package/dist/questionnaire/index.d.mts +69 -23
  25. package/dist/questionnaire/index.d.ts +69 -23
  26. package/dist/questionnaire/index.js +643 -172
  27. package/dist/questionnaire/index.js.map +1 -1
  28. package/dist/questionnaire/index.mjs +608 -137
  29. package/dist/questionnaire/index.mjs.map +1 -1
  30. package/dist/reviews/index.d.mts +24 -12
  31. package/dist/reviews/index.d.ts +24 -12
  32. package/dist/reviews/index.js +507 -173
  33. package/dist/reviews/index.js.map +1 -1
  34. package/dist/reviews/index.mjs +462 -129
  35. package/dist/reviews/index.mjs.map +1 -1
  36. package/dist/showcase/index.d.mts +2 -2
  37. package/dist/showcase/index.d.ts +2 -2
  38. package/dist/showcase/index.js.map +1 -1
  39. package/dist/showcase/index.mjs.map +1 -1
  40. package/dist/transport-DzU-TqZz.d.ts +86 -0
  41. package/dist/transport-f5VVB5hH.d.mts +86 -0
  42. package/dist/{types-GL_hQ0TN.d.mts → types-BcmagF6K.d.mts} +1 -1
  43. package/dist/{types-GL_hQ0TN.d.ts → types-BcmagF6K.d.ts} +1 -1
  44. package/dist/{types-BhpXJGlg.d.ts → types-Buj9Lw9t.d.ts} +15 -3
  45. package/dist/{types-CMuOexw0.d.mts → types-CKFhyrMu.d.mts} +1 -1
  46. package/dist/{types-CMuOexw0.d.ts → types-CKFhyrMu.d.ts} +1 -1
  47. package/dist/{types-A6pTxIZV.d.mts → types-CNUqMK0D.d.mts} +15 -3
  48. package/metro/index.d.ts +18 -1
  49. package/metro/index.js +80 -0
  50. package/package.json +9 -1
  51. package/src/WireOnboarding.tsx +4 -0
  52. package/src/analytics/analyticsFacade.ts +55 -10
  53. package/src/analytics/currentSession.ts +1 -1
  54. package/src/cards/CardGridSelectCard.tsx +253 -0
  55. package/src/cards/ChipSelectCard.tsx +1 -1
  56. package/src/cards/SelectionCard.tsx +15 -19
  57. package/src/cards/index.ts +13 -1
  58. package/src/cards/optionSchema.ts +65 -0
  59. package/src/coachmarks/types.ts +1 -1
  60. package/src/components/CardHandoff.tsx +39 -7
  61. package/src/components/DoneCheck.tsx +94 -0
  62. package/src/components/ModalPaneHost.tsx +141 -0
  63. package/src/components/StepDots.tsx +112 -0
  64. package/src/context/deviceId.ts +43 -0
  65. package/src/device/appVersion.ts +29 -8
  66. package/src/device/deviceContext.ts +14 -1
  67. package/src/device/deviceModel.ts +106 -0
  68. package/src/features/cache.ts +1 -1
  69. package/src/haptics/expo-haptics.d.ts +31 -0
  70. package/src/haptics/haptics.ts +58 -0
  71. package/src/icons/IconRegistry.tsx +53 -0
  72. package/src/icons/WireIcon.tsx +85 -0
  73. package/src/icons/expoIcons.ts +165 -0
  74. package/src/icons/index.ts +18 -0
  75. package/src/icons/vocabulary.ts +154 -0
  76. package/src/index.ts +22 -0
  77. package/src/questionnaire/QuestionnaireGate.tsx +315 -164
  78. package/src/questionnaire/decision.ts +4 -2
  79. package/src/questionnaire/index.ts +3 -2
  80. package/src/questionnaire/steps.ts +261 -0
  81. package/src/questionnaire/types.ts +33 -12
  82. package/src/reviews/ReviewGate.tsx +250 -152
  83. package/src/reviews/index.ts +7 -3
  84. package/src/reviews/transport.ts +77 -3
  85. package/src/reviews/types.ts +13 -0
  86. package/src/session/persistedSession.ts +1 -1
  87. package/src/session-analytics/lifecycle.ts +1 -1
  88. package/src/session-analytics/useSessionStart.ts +2 -2
  89. package/src/showcase/FeatureShowcase.tsx +1 -1
  90. package/src/showcase/showcaseColors.ts +1 -1
  91. package/src/theme/mergeThemeOver.ts +27 -0
  92. package/src/types.ts +8 -0
  93. package/dist/transport-BeO_Brcu.d.mts +0 -40
  94. package/dist/transport-DLpd1v5_.d.ts +0 -40
@@ -0,0 +1,43 @@
1
+ /**
2
+ * deviceId — mint a stable, NON-PII, per-install device id the kit owns when the host supplies
3
+ * none. This is the headline of "device fully automatic": the analytics façade auto-mints ONE id,
4
+ * persists it via the host's `storage` abstraction, and reuses it on every subsequent open — so
5
+ * `user_context.device_key` is ALWAYS present and the server's review/questionnaire gating +
6
+ * A/B stickiness (both key on `device_key`) work out of the box, with zero host wiring.
7
+ *
8
+ * WHY it is NOT PII and adds NO dependency (the kit's hard rules):
9
+ * The id is a random token generated from `Date.now()` + `Math.random()` — it carries NO hardware
10
+ * identifier, NO IDFA/GAID, NO fingerprint. It is a first-party per-install correlation key, the
11
+ * same privacy category as a first-party cookie: it groups a single install's sessions and cannot
12
+ * identify a person or be joined across apps. There is NO `uuid` (or any) dependency — a
13
+ * time+random scheme is sufficient because the id is minted ONCE and then persisted, so global
14
+ * uniqueness across the fleet is not required (a per-install collision is astronomically unlikely
15
+ * and inconsequential — worst case two installs share a bucket).
16
+ *
17
+ * A host that wants its OWN device id still wins: pass `WireUserContext.deviceKey` and the kit uses
18
+ * that verbatim and never mints/persists an auto id.
19
+ */
20
+
21
+ /** Prefix so an auto-minted id is visibly the kit's (distinguishable from a host-supplied `deviceKey`). */
22
+ export const AUTO_DEVICE_ID_PREFIX = "wdev_";
23
+
24
+ /** The storage key the façade persists the auto-minted id under (namespaced per `appId`). */
25
+ export const deviceIdStorageKey = (appId?: string): string =>
26
+ `wireai:analytics:deviceKey:${appId ?? "default"}`;
27
+
28
+ /** One 32-bit base-36 chunk of randomness. Two chunks are concatenated for a wider token. */
29
+ const randomChunk = (): string =>
30
+ Math.floor(Math.random() * 0x100000000)
31
+ .toString(36)
32
+ .padStart(6, "0");
33
+
34
+ /**
35
+ * Mint a fresh per-install device id. Dependency-free (`Date.now()` + `Math.random()`), never
36
+ * throws, and returns a NEW value on every call — the façade mints ONCE and persists, so this is
37
+ * called at most once per install (then the persisted value is reused). Two random chunks plus the
38
+ * timestamp keep the token wide enough that a per-install collision is not a practical concern.
39
+ */
40
+ export const mintDeviceId = (): string => {
41
+ const time = Date.now().toString(36);
42
+ return `${AUTO_DEVICE_ID_PREFIX}${time}_${randomChunk()}${randomChunk()}`;
43
+ };
@@ -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
  }
@@ -21,6 +21,7 @@
21
21
  import { Dimensions, I18nManager, Platform } from "react-native";
22
22
 
23
23
  import { detectAppVersion } from "./appVersion";
24
+ import { detectNativeModel } from "./deviceModel";
24
25
 
25
26
  /** Coarse device class. iOS uses the reported interface idiom; else a screen-size heuristic. */
26
27
  export type DeviceFormFactor = "phone" | "tablet";
@@ -36,7 +37,15 @@ export type DeviceContext = {
36
37
  osVersion?: string;
37
38
  /** Android device brand (e.g. "samsung"). Android only. */
38
39
  brand?: string;
39
- /** Android device model (e.g. "SM-G991B"). Android only. */
40
+ /**
41
+ * Device model. On Android it is read directly from `Platform.constants.Model` (e.g. "SM-G991B").
42
+ * iOS `Platform.constants` exposes NO model, so on iOS it is a BEST-EFFORT read of `expo-device`'s
43
+ * `modelName` ("iPhone 14 Pro"), falling back to `modelId` ("iPhone15,2") — dependency-free via a
44
+ * guarded require (see device/deviceModel.ts). ASYMMETRY: without `expo-device` installed, iOS
45
+ * `model` is omitted (there is no dependency-free iOS model in the already-used RN surface, and the
46
+ * kit will not add a native dep for it); Android needs no extra module. Not PII (a device class,
47
+ * not a unique id), so it changes no privacy-label declaration.
48
+ */
40
49
  model?: string;
41
50
  /** iOS interface idiom ("phone" | "pad" | …), when reported. iOS only. */
42
51
  interfaceIdiom?: string;
@@ -125,6 +134,10 @@ export const collectDeviceContext = (): DeviceContext => {
125
134
  ctx.interfaceIdiom = idiom;
126
135
  iosIdiom = idiom;
127
136
  }
137
+ // iOS has no model in `Platform.constants`; best-effort via `expo-device` (adds no dep,
138
+ // omitted when the module is absent — see device/deviceModel.ts).
139
+ const iosModel = detectNativeModel();
140
+ if (iosModel) ctx.model = iosModel;
128
141
  }
129
142
  } catch {
130
143
  // ignore per-OS constant reads
@@ -0,0 +1,106 @@
1
+ /**
2
+ * deviceModel — best-effort, DEPENDENCY-FREE detection of the device MODEL on iOS.
3
+ *
4
+ * WHY this exists (the asymmetry it closes): `collectDeviceContext` already reports `device.model`
5
+ * on Android straight from `Platform.constants.Model` (e.g. "SM-G991B"), but iOS `Platform.constants`
6
+ * exposes NO model — only `osVersion` / `interfaceIdiom`. So the analytics `by_model` breakdown was
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, 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.
20
+ *
21
+ * WHY it is NOT PII: `expo-device`'s `modelName` ("iPhone 14 Pro") and `modelId` ("iPhone15,2") are a
22
+ * device CLASS shared by millions of units — the same privacy category as the Android `Model` the kit
23
+ * already sends. It is NOT a unique device id / IDFA / fingerprint, so surfacing it changes no App
24
+ * Privacy / Data Safety declaration (identical guarantee to the rest of deviceContext). It deliberately
25
+ * does NOT read `expo-device`'s `deviceName` (that is the user-set name, e.g. "Malik's iPhone", and IS
26
+ * personal data).
27
+ *
28
+ * NEVER THROWS: every read is guarded; a missing/odd value yields `undefined`, never an exception.
29
+ */
30
+
31
+ // Metro injects a module-scoped `require`; it is ABSENT in a pure-ESM runtime. Declared locally so
32
+ // this type-checks without ambient Node types; the `typeof` guard keeps the reference ESM-safe.
33
+ declare const require: ((id: string) => unknown) | undefined;
34
+
35
+ /** A `require`-like resolver. Injectable in tests; production uses the guarded runtime require. */
36
+ export type OptionalRequire = (moduleName: string) => unknown;
37
+
38
+ /** Trim + reject non-strings/empties so we only ever emit a real model string. */
39
+ const coerceModel = (value: unknown): string | undefined => {
40
+ if (typeof value !== "string") return undefined;
41
+ const trimmed = value.trim();
42
+ return trimmed.length > 0 ? trimmed : undefined;
43
+ };
44
+
45
+ /**
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.)
52
+ */
53
+ const runtimeRequire: OptionalRequire = (moduleName) => {
54
+ if (moduleName !== "expo-device") return undefined;
55
+ if (typeof require !== "function") return undefined;
56
+ try {
57
+ return require("expo-device");
58
+ } catch {
59
+ return undefined;
60
+ }
61
+ };
62
+
63
+ /** Read a module's `default` (Expo modules are often consumed as default exports) or the namespace. */
64
+ const interop = (mod: unknown): Record<string, unknown> | undefined => {
65
+ if (!mod || typeof mod !== "object") return undefined;
66
+ const def = (mod as { default?: unknown }).default;
67
+ if (def && typeof def === "object") return def as Record<string, unknown>;
68
+ return mod as Record<string, unknown>;
69
+ };
70
+
71
+ /** Resolve a module namespace, swallowing a throwing require (an uninstalled module throws). */
72
+ const safeInterop = (
73
+ requireModule: OptionalRequire,
74
+ moduleName: string,
75
+ ): Record<string, unknown> | undefined => {
76
+ try {
77
+ return interop(requireModule(moduleName));
78
+ } catch {
79
+ return undefined;
80
+ }
81
+ };
82
+
83
+ /**
84
+ * Detect the device model via `expo-device`, preferring the human-readable `modelName`
85
+ * ("iPhone 14 Pro") and falling back to the identifier `modelId` ("iPhone15,2"). Returns the first
86
+ * real string, or `undefined` when `expo-device` is absent. Pure and never throws.
87
+ *
88
+ * `requireModule` is injectable so tests can exercise the "found" path without the native module;
89
+ * production defaults to the guarded runtime require above.
90
+ */
91
+ export const detectNativeModel = (
92
+ requireModule: OptionalRequire = runtimeRequire,
93
+ ): string | undefined => {
94
+ try {
95
+ const device = safeInterop(requireModule, "expo-device");
96
+ if (device) {
97
+ const modelName = coerceModel(device.modelName);
98
+ if (modelName) return modelName;
99
+ const modelId = coerceModel(device.modelId);
100
+ if (modelId) return modelId;
101
+ }
102
+ } catch {
103
+ // Any unexpected read error → undefined; analytics must never crash onboarding.
104
+ }
105
+ return undefined;
106
+ };
@@ -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
+ });
@@ -0,0 +1,165 @@
1
+ /**
2
+ * expoIcons — resolve an `@expo/vector-icons` family, lazily and optionally.
3
+ *
4
+ * ── WHY THIS DOES NOT BREAK THE NO-NATIVE-DEPENDENCY POLICY ──────────────────────────────
5
+ *
6
+ * The policy (see `questionnaire/QuestionnaireGate.tsx`'s back-chevron note, `AnimatedSparkle`,
7
+ * `coachmarks/GestureHint`, `cards/StatusCard`'s Unicode STATUS_ICONS) says: no vector library
8
+ * is worth FORCING EVERY HOST INTO A NATIVE REBUILD. Read it precisely — what it forbids is
9
+ * forcing hosts, not the existence of an icon.
10
+ *
11
+ * `@expo/vector-icons` is an OPTIONAL peer:
12
+ *
13
+ * • A host WITHOUT it installs the kit fine (optional peer → npm never fetches it) and renders
14
+ * fine (icon → null, layout unchanged). To BUNDLE fine it must let Metro know the module is
15
+ * allowed to be missing — see "OPTIONALITY IS A BUILD-TIME CONTRACT" below.
16
+ * • A host WITH it gets real brand icons. Both live host apps already ship it
17
+ * (`@expo/vector-icons ^15.0.3`), so neither needs a rebuild — which matters because
18
+ * neither can be force-updated: both ship new store builds only and never OTA.
19
+ * • A host that wants neither can override every name with its own artwork through the
20
+ * `icons` registry, so the library is never on the critical path at all.
21
+ *
22
+ * ── THE SPECIFIER MUST BE A STRING LITERAL — THIS IS THE WHOLE BUG ───────────────────────
23
+ *
24
+ * DO NOT "restore" the old `const req = require; req(moduleName)` shape. It could never work,
25
+ * and it shipped broken in 0.8.0. What that shape actually did, proven in a real `expo export`
26
+ * bundle and in Expo SDK 55's runtime:
27
+ *
28
+ * 1. Metro collects dependencies STATICALLY, by matching a call whose callee is literally the
29
+ * identifier `require` and whose argument is a STRING LITERAL. Aliasing `require` to a local
30
+ * hid the call from the collector, so this module's dependencyMap was emitted EMPTY (`[]`)
31
+ * and `@expo/vector-icons` was never put in the bundle at all.
32
+ * 2. At runtime the alias did NOT point at Node's `require`. Metro renames the free `require`
33
+ * identifier to the module factory's require param, so `req` WAS `metroRequire`, whose
34
+ * module registry is keyed by NUMERIC module ids. Handing it the string "@expo/vector-icons"
35
+ * could never match a key, so it threw `Requiring unknown module "@expo/vector-icons"` —
36
+ * even in a bundle that contained the module.
37
+ * 3. That throw never reached the `catch` below. Metro's own `guardedLoadModule` wraps the
38
+ * load, catches the error FIRST, and routes it to `global.ErrorUtils.reportFatalError(e)`
39
+ * instead of rethrowing (Expo SDK 55: `@expo/cli/build/metro-require/require.js:151-165`).
40
+ * So the call returned `undefined` — icon silently null — AND logged a red fatal error. A
41
+ * try/catch cannot guard a require that Metro never lets throw.
42
+ *
43
+ * A LITERAL `require("@expo/vector-icons")` is collected, is bundled, and resolves SYNCHRONOUSLY
44
+ * through the numeric dependencyMap — the same reason `reviews/storeReview.ts`'s literal
45
+ * `await import("expo-store-review")` has always worked. (The old comment here claimed the alias
46
+ * worked "exactly like expo-store-review". It was the opposite: storeReview's specifier is a
47
+ * literal, and that is precisely why it resolves and this one did not.)
48
+ *
49
+ * ── OPTIONALITY IS A BUILD-TIME CONTRACT, AND THE TRY/CATCH IS LOAD-BEARING ──────────────
50
+ *
51
+ * Metro has NO runtime resolution: every specifier is resolved when the bundle is built. It does
52
+ * have a real optional-dependency mechanism, and it needs BOTH halves of what is written below:
53
+ * a LITERAL specifier (so the dep is collected at all) AND the call inside a TRY/CATCH — that is
54
+ * literally how Metro marks a dependency `isOptional`. When the peer is absent, Metro then puts
55
+ * `null` in this module's dependencyMap and `metroRequire(null, "@expo/vector-icons")` throws
56
+ * "Cannot find module" from its own `moduleId === null` branch, BEFORE `guardedLoadModule` — so
57
+ * unlike the string-id path in the autopsy above, that throw really does land in the catch below.
58
+ * So do NOT "simplify" the try/catch away: it is what keeps the peer optional, not just tidy.
59
+ *
60
+ * That mechanism rides on the transformer's `allowOptionalDependencies`, which Expo enables by
61
+ * default but bare `metro-config` defaults to FALSE. For hosts in the second camp, the kit ships
62
+ * a safety net: `withWireOnboarding` (metro/index.js) installs a `resolveRequest` that maps an
63
+ * absent optional peer to an EMPTY module, landing on exactly the degradation path below (no
64
+ * families on the namespace → `undefined` → icon renders null).
65
+ */
66
+ import type { WireIconFamily } from "./vocabulary";
67
+
68
+ // Metro injects a module-scoped `require`; it is ABSENT in a pure-ESM runtime (the kit's own
69
+ // tests run under `node --test` as ESM). Declared locally so this type-checks without ambient
70
+ // Node types; the `typeof` guard keeps the reference ESM-safe.
71
+ declare const require: ((id: string) => unknown) | undefined;
72
+
73
+ /** A `require`-like resolver. Injectable in tests; production uses the guarded literal require. */
74
+ export type OptionalRequire = (moduleName: string) => unknown;
75
+
76
+ /**
77
+ * The minimal shape the kit uses from a vector-icons family. A COMPONENT TYPE, not a plain
78
+ * function: these are real React components (createIconSet output) and must be mounted via
79
+ * JSX/createElement, never invoked directly — calling one bypasses the reconciler and breaks
80
+ * the moment the family uses hooks or is a class.
81
+ */
82
+ export type IconComponent = import("react").ComponentType<{
83
+ name: string;
84
+ size?: number;
85
+ color?: string;
86
+ allowFontScaling?: boolean;
87
+ }>;
88
+
89
+ /**
90
+ * The production resolver. The specifier is a LITERAL so Metro collects it (see the header); the
91
+ * `moduleName` parameter exists only to keep the `OptionalRequire` seam shape, so anything other
92
+ * than the one module this file owns resolves to undefined.
93
+ */
94
+ const runtimeRequire: OptionalRequire = (moduleName) => {
95
+ if (moduleName !== "@expo/vector-icons") return undefined;
96
+ if (typeof require !== "function") return undefined;
97
+ try {
98
+ return require("@expo/vector-icons");
99
+ } catch {
100
+ return undefined;
101
+ }
102
+ };
103
+
104
+ /** Read a module's `default` (Expo modules are consumed as default exports) or the namespace. */
105
+ const interop = (mod: unknown): Record<string, unknown> | undefined => {
106
+ if (!mod || typeof mod !== "object") return undefined;
107
+ const def = (mod as { default?: unknown }).default;
108
+ // `@expo/vector-icons` exposes the families as NAMED exports; prefer the namespace when it
109
+ // already carries them, and only fall back to `default` for a CJS-interop wrapper.
110
+ const ns = mod as Record<string, unknown>;
111
+ if (ns.MaterialCommunityIcons || ns.Ionicons) return ns;
112
+ if (def && typeof def === "object") return def as Record<string, unknown>;
113
+ return ns;
114
+ };
115
+
116
+ /**
117
+ * Narrow an unknown export to something mountable. A vector-icons family is either a function
118
+ * component or a class (`createIconSet` has returned both across versions), and React.memo /
119
+ * forwardRef wrappers are objects — so accept an object carrying a `$$typeof` too, and reject
120
+ * anything else rather than handing the reconciler a non-component.
121
+ */
122
+ const asComponent = (value: unknown): IconComponent | undefined => {
123
+ if (typeof value === "function") return value as IconComponent;
124
+ if (value && typeof value === "object" && "$$typeof" in (value as object)) {
125
+ return value as IconComponent;
126
+ }
127
+ return undefined;
128
+ };
129
+
130
+ /**
131
+ * Module-level memo. `null` = "we looked and it is not there" (distinct from "not looked yet"),
132
+ * so an absent peer costs exactly one failed require per process, not one per rendered icon.
133
+ */
134
+ let cached: Record<string, unknown> | null | undefined;
135
+
136
+ /** Reset the memo. TEST-ONLY seam — production never calls it. */
137
+ export const resetIconModuleCache = (): void => {
138
+ cached = undefined;
139
+ };
140
+
141
+ /**
142
+ * Resolve one icon family component, or undefined when the peer is absent/unresolvable.
143
+ * Never throws: an icon is decorative, so it must never be able to break a card.
144
+ *
145
+ * `requireModule` is injectable so tests can exercise BOTH the found and absent paths without
146
+ * installing the native peer (same convention as `detectAppVersion`).
147
+ */
148
+ export const resolveIconFamily = (
149
+ family: WireIconFamily,
150
+ requireModule: OptionalRequire = runtimeRequire,
151
+ ): IconComponent | undefined => {
152
+ try {
153
+ if (cached === undefined || requireModule !== runtimeRequire) {
154
+ const resolved = interop(requireModule("@expo/vector-icons"));
155
+ // Don't poison the module memo from an injected test require.
156
+ if (requireModule === runtimeRequire) cached = resolved ?? null;
157
+ if (!resolved) return undefined;
158
+ return asComponent(resolved[family]);
159
+ }
160
+ if (cached === null) return undefined;
161
+ return asComponent(cached[family]);
162
+ } catch {
163
+ return undefined;
164
+ }
165
+ };