@wireai/activation 0.14.2 → 0.15.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 (67) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +144 -0
  3. package/README.md +23 -9
  4. package/dist/analytics/index.d.mts +5 -5
  5. package/dist/analytics/index.d.ts +5 -5
  6. package/dist/analytics/index.js.map +1 -1
  7. package/dist/analytics/index.mjs.map +1 -1
  8. package/dist/coachmarks/index.d.mts +13 -5
  9. package/dist/coachmarks/index.d.ts +13 -5
  10. package/dist/coachmarks/index.js +252 -58
  11. package/dist/coachmarks/index.js.map +1 -1
  12. package/dist/coachmarks/index.mjs +204 -9
  13. package/dist/coachmarks/index.mjs.map +1 -1
  14. package/dist/{currentSession-CW_5Mq4O.d.ts → currentSession-CFSRZ2wg.d.ts} +10 -4
  15. package/dist/{currentSession-CUvTOchb.d.mts → currentSession-orZy5p1e.d.mts} +10 -4
  16. package/dist/{decision-Bgo17oH7.d.mts → decision-3vWLuBlO.d.ts} +11 -2
  17. package/dist/{decision-Bkh_LigV.d.ts → decision-yBj2AyPW.d.mts} +11 -2
  18. package/dist/index.d.mts +2 -2
  19. package/dist/index.d.ts +2 -2
  20. package/dist/index.js.map +1 -1
  21. package/dist/index.mjs.map +1 -1
  22. package/dist/questionnaire/index.d.mts +3 -3
  23. package/dist/questionnaire/index.d.ts +3 -3
  24. package/dist/questionnaire/index.js +48 -3
  25. package/dist/questionnaire/index.js.map +1 -1
  26. package/dist/questionnaire/index.mjs +48 -3
  27. package/dist/questionnaire/index.mjs.map +1 -1
  28. package/dist/reviews/index.d.mts +10 -6
  29. package/dist/reviews/index.d.ts +10 -6
  30. package/dist/reviews/index.js +167 -18
  31. package/dist/reviews/index.js.map +1 -1
  32. package/dist/reviews/index.mjs +167 -18
  33. package/dist/reviews/index.mjs.map +1 -1
  34. package/dist/showcase/index.d.mts +1 -1
  35. package/dist/showcase/index.d.ts +1 -1
  36. package/dist/showcase/index.js +282 -48
  37. package/dist/showcase/index.js.map +1 -1
  38. package/dist/showcase/index.mjs +248 -12
  39. package/dist/showcase/index.mjs.map +1 -1
  40. package/dist/{transport-j5gFfJhK.d.mts → transport-s5QxA-ci.d.mts} +18 -13
  41. package/dist/{transport-B_0SgCBe.d.ts → transport-xqqSFqxs.d.ts} +18 -13
  42. package/dist/{types-Cju-1_jT.d.mts → types-Byx306Kv.d.mts} +25 -10
  43. package/dist/{types-BcmagF6K.d.mts → types-D_0B0yay.d.mts} +7 -2
  44. package/dist/{types-BcmagF6K.d.ts → types-D_0B0yay.d.ts} +7 -2
  45. package/dist/{types-h2BZvl1t.d.ts → types-tdATL5z0.d.ts} +25 -10
  46. package/metro/index.js +16 -5
  47. package/package.json +1 -1
  48. package/src/WireOnboarding.tsx +4 -1
  49. package/src/coachmarks/GestureHint.tsx +16 -7
  50. package/src/coachmarks/SpotlightOverlay.tsx +19 -13
  51. package/src/coachmarks/index.ts +7 -2
  52. package/src/coachmarks/reanimated.ts +342 -0
  53. package/src/coachmarks/runtime.ts +103 -2
  54. package/src/coachmarks/types.ts +7 -2
  55. package/src/context/deviceId.ts +32 -0
  56. package/src/reviews/ReviewGate.tsx +86 -37
  57. package/src/reviews/decision.ts +11 -1
  58. package/src/reviews/idempotency.ts +71 -0
  59. package/src/reviews/index.ts +4 -2
  60. package/src/reviews/runtime.ts +5 -3
  61. package/src/reviews/transport.ts +17 -12
  62. package/src/reviews/types.ts +7 -0
  63. package/src/showcase/FeatureShowcase.tsx +25 -9
  64. package/src/showcase/blazejOnboarding.ts +136 -0
  65. package/src/showcase/index.ts +8 -4
  66. package/src/types.ts +10 -4
  67. package/src/utils/submitResult.ts +17 -9
@@ -17,7 +17,11 @@
17
17
  *
18
18
  * The form NEVER asks for identity (no name / email / contact field): the host already
19
19
  * identifies the user via `sessionId` + `meta` (e.g. user_id, device_key), which is all
20
- * the POST body carries alongside the rating and the free text.
20
+ * the POST body carries alongside the rating and the free text. Since 2026-08-19 the gate also
21
+ * fills in `meta.device_key` from the kit's own per-install id when the host passed none — not for
22
+ * identification, but because it is the unit the server scopes the idempotency key to, and without
23
+ * it the key is discarded. Any non-blank string the host passes still wins verbatim. See
24
+ * `idempotency.ts`.
21
25
  *
22
26
  * Motion (2026-07-16): the three phases used to swap IN PLACE with no transition. They now
23
27
  * ride `ModalPaneHost`, the same pane-slide primitive the questionnaire steps use, and the
@@ -45,6 +49,7 @@ import { useOnboardingTheme } from "../theme/ThemeContext";
45
49
  import { bodyStyle, captionStyle, headingStyle } from "../theme/typography";
46
50
  import { buildReviewSubmission, routeRating } from "./decision";
47
51
  import { feedbackInputHeights } from "./feedbackForm";
52
+ import { createImpression, hasDeviceKey, type ReviewImpression } from "./idempotency";
48
53
  import { resolvePresentation } from "./presentation";
49
54
  import { ReviewModal, type ReviewModalHandle } from "./ReviewModal";
50
55
  import { requestStoreReview } from "./storeReview";
@@ -107,18 +112,63 @@ const _ReviewGate: React.FC<ReviewGateProps> = ({
107
112
  onResolved?.();
108
113
  }, [onResolved]);
109
114
 
115
+ /**
116
+ * ONE identity + ONE idempotency key for this whole gate impression, opened lazily at the first
117
+ * post so a prompt the user never acts on mints nothing.
118
+ *
119
+ * Both values live for the IMPRESSION, not the render and not the post: the server derives the
120
+ * row id from `(app_id, unit, key)`, so a key that moves between two posts writes two rows and a
121
+ * unit that moves does the same. `idempotency.ts` carries the full reasoning.
122
+ */
123
+ const impressionRef = React.useRef<ReviewImpression | null>(null);
124
+
125
+ /**
126
+ * Build a submission for THIS impression: the host's `meta` plus whatever the call site adds,
127
+ * stamped with the impression's key and — only when the host supplied none — its device key.
128
+ *
129
+ * A host-supplied `meta.device_key` ALWAYS wins and is never overwritten — "supplied" meaning a
130
+ * string with at least one non-whitespace character, the bar {@link hasDeviceKey} sets; a blank
131
+ * or non-string value is not a usable unit and is replaced. This mirrors the `userContext` /
132
+ * `autoJoinKey` rule one directory over, which sets the same bar through `resolveIdentity`. The
133
+ * injection exists because without a resolvable unit the server discards the key outright, and a
134
+ * home-feed gate has no `session_id` to fall back to.
135
+ */
136
+ const buildBody = useCallback(
137
+ (input: {
138
+ stars: number;
139
+ feedbackText?: string;
140
+ suggestion?: string;
141
+ extraMeta?: Record<string, unknown>;
142
+ }): ReviewSubmission => {
143
+ let impression = impressionRef.current;
144
+ if (!impression) {
145
+ impression = createImpression();
146
+ impressionRef.current = impression;
147
+ }
148
+ const merged: Record<string, unknown> = { ...meta, ...input.extraMeta };
149
+ if (!hasDeviceKey(merged) && impression.deviceKey) merged.device_key = impression.deviceKey;
150
+ return buildReviewSubmission({
151
+ stars: input.stars,
152
+ feedbackText: input.feedbackText,
153
+ suggestion: input.suggestion,
154
+ sessionId,
155
+ meta: merged,
156
+ idempotencyKey: impression.idempotencyKey,
157
+ });
158
+ },
159
+ [meta, sessionId],
160
+ );
161
+
110
162
  /**
111
163
  * EXACTLY ONE review row per gate, ever — the latch that makes "force feedback" safe.
112
164
  *
113
- * WHY ONE POST AND NOT TWO: the tempting design is to post the rating at star-tap and then
114
- * post again with the text, enriching the first row. That needs the server to accept a
115
- * client-supplied id so the second write UPSERTS. It does not, as deployed today:
116
- * `create_review` mints its own id unconditionally (`app/reviews.py` `_new_id()`) and
117
- * `CreateReviewRequest` (`app/schemas.py`) has no id field so two posts = two rows, which
118
- * double-counts `count` and corrupts `avg`. Reading the ack does not rescue that guess either:
119
- * a second post against a server with no upsert comes back 2xx HAVING created the second row, so
120
- * the failure is invisible to the caller and permanent. Post once, at the last responsible
121
- * moment, and never depend on an unmerged/undeployed server capability.
165
+ * WHY ONE POST AND NOT TWO: the tempting design is to post the rating at star-tap and then post
166
+ * again with the text, enriching the first row. The server now supports exactly that a post
167
+ * carrying `idempotency_key` upserts on a derived row id instead of inserting but the latch
168
+ * stays, because two posts buy nothing the one-at-the-last-responsible-moment post does not
169
+ * already have, and every additional post is another chance to hand the server a unit it cannot
170
+ * resolve. What the key genuinely buys is the RECOVERY paths below: the `unsent` re-post and the
171
+ * abandonment net now merge into the row they may have already written instead of duplicating it.
122
172
  *
123
173
  * ── THE LATCH IS A CLAIM, NOT A RECEIPT ────────────────────────────────────────────────────
124
174
  *
@@ -134,17 +184,23 @@ const _ReviewGate: React.FC<ReviewGateProps> = ({
134
184
  *
135
185
  * "Did it land" and "did the server answer" are DIFFERENT questions, which is why `submitReview`
136
186
  * resolves `accepted` / `rejected` / `unsent` instead of a boolean. Only `unsent` un-latches.
137
- * A non-2xx means the server was REACHED, and with no idempotency key on the wire (see above:
138
- * the id is server-minted) we cannot know whether it stored the row before failing to say so — a
139
- * gateway 502 after the insert commits looks identical to a refusal. Re-posting on that verdict
140
- * writes the second row this whole latch exists to prevent, and does it on the exact code path
141
- * meant to protect the data. So on any answer the latch stays closed and `unackedRef` is
142
- * cleared: the row is the server's problem now.
187
+ * A non-2xx means the server was REACHED, and we still cannot know whether it stored the row
188
+ * before failing to say so a gateway 502 after the insert commits looks identical to a refusal.
189
+ * That verdict is left alone DELIBERATELY (2026-08-19): re-posting on it is now safe wherever the
190
+ * key is honoured, but it would also turn a genuine 4xx refusal into a retry needing its own
191
+ * budget, so changing it is its own decision and not this one. On any answer the latch stays
192
+ * closed and `unackedRef` is cleared: the row is the server's problem now.
143
193
  *
144
- * HONEST RESIDUAL: `unsent` is not proof the server never got it either. A connection dropped
145
- * after the row committed throws here exactly like an offline device, so the one re-post below
146
- * can still duplicate. This is SAFER, not safe. The real fix is a client-minted idempotency key
147
- * the server upserts on a server change, out of scope for the kit.
194
+ * WHAT THE KEY CHANGED, STATED AS A CAPABILITY: `unsent` was never proof the server got nothing
195
+ * a connection dropped after the row committed throws here exactly like an offline device, so the
196
+ * one re-post below could duplicate. It now carries this impression's `idempotency_key` and the
197
+ * unit the server scopes it to, so the server upserts that row rather than adding a second one.
198
+ * That holds while the unit is resolvable, and a host CANNOT make it unresolvable by editing
199
+ * `meta`: `buildBody` tests the MERGED bucket, so stripping `device_key` there only gets the auto
200
+ * id re-injected. The one post that carries no unit is the one where the kit cannot name an id
201
+ * honestly — two tenants registered in one process leave `impression.deviceKey` undefined, and
202
+ * nothing is stamped. On a gate with no `session_id` that post reaches the server with no unit to
203
+ * scope the key to, so the server ignores the key and it is back in the old, duplicating world.
148
204
  *
149
205
  * NOTHING HERE IS AWAITED. `postOnce` returns synchronously and every caller advances the phase
150
206
  * on the next line, so the user is never waiting on the network to dismiss the prompt.
@@ -169,8 +225,9 @@ const _ReviewGate: React.FC<ReviewGateProps> = ({
169
225
  postedRef.current = false;
170
226
  return;
171
227
  }
172
- // `accepted` OR `rejected`: the server answered, so it owns this row. Never re-post it
173
- // a non-2xx can follow a committed insert, and the wire has no idempotency key.
228
+ // `accepted` OR `rejected`: the server answered, so it owns this row. Not re-posted here
229
+ // a non-2xx can follow a committed insert, and re-posting a refusal is a retry decision
230
+ // with its own budget, deliberately left out of the change that added the key.
174
231
  unackedRef.current = null;
175
232
  });
176
233
  },
@@ -202,12 +259,11 @@ const _ReviewGate: React.FC<ReviewGateProps> = ({
202
259
  }
203
260
  if (postedRef.current || stars < 1) return;
204
261
  postOnce(
205
- buildReviewSubmission({
262
+ buildBody({
206
263
  stars,
207
264
  feedbackText: feedback,
208
265
  suggestion,
209
- sessionId,
210
- meta: { ...meta, abandoned: true },
266
+ extraMeta: { abandoned: true },
211
267
  }),
212
268
  );
213
269
  };
@@ -249,10 +305,9 @@ const _ReviewGate: React.FC<ReviewGateProps> = ({
249
305
  // the user actually left a review — this only ever means we ASKED. Never read it as
250
306
  // a review count. We await first so the row carries the route it actually took.
251
307
  postOnce(
252
- buildReviewSubmission({
308
+ buildBody({
253
309
  stars: value,
254
- sessionId,
255
- meta: { ...meta, store_route: route },
310
+ extraMeta: { store_route: route },
256
311
  }),
257
312
  );
258
313
  setPhase("thanks");
@@ -263,7 +318,7 @@ const _ReviewGate: React.FC<ReviewGateProps> = ({
263
318
  setPhase("feedback");
264
319
  }
265
320
  },
266
- [onEvent, id, store, postOnce, sessionId, meta, resolveWithDelay],
321
+ [onEvent, id, store, postOnce, buildBody, resolveWithDelay],
267
322
  );
268
323
 
269
324
  /**
@@ -284,19 +339,13 @@ const _ReviewGate: React.FC<ReviewGateProps> = ({
284
339
  if (!feedbackReady) return;
285
340
  // Identity is NEVER collected here — the host already sends user_id / device /
286
341
  // session via `sessionId` + `meta`. We POST only the rating + the free text.
287
- const body = buildReviewSubmission({
288
- stars,
289
- feedbackText: feedback,
290
- suggestion,
291
- sessionId,
292
- meta,
293
- });
342
+ const body = buildBody({ stars, feedbackText: feedback, suggestion });
294
343
  postOnce(body);
295
344
  onEvent?.({ name: "review_feedback_submitted", id, stars });
296
345
  playHaptic("success");
297
346
  setPhase("thanks");
298
347
  resolveWithDelay();
299
- }, [feedbackReady, stars, feedback, suggestion, sessionId, meta, postOnce, onEvent, id, resolveWithDelay]);
348
+ }, [feedbackReady, stars, feedback, suggestion, buildBody, postOnce, onEvent, id, resolveWithDelay]);
300
349
 
301
350
  const dismiss = useCallback(() => {
302
351
  setPhase("thanks");
@@ -103,7 +103,14 @@ export const evaluateGate = (rules: GateRules, s: GateSignals): ReviewDecision =
103
103
  export const routeRating = (stars: number): RatingRoute =>
104
104
  stars >= 5 ? "store" : "feedback";
105
105
 
106
- /** Build the `POST /v1/reviews` body. Feedback text/contact belong ONLY here, never in events. */
106
+ /**
107
+ * Build the `POST /v1/reviews` body. Feedback text/contact belong ONLY here, never in events.
108
+ *
109
+ * STILL PURE, deliberately: `idempotencyKey` and the `meta.device_key` the server scopes it to are
110
+ * both passed IN rather than resolved here. Reaching into the device-key registry from this function
111
+ * would make the gate logic untestable in isolation, and both values are scoped to an impression
112
+ * this function cannot see — see `reviews/idempotency.ts`.
113
+ */
107
114
  export const buildReviewSubmission = (input: {
108
115
  stars: number;
109
116
  feedbackText?: string;
@@ -111,6 +118,8 @@ export const buildReviewSubmission = (input: {
111
118
  contact?: string;
112
119
  sessionId?: string;
113
120
  source?: string;
121
+ /** The impression's idempotency key. Omitted → the server mints a row id and inserts, as before. */
122
+ idempotencyKey?: string;
114
123
  meta?: Record<string, unknown>;
115
124
  }): ReviewSubmission => {
116
125
  const body: ReviewSubmission = { stars: input.stars };
@@ -122,6 +131,7 @@ export const buildReviewSubmission = (input: {
122
131
  if (contact) body.contact = contact;
123
132
  if (input.sessionId) body.session_id = input.sessionId;
124
133
  if (input.source) body.source = input.source;
134
+ if (input.idempotencyKey) body.idempotency_key = input.idempotencyKey;
125
135
  if (input.meta && Object.keys(input.meta).length > 0) body.meta = input.meta;
126
136
  return body;
127
137
  };
@@ -0,0 +1,71 @@
1
+ /**
2
+ * idempotency.ts — the ONE idempotency key and the ONE identity a single gate impression posts under.
3
+ *
4
+ * ── WHAT THE SERVER DOES WITH THESE, AND WHY BOTH ARE REQUIRED ───────────────────────────────
5
+ *
6
+ * `POST /v1/reviews` accepts an optional `idempotency_key`: two posts carrying the same key upsert
7
+ * ONE row instead of creating two, which is what stops a re-post double-counting `count` and
8
+ * corrupting `avg`. The row id is derived, never the key verbatim —
9
+ * `rev_ + sha256(app_id : unit : key)[:24]` — where `app_id` comes from the resolved `wai_` Bearer
10
+ * key (never the body) and `unit` is the USER the key is scoped to.
11
+ *
12
+ * THE PART THAT MAKES A KEY-ONLY CHANGE INERT: `unit` is `meta.device_key`, else `session_id`, and
13
+ * when the post carries NEITHER the server drops the key on the floor and inserts a normal row —
14
+ * deliberately, because an unbounded key could merge two strangers' reviews. The gate lives on the
15
+ * home feed, where a user legitimately has no onboarding session, so `session_id` is frequently
16
+ * absent; and nothing in the kit ever put a `device_key` into a review `meta` — that was documented
17
+ * as a host responsibility. Sending a key without guaranteeing the identity would therefore have
18
+ * shipped a field the server discards.
19
+ *
20
+ * ── THE SCOPE IS THE WHOLE DESIGN ────────────────────────────────────────────────────────────
21
+ *
22
+ * One key per gate IMPRESSION, held across every post of that impression:
23
+ * • minted per POST instead → the re-post carries a different key, writes the second row, and
24
+ * defeats the feature on the exact path it exists to protect.
25
+ * • minted once per INSTALL instead → a later, genuine second review merges into the first and
26
+ * destroys a rating.
27
+ * The identity is pinned for the same reason and in the same object: the auto device key is adopted
28
+ * from persistence ASYNCHRONOUSLY, so a value re-read between two posts of one impression can move
29
+ * under us, and two posts under two different units hash to two different rows.
30
+ */
31
+ import { ambientAutoDeviceKey } from "../context/deviceId";
32
+
33
+ /** Prefix so a kit-minted review key is visibly ours in a stored `meta` dump. */
34
+ export const REVIEW_IDEMPOTENCY_PREFIX = "rvw_";
35
+
36
+ /**
37
+ * Mint a fresh idempotency key. Dependency-free timestamp + randomness, the same scheme
38
+ * `makeSessionId` and `mintDeviceId` already use — the key only has to be unique among the
39
+ * impressions of ONE install, because the server hashes it together with `app_id` and the unit
40
+ * before it ever becomes a row id.
41
+ */
42
+ export const mintIdempotencyKey = (): string =>
43
+ `${REVIEW_IDEMPOTENCY_PREFIX}${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
44
+
45
+ /** Whether a `meta` bucket already carries a usable, host-supplied `device_key`. */
46
+ export const hasDeviceKey = (meta: Record<string, unknown> | undefined): boolean => {
47
+ const value = meta?.device_key;
48
+ return typeof value === "string" && value.trim().length > 0;
49
+ };
50
+
51
+ /** The key + identity every post of ONE gate impression carries. Both resolved once, at creation. */
52
+ export interface ReviewImpression {
53
+ /** The `idempotency_key` on the wire. Stable for the impression, distinct across impressions. */
54
+ readonly idempotencyKey: string;
55
+ /**
56
+ * The auto `device_key` to stamp when the host supplied none, or `undefined` when the kit cannot
57
+ * name one honestly (two tenants in one process). A host-supplied key that {@link hasDeviceKey}
58
+ * accepts — a string with at least one non-whitespace character — always wins over this and is
59
+ * never touched; the caller checks that first.
60
+ */
61
+ readonly deviceKey: string | undefined;
62
+ }
63
+
64
+ /**
65
+ * Open one impression. Both fields are resolved EAGERLY here rather than read per post, which is the
66
+ * entire point: a lazily re-read key or identity is a per-post value wearing an impression's name.
67
+ */
68
+ export const createImpression = (): ReviewImpression => ({
69
+ idempotencyKey: mintIdempotencyKey(),
70
+ deviceKey: ambientAutoDeviceKey(),
71
+ });
@@ -55,8 +55,10 @@ export { requestStoreReview, openStoreListing, storeUrl } from "./storeReview";
55
55
  // hand-rolling a decision fetch: a host-rolled one that collapses `{fire:false}` into undefined
56
56
  // makes the server able to turn prompts ON but never OFF (the 2026-07-16 first-session incident).
57
57
  // `submitReview` resolves with a THREE-state `ReviewSubmitResult` (accepted / rejected / unsent),
58
- // not a boolean: only `unsent` may be retried, because the wire has no idempotency key and a
59
- // re-post after a non-2xx that already committed writes a second row.
58
+ // not a boolean: only `unsent` is retried today. A body built by `ReviewGate` now carries an
59
+ // `idempotency_key` plus the unit the server scopes it to, so that re-post upserts one row instead
60
+ // of writing a second; whether a `rejected` verdict should ALSO retry is a separate recovery-budget
61
+ // decision and is deliberately unchanged.
60
62
  export { submitReview, fetchReviewDecision, reportAppEvent } from "./transport";
61
63
  export type {
62
64
  FetchReviewDecisionOptions,
@@ -5,7 +5,7 @@
5
5
  * mounted CoachmarkProvider gets review gating for free — no second storage to wire.
6
6
  */
7
7
  import type { CoachmarkStorage } from "../coachmarks/types";
8
- import { getCoachmarkStorage } from "../coachmarks/runtime";
8
+ import { getCoachmarkStorage, validateGateStorage } from "../coachmarks/runtime";
9
9
  import { getCurrentSessionId } from "../analytics/currentSession";
10
10
  import { makeSessionId } from "../analytics/reportClientEvent";
11
11
 
@@ -22,10 +22,12 @@ export const reviewSessionsKey = (id: string): string => `wire_review_${id}_sess
22
22
  /** Companion key holding the open id the counter was LAST incremented for (see {@link bumpSessionCount}). */
23
23
  export const reviewSessionOpenKey = (id: string): string => `wire_review_${id}_open`;
24
24
 
25
- /** Resolve the storage to use: an explicit override, else the provider-injected singleton. */
25
+ /** Resolve the storage to use: an explicit override, else the provider-injected singleton — both
26
+ * routed through the thenable probe, so an ASYNC adapter resolves to the fail-closed substitute
27
+ * (see `validateGateStorage` in coachmarks/runtime) instead of feeding Promises to `readInt`. */
26
28
  export const resolveStorage = (
27
29
  override?: CoachmarkStorage | null,
28
- ): CoachmarkStorage | null => override ?? getCoachmarkStorage();
30
+ ): CoachmarkStorage | null => validateGateStorage(override ?? getCoachmarkStorage());
29
31
 
30
32
  /** RN sets this global; absent under node/SSR. Read defensively via {@link warnMissingGateStorage}. */
31
33
  declare const __DEV__: boolean | undefined;
@@ -45,23 +45,28 @@ export type ReviewSubmitResult = SubmitResult;
45
45
  *
46
46
  * It was, for exactly one unpublished release, and the boolean was the bug. `false` meant both
47
47
  * "nothing reached the server" and "the server answered non-2xx", and the one caller that reads
48
- * this (`ReviewGate.postOnce`) treats `false` as "still owed" and re-posts. But the server mints
49
- * its own row id (`create_review` / `_new_id()`) and `CreateReviewRequest` carries no id, so there
50
- * is NO idempotency key on the wire: a 502 returned AFTER the insert commits means the re-post
51
- * writes a SECOND row, double-counting `count` and corrupting `avg` the precise corruption the
52
- * one-row latch exists to prevent. A response of any status proves the server was reached, and
53
- * that is a different question from whether it liked the row. So the two are different values.
48
+ * this (`ReviewGate.postOnce`) treats `false` as "still owed" and re-posts. At the time there was
49
+ * no idempotency key on the wire at all, so a 502 returned AFTER the insert commits meant the
50
+ * re-post wrote a SECOND row, double-counting `count` and corrupting `avg` the precise corruption
51
+ * the one-row latch exists to prevent. A response of any status proves the server was reached, and
52
+ * that is a different question from whether it liked the row. So the two are different values, and
53
+ * they stay different: the split is what lets the caller decide, and the retry-on-`rejected`
54
+ * question is a recovery-budget decision the key does not answer on its own.
54
55
  *
55
56
  * ── THE RESIDUAL, STATED HONESTLY ────────────────────────────────────────────────────────────
56
57
  *
57
58
  * `unsent` is not proof the server never got the row. A connection dropped after the request was
58
59
  * written — or after the row committed — surfaces as a thrown/rejected `fetch` here, exactly like
59
- * an offline device. Retrying only on `unsent` is therefore SAFER, not SAFE: it removes the
60
- * double-post the server itself told us about, and leaves the narrow window where the answer never
61
- * made it back onto the wire. Closing that window needs a CLIENT-MINTED IDEMPOTENCY KEY the server
62
- * upserts on, which is a server change (`CreateReviewRequest` + `create_review`) and is not
63
- * something the kit can fake. Until it exists, prefer losing a row over inventing one: a lost
64
- * detractor is a gap in the data, a duplicated one is a lie in the data.
60
+ * an offline device. That used to make the `unsent` re-post SAFER rather than SAFE, and it is what
61
+ * the client-minted idempotency key now covers: `ReviewSubmission.idempotency_key` reaches
62
+ * `CreateReviewRequest`, and the server upserts the derived row rather than inserting a second one.
63
+ *
64
+ * IT IS A CAPABILITY, NOT A GUARANTEE, and the condition is not ours to enforce from in here. The
65
+ * server honours the key only when the post also carries a unit it can scope it to
66
+ * `meta.device_key`, else `session_id` — and ignores it outright otherwise. `ReviewGate` fills the
67
+ * device key in for exactly this reason, but this function posts whatever body it is handed, so a
68
+ * caller that assembles its own body without a unit is back in the old world where a lost detractor
69
+ * is a gap in the data and a duplicated one is a lie in the data.
65
70
  */
66
71
  export const submitReview = async (
67
72
  target: ReviewTarget | undefined,
@@ -97,6 +97,13 @@ export interface ReviewSubmission {
97
97
  contact?: string;
98
98
  session_id?: string;
99
99
  source?: string;
100
+ /**
101
+ * OPTIONAL client-minted key for the two-phase submit: two posts carrying the same key upsert ONE
102
+ * row instead of creating two. The server honours it ONLY when the post also carries a resolvable
103
+ * user — `meta.device_key`, else `session_id` — and silently ignores it otherwise, so it always
104
+ * travels with one of those. One key per gate impression, stable across that impression's posts.
105
+ */
106
+ idempotency_key?: string;
100
107
  meta?: Record<string, unknown>;
101
108
  }
102
109
 
@@ -1,8 +1,8 @@
1
- import Onboarding, {
2
- type OnboardingColors,
3
- type OnboardingFonts,
4
- type OnboardingProps,
5
- type OnboardingStep,
1
+ import type {
2
+ OnboardingColors,
3
+ OnboardingFonts,
4
+ OnboardingProps,
5
+ OnboardingStep,
6
6
  } from "@blazejkustra/react-native-onboarding";
7
7
  import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
8
8
  import { type ImageSourcePropType, StyleSheet, View } from "react-native";
@@ -12,6 +12,7 @@ import { hasSeenGate, markSeenGate, showcaseGateKey } from "../coachmarks/runtim
12
12
  import { useResolvedFeatures } from "../features/WireFeaturesProvider";
13
13
  import { useOnboardingTheme } from "../theme/ThemeContext";
14
14
  import type { OnboardingTheme } from "../theme/types";
15
+ import { resolveShowcaseOnboarding } from "./blazejOnboarding";
15
16
  import { showcaseColorsFromTheme, showcasePanelBackground } from "./showcaseColors";
16
17
  import type { FeatureShowcaseProps } from "./types";
17
18
 
@@ -47,6 +48,13 @@ const mergeThemeOver = (
47
48
  *
48
49
  * When the gate says "seen", it renders nothing and calls `onDone` from an
49
50
  * effect (never during render).
51
+ *
52
+ * The underlying package is an OPTIONAL peer, resolved through the guarded lazy
53
+ * require in `blazejOnboarding.ts` rather than a static import, so the showcase
54
+ * subpath builds on a host that never installed it. Absent, this takes the SAME
55
+ * path as the kill switch — render null, call `onDone`, write no gate — so the
56
+ * host's flow always advances and the showcase still plays once if the peer is
57
+ * added later.
50
58
  */
51
59
  const _FeatureShowcase: React.FC<FeatureShowcaseProps> = ({
52
60
  config,
@@ -71,14 +79,20 @@ const _FeatureShowcase: React.FC<FeatureShowcaseProps> = ({
71
79
  const flags = useResolvedFeatures({ flags: features, config: featuresConfig });
72
80
  const disabled = !flags.showcase.enabled;
73
81
 
82
+ // The optional pager peer. Resolved once, on first render, instead of at module scope — so
83
+ // merely importing this file (and thus the showcase subpath) never needs it. Absent → treated
84
+ // exactly like the kill switch below: no render, no gate write, `onDone` from the effect.
85
+ const Onboarding = useMemo(() => resolveShowcaseOnboarding(), []);
86
+
74
87
  const gateKey = showcaseGateKey(config.id);
75
88
  const seen = useMemo(
76
89
  () => hasSeenGate(gateKey, storage, isTesting),
77
90
  [gateKey, storage, isTesting],
78
91
  );
79
- // Either already-seen OR feature-disabled short-circuits the showcase. Only the seen path is a
80
- // gate write (in `finish`); the disabled path never persists, so it replays when re-enabled.
81
- const skip = seen || disabled;
92
+ // Already-seen, feature-disabled OR the peer missing short-circuits the showcase. Only the seen
93
+ // path is a gate write (in `finish`); the other two never persist, so the showcase replays once
94
+ // the feature is re-enabled or the peer is installed.
95
+ const skip = seen || disabled || !Onboarding;
82
96
 
83
97
  const doneRef = useRef(false);
84
98
  const finish = useCallback(() => {
@@ -177,7 +191,9 @@ const _FeatureShowcase: React.FC<FeatureShowcaseProps> = ({
177
191
  [t],
178
192
  );
179
193
 
180
- if (skip) return null;
194
+ // `!Onboarding` is already folded into `skip`; it is repeated here so the narrowing is explicit
195
+ // to the reader and to the compiler at the JSX below.
196
+ if (skip || !Onboarding) return null;
181
197
 
182
198
  const activeGesture = config.slides[activeIndex]?.gesture;
183
199
  // Same rule as the coachmark path: tap / double-tap slides show no glyph.
@@ -0,0 +1,136 @@
1
+ /**
2
+ * blazejOnboarding — resolve `@blazejkustra/react-native-onboarding`, lazily and optionally.
3
+ *
4
+ * ── WHY THIS DOES NOT BREAK THE NO-NATIVE-DEPENDENCY POLICY ──────────────────────────────
5
+ *
6
+ * `@blazejkustra/react-native-onboarding` is an OPTIONAL peer of the showcase subpath — it is the
7
+ * slide pager `FeatureShowcase` wraps. Before this file, `FeatureShowcase.tsx` imported its default
8
+ * export at module scope, and `showcase/index.ts` re-exports the component with no wildcard escape,
9
+ * so importing ANYTHING from `@wireai/activation/showcase` (even `selectShowcaseSlides`) dragged
10
+ * the peer in, and a host that skipped the "optional" peer hit a Metro resolution failure. This
11
+ * resolver removes that static edge. Same class and same shape as the 0.14.2 `expo-blur` fix
12
+ * (`coachmarks/expoBlur.ts`), whose header carries the reasoning in full.
13
+ *
14
+ * The TYPES stay statically imported on purpose: a type-only import is erased by every toolchain
15
+ * (babel, esbuild and sucrase all drop it), so it creates no runtime edge and no bundle dependency,
16
+ * and `showcaseColors.ts` already relies on exactly that.
17
+ *
18
+ * ── THE SPECIFIER MUST BE A STRING LITERAL, INSIDE A TRY/CATCH ───────────────────────────
19
+ *
20
+ * Metro collects dependencies statically and matches only a call whose callee is literally the
21
+ * identifier `require` and whose argument is literally a string; the try/catch around it is what
22
+ * marks the dependency `isOptional`. Both halves are load-bearing — see `icons/expoIcons.ts` for
23
+ * the full autopsy of the 0.8.0 shape that aliased the callee and was collected nowhere.
24
+ *
25
+ * ── THE DEGRADE ─────────────────────────────────────────────────────────────────────────
26
+ *
27
+ * Absent → `FeatureShowcase` renders null and calls `onDone` from an effect, which is the SAME path
28
+ * the feature kill switch already takes. The host's flow always advances (a showcase that cannot
29
+ * render must never strand the user on a blank screen), and the seen-gate is deliberately NOT
30
+ * written, so installing the peer later still plays the showcase once.
31
+ */
32
+ import type { ComponentType } from "react";
33
+
34
+ import type { OnboardingProps } from "@blazejkustra/react-native-onboarding";
35
+
36
+ // Metro injects a module-scoped `require`; it is ABSENT in a pure-ESM runtime (the kit's own tests
37
+ // run under `node --test` as ESM). Declared locally so this type-checks without ambient Node types;
38
+ // the `typeof` guard keeps the reference ESM-safe.
39
+ declare const require: ((id: string) => unknown) | undefined;
40
+
41
+ /** A `require`-like resolver. Injectable in tests; production uses the guarded literal require. */
42
+ export type OptionalRequire = (moduleName: string) => unknown;
43
+
44
+ /** The pager component `FeatureShowcase` wraps. */
45
+ export type OnboardingComponent = ComponentType<OnboardingProps>;
46
+
47
+ const MODULE_NAME = "@blazejkustra/react-native-onboarding";
48
+
49
+ /**
50
+ * The production resolver. The specifier is a LITERAL so Metro collects it (see the header); the
51
+ * `moduleName` parameter exists only to keep the `OptionalRequire` seam shape, so anything other
52
+ * than the one module this file owns resolves to undefined.
53
+ */
54
+ const runtimeRequire: OptionalRequire = (moduleName) => {
55
+ if (moduleName !== MODULE_NAME) return undefined;
56
+ if (typeof require !== "function") return undefined;
57
+ try {
58
+ return require("@blazejkustra/react-native-onboarding");
59
+ } catch {
60
+ return undefined;
61
+ }
62
+ };
63
+
64
+ /**
65
+ * TEST-ONLY seam. `FeatureShowcase`'s public props are frozen, so it cannot take a `requireModule`
66
+ * the way `WireIcon` does — this lets a component render exercise the PRESENT path without the peer
67
+ * installed. Production never sets it; `runtimeRequire` is the only resolver.
68
+ */
69
+ let testRequire: OptionalRequire | undefined;
70
+ export const __setShowcaseOnboardingRequireForTests = (fn: OptionalRequire | undefined): void => {
71
+ testRequire = fn;
72
+ cached = undefined;
73
+ };
74
+
75
+ /**
76
+ * Read the component off the module. The package ships it as a DEFAULT export; a CJS build may put
77
+ * the component directly on `module.exports` instead, so both shapes are accepted.
78
+ */
79
+ const interop = (mod: unknown): unknown => {
80
+ if (typeof mod === "function") return mod;
81
+ if (!mod || typeof mod !== "object") return undefined;
82
+ const ns = mod as Record<string, unknown>;
83
+ if (ns.default) return ns.default;
84
+ // `module.exports = forwardRef(...)`: the component IS the namespace, and it is an object rather
85
+ // than a function, so it would slip past the check above.
86
+ if ("$$typeof" in ns) return ns;
87
+ return undefined;
88
+ };
89
+
90
+ /**
91
+ * Narrow an unknown export to something mountable: a real component (function or class) or a
92
+ * React.memo / forwardRef wrapper (an object carrying `$$typeof`). Reject anything else rather than
93
+ * handing the reconciler a non-component.
94
+ */
95
+ const asComponent = (value: unknown): OnboardingComponent | undefined => {
96
+ if (typeof value === "function") return value as OnboardingComponent;
97
+ if (value && typeof value === "object" && "$$typeof" in (value as object)) {
98
+ return value as OnboardingComponent;
99
+ }
100
+ return undefined;
101
+ };
102
+
103
+ /**
104
+ * Module-level memo. `null` = "we looked and it is not there" (distinct from "not looked yet"), so
105
+ * an absent peer costs exactly one failed require per process, not one per showcase mount.
106
+ */
107
+ let cached: OnboardingComponent | null | undefined;
108
+
109
+ /** Reset the memo. TEST-ONLY seam — production never calls it. */
110
+ export const resetShowcaseOnboardingCache = (): void => {
111
+ cached = undefined;
112
+ };
113
+
114
+ /**
115
+ * Resolve the pager component, or undefined when the peer is absent/unresolvable. Never throws: an
116
+ * absent showcase must advance the host's flow, never break it.
117
+ *
118
+ * `requireModule` is injectable so tests can exercise BOTH the found and absent paths without
119
+ * installing the peer (same convention as `resolveBlurView` / `resolveIconFamily`).
120
+ */
121
+ export const resolveShowcaseOnboarding = (
122
+ requireModule: OptionalRequire = testRequire ?? runtimeRequire,
123
+ ): OnboardingComponent | undefined => {
124
+ try {
125
+ if (cached === undefined || requireModule !== runtimeRequire) {
126
+ const component = asComponent(interop(requireModule(MODULE_NAME)));
127
+ // Don't poison the module memo from an injected test require.
128
+ if (requireModule === runtimeRequire) cached = component ?? null;
129
+ return component;
130
+ }
131
+ if (cached === null) return undefined;
132
+ return cached;
133
+ } catch {
134
+ return undefined;
135
+ }
136
+ };
@@ -2,10 +2,14 @@
2
2
  * @wireai/activation/showcase — the pre-onboarding feature showcase ("app intro").
3
3
  *
4
4
  * Subpath entry, kept OUT of the main barrel so the core kit stays dependency-
5
- * free: importing this pulls in the optional peer
6
- * `@blazejkustra/react-native-onboarding` (and, transitively via GestureHint,
7
- * `react-native-reanimated`). The app supplies a declarative ShowcaseConfig; the
8
- * kit bakes in the Wire theme + optional per-slide gesture hand and gates once.
5
+ * free. Importing this pulls in NO native peer (0.14.3): the pager
6
+ * `@blazejkustra/react-native-onboarding` is reached through the guarded lazy
7
+ * require in `blazejOnboarding.ts`, and the transitive edge through GestureHint
8
+ * to `react-native-reanimated` is guarded the same way in `coachmarks/reanimated.ts`.
9
+ * Without the pager the showcase renders null and calls `onDone` so the host's
10
+ * flow advances; without reanimated the gesture glyph renders static. The app
11
+ * supplies a declarative ShowcaseConfig; the kit bakes in the Wire theme +
12
+ * optional per-slide gesture hand and gates once.
9
13
  *
10
14
  * import { FeatureShowcase, selectShowcaseSlides } from "@wireai/activation/showcase";
11
15
  */