@cxpinsight/react-native 0.17.8 → 0.18.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 (58) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/CXPinsightReactNative.podspec +24 -0
  3. package/README.md +10 -5
  4. package/android/build.gradle +33 -0
  5. package/android/src/main/AndroidManifest.xml +1 -0
  6. package/android/src/main/java/com/cxpinsight/reactnative/CXPScreenshotModule.java +134 -0
  7. package/android/src/main/java/com/cxpinsight/reactnative/CXPinsightPackage.java +25 -0
  8. package/ios/CXPScreenshot.m +99 -0
  9. package/lib/CXPinsight.d.ts +18 -0
  10. package/lib/CXPinsight.d.ts.map +1 -1
  11. package/lib/CXPinsight.js +124 -10
  12. package/lib/CXPinsight.js.map +1 -1
  13. package/lib/CXPinsightProvider.d.ts.map +1 -1
  14. package/lib/CXPinsightProvider.js +215 -10
  15. package/lib/CXPinsightProvider.js.map +1 -1
  16. package/lib/core/mobileRecorder.d.ts +19 -0
  17. package/lib/core/mobileRecorder.d.ts.map +1 -1
  18. package/lib/core/mobileRecorder.js +51 -3
  19. package/lib/core/mobileRecorder.js.map +1 -1
  20. package/lib/core/nativeScreenshot.d.ts +9 -0
  21. package/lib/core/nativeScreenshot.d.ts.map +1 -0
  22. package/lib/core/nativeScreenshot.js +76 -0
  23. package/lib/core/nativeScreenshot.js.map +1 -0
  24. package/lib/core/networkCapture.d.ts +1 -0
  25. package/lib/core/networkCapture.d.ts.map +1 -1
  26. package/lib/core/networkCapture.js +18 -1
  27. package/lib/core/networkCapture.js.map +1 -1
  28. package/lib/core/triggerEngine.d.ts +17 -0
  29. package/lib/core/triggerEngine.d.ts.map +1 -1
  30. package/lib/core/triggerEngine.js +17 -3
  31. package/lib/core/triggerEngine.js.map +1 -1
  32. package/lib/core/viewShot.d.ts +10 -0
  33. package/lib/core/viewShot.d.ts.map +1 -0
  34. package/lib/core/viewShot.js +56 -0
  35. package/lib/core/viewShot.js.map +1 -0
  36. package/lib/instrumented/interactionObserver.d.ts.map +1 -1
  37. package/lib/instrumented/interactionObserver.js +32 -9
  38. package/lib/instrumented/interactionObserver.js.map +1 -1
  39. package/lib/survey/SurveyModal.d.ts +72 -1
  40. package/lib/survey/SurveyModal.d.ts.map +1 -1
  41. package/lib/survey/SurveyModal.js +1162 -392
  42. package/lib/survey/SurveyModal.js.map +1 -1
  43. package/lib/survey/fadeGradient.d.ts +35 -0
  44. package/lib/survey/fadeGradient.d.ts.map +1 -0
  45. package/lib/survey/fadeGradient.js +68 -0
  46. package/lib/survey/fadeGradient.js.map +1 -0
  47. package/lib/survey/styleContract.d.ts +49 -1
  48. package/lib/survey/styleContract.d.ts.map +1 -1
  49. package/lib/survey/styleContract.js +76 -17
  50. package/lib/survey/styleContract.js.map +1 -1
  51. package/lib/types.d.ts +11 -2
  52. package/lib/types.d.ts.map +1 -1
  53. package/lib/types.js.map +1 -1
  54. package/lib/version.d.ts +9 -0
  55. package/lib/version.d.ts.map +1 -0
  56. package/lib/version.js +12 -0
  57. package/lib/version.js.map +1 -0
  58. package/package.json +13 -5
@@ -33,12 +33,18 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.SWIPE_DISMISS_VELOCITY = exports.SWIPE_DISMISS_PX = exports.SWIPE_CLAIM_PX = void 0;
36
37
  exports.stylePlatformSlice = stylePlatformSlice;
37
38
  exports.topInsetForHeight = topInsetForHeight;
38
39
  exports.resolvePopupTop = resolvePopupTop;
40
+ exports.shouldClaimSwipe = shouldClaimSwipe;
41
+ exports.swipeOffset = swipeOffset;
42
+ exports.swipeRelease = swipeRelease;
43
+ exports.surfaceScrolls = surfaceScrolls;
39
44
  exports.SurveyModal = SurveyModal;
45
+ const react_1 = require("react");
40
46
  const jsx_runtime_1 = require("react/jsx-runtime");
41
- const react_1 = __importStar(require("react"));
47
+ const react_2 = __importStar(require("react"));
42
48
  const react_native_1 = require("react-native");
43
49
  const types_1 = require("../types");
44
50
  const storage = __importStar(require("../core/storage"));
@@ -55,6 +61,7 @@ const MatrixDynamicQuestion_1 = require("./questions/MatrixDynamicQuestion");
55
61
  const PanelDynamicQuestion_1 = require("./questions/PanelDynamicQuestion");
56
62
  const DateQuestion_1 = require("./questions/DateQuestion");
57
63
  const survey_spec_1 = require("@cxpinsight/survey-spec");
64
+ const mobileRecorder_1 = require("../core/mobileRecorder");
58
65
  const SignatureQuestion_1 = require("./questions/SignatureQuestion");
59
66
  const FileUploadQuestion_1 = require("./questions/FileUploadQuestion");
60
67
  const MultiTextQuestion_1 = require("./questions/MultiTextQuestion");
@@ -69,6 +76,7 @@ const validate_1 = require("./validate");
69
76
  const pagesFromV1_1 = require("./pagesFromV1");
70
77
  const themePalette_1 = require("./themePalette");
71
78
  const styleContract_1 = require("./styleContract");
79
+ const fadeGradient_1 = require("./fadeGradient");
72
80
  const CXPinsight_1 = require("../CXPinsight");
73
81
  // ISO 639-1 codes for right-to-left scripts. When the respondent picks
74
82
  // one of these in the language switcher, the modal flips to RTL even if
@@ -145,10 +153,96 @@ function resolvePopupTop(opts) {
145
153
  // A card taller than the safe band pins to the top rather than going negative.
146
154
  return Math.max(minTop, maxTop > minTop ? Math.min(wanted, maxTop) : minTop);
147
155
  }
156
+ /* ── Swipe to dismiss: the decisions, as named rules ───────────────────────
157
+ *
158
+ * A push notification goes away when you flick it, and the mobile display
159
+ * slice drops the ✕ for exactly that reason — so on a notification this
160
+ * gesture is not a convenience, it is the only way out.
161
+ *
162
+ * Every threshold below was wrong at least once, and each one was wrong in a
163
+ * way that surfaced as the same single report: "the notification doesn't swipe
164
+ * away". They live out here as named, testable rules rather than as numbers
165
+ * inside the gesture handler, because that report could not tell them apart.
166
+ */
167
+ /** Travel, in points, at which a touch stops being a tap and becomes a drag. */
168
+ exports.SWIPE_CLAIM_PX = 8;
169
+ /** Travel at which letting go dismisses, whatever the speed. */
170
+ exports.SWIPE_DISMISS_PX = 40;
171
+ /** Speed at which letting go dismisses, whatever the travel. */
172
+ exports.SWIPE_DISMISS_VELOCITY = 0.3;
173
+ /**
174
+ * Should the card take this gesture off whatever is underneath it?
175
+ *
176
+ * Asked on the CAPTURE phase, which is what makes the whole surface a handle.
177
+ * On the bubble phase the card is consulted only after some descendant has
178
+ * already become the responder — and on a notification that means the action
179
+ * button and nothing else. Touch the title, the thumbnail or the body and no
180
+ * view claims the touch, so the card was never asked and the banner could only
181
+ * be dragged by its button.
182
+ *
183
+ * The threshold is what keeps a button a button: a press that does not travel
184
+ * is never claimed here, so it reaches the button untouched.
185
+ */
186
+ function shouldClaimSwipe(swipeable, dx, dy) {
187
+ return swipeable && (Math.abs(dx) > exports.SWIPE_CLAIM_PX || Math.abs(dy) > exports.SWIPE_CLAIM_PX);
188
+ }
189
+ /**
190
+ * Where the card sits while the finger is still down.
191
+ *
192
+ * A banner travels off its own edge and no further: dragging a top banner
193
+ * downwards is a pull, not a dismissal, so it does not follow.
194
+ */
195
+ function swipeOffset(placement, dx, dy) {
196
+ if (Math.abs(dx) > Math.abs(dy))
197
+ return { x: dx, y: 0 };
198
+ const clamped = placement === 'top' ? Math.min(0, dy)
199
+ : placement === 'bottom' ? Math.max(0, dy) : dy;
200
+ return { x: 0, y: clamped };
201
+ }
202
+ /**
203
+ * Does letting go here dismiss the card?
204
+ *
205
+ * 56px with a 0.5 velocity escape assumed the gesture is thrown. A deliberate,
206
+ * slow drag is just as clear an instruction and was being refused: measured on
207
+ * a real attempt, 46px of travel at 0.01 velocity — unmistakably someone
208
+ * pushing the card off screen — sprang back, because it missed both gates.
209
+ *
210
+ * 40px is still far enough that a stray touch cannot reach it (a drag is not
211
+ * even claimed below 8px, and a tap typically travels 2-3), and 0.3 catches a
212
+ * light flick that covers less ground.
213
+ */
214
+ function swipeRelease(g) {
215
+ const horizontal = Math.abs(g.dx) > Math.abs(g.dy);
216
+ const moved = horizontal ? g.dx : g.dy;
217
+ const speed = Math.abs(horizontal ? g.vx : g.vy);
218
+ return {
219
+ horizontal,
220
+ moved,
221
+ dismiss: Math.abs(moved) >= exports.SWIPE_DISMISS_PX || speed > exports.SWIPE_DISMISS_VELOCITY,
222
+ };
223
+ }
224
+ /**
225
+ * May the card's body scroll?
226
+ *
227
+ * NOT on a notification, and the reason is the swipe rather than the layout.
228
+ * On iOS that body is a native UIScrollView: once it starts scrolling it owns
229
+ * the touch and the JS responder cannot take it back, so claiming the gesture
230
+ * on the card achieved nothing. The travel went into scrolling content that
231
+ * does not overflow, and the pan saw ~19px at release — under any dismissal
232
+ * threshold worth having. The give-away was the SDK's own capture: ten
233
+ * `scroll` events queued between the card claiming the gesture and the release.
234
+ *
235
+ * Keyed off the INTERACTION, not off whether a thumbnail was composed. A
236
+ * notification with no image is still two lines and a button, and gating this
237
+ * on the image is what left exactly those notifications un-dismissable.
238
+ */
239
+ function surfaceScrolls(interactionKind) {
240
+ return String(interactionKind || '').toLowerCase() !== 'notify';
241
+ }
148
242
  function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
149
- const [currentPage, setCurrentPage] = (0, react_1.useState)(0);
150
- const [responses, setResponses] = (0, react_1.useState)({});
151
- const [showThankYou, setShowThankYou] = (0, react_1.useState)(false);
243
+ const [currentPage, setCurrentPage] = (0, react_2.useState)(0);
244
+ const [responses, setResponses] = (0, react_2.useState)({});
245
+ const [showThankYou, setShowThankYou] = (0, react_2.useState)(false);
152
246
  // Back-navigation history. Non-linear jumps via branches mean the
153
247
  // previous page is NOT always currentPage - 1; the stack records
154
248
  // the pages the respondent actually walked through. Matches the
@@ -156,55 +250,74 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
156
250
  // v1.1 multi-flow (spec §28): also records the flow the respondent
157
251
  // was on at that point — Back after a fork restores BOTH pieces of
158
252
  // state.
159
- const [history, setHistory] = (0, react_1.useState)([]);
253
+ const [history, setHistory] = (0, react_2.useState)([]);
160
254
  // Multi-flow state (spec §28). currentFlow tracks which flow the
161
255
  // respondent is walking through right now. undefined = root flow.
162
256
  // When a fork branch fires we push the origin onto forkStack and
163
257
  // switch currentFlow to the branch's target flowId.
164
- const [currentFlow, setCurrentFlow] = (0, react_1.useState)(undefined);
165
- const [forkStack, setForkStack] = (0, react_1.useState)([]);
258
+ const [currentFlow, setCurrentFlow] = (0, react_2.useState)(undefined);
259
+ const [forkStack, setForkStack] = (0, react_2.useState)([]);
166
260
  // Per-question validation errors surfaced on Next / Submit.
167
261
  // Populated when a required question has no answer; cleared when
168
262
  // the respondent supplies one. Keyed by question id/name so a
169
263
  // page with multiple required questions can flag several at once.
170
264
  // Web renderer keeps a `touched` set to live-re-validate after the
171
265
  // first flag; mobile MVP just clears on updateResponse for now.
172
- const [errors, setErrors] = (0, react_1.useState)({});
266
+ const [errors, setErrors] = (0, react_2.useState)({});
173
267
  // Set when the server rejects a submit (returns false). Shown near the
174
268
  // footer; cleared on retry / navigation / dismiss.
175
- const [submitError, setSubmitError] = (0, react_1.useState)('');
269
+ const [submitError, setSubmitError] = (0, react_2.useState)('');
176
270
  // Hands-free voice state. voiceIdxRef walks the current page's questions;
177
271
  // voiceDoneRef flips once the last page has been submitted by voice.
178
- const voiceIdxRef = react_1.default.useRef(0);
179
- const voiceDoneRef = react_1.default.useRef(false);
180
- const [voiceActive, setVoiceActive] = (0, react_1.useState)(false);
181
- const [voicePhase, setVoicePhase] = (0, react_1.useState)('idle');
182
- const [voiceTranscript, setVoiceTranscript] = (0, react_1.useState)('');
272
+ const voiceIdxRef = react_2.default.useRef(0);
273
+ const voiceDoneRef = react_2.default.useRef(false);
274
+ const [voiceActive, setVoiceActive] = (0, react_2.useState)(false);
275
+ const [voicePhase, setVoicePhase] = (0, react_2.useState)('idle');
276
+ const [voiceTranscript, setVoiceTranscript] = (0, react_2.useState)('');
183
277
  // Selected language for the current session. Initialized to
184
278
  // survey.defaultLanguage; the useEffect below hydrates from
185
279
  // AsyncStorage on mount so a repeat respondent keeps their
186
280
  // previous pick. Only restored if that language is still in the
187
281
  // current survey's languages[] — an older stored value that's no
188
282
  // longer offered silently falls back to defaultLanguage.
189
- const [currentLanguage, setCurrentLanguageRaw] = (0, react_1.useState)(survey.defaultLanguage);
190
- const setCurrentLanguage = (0, react_1.useCallback)((next) => {
283
+ const [currentLanguage, setCurrentLanguageRaw] = (0, react_2.useState)(survey.defaultLanguage);
284
+ const setCurrentLanguage = (0, react_2.useCallback)((next) => {
191
285
  setCurrentLanguageRaw(next);
192
286
  // Fire-and-forget — persistence is a "nice to have", never
193
287
  // block the switcher UX on a slow storage write.
194
288
  storage.set(types_1.STORAGE_KEYS.SURVEY_LANGUAGE, next).catch(() => { });
195
289
  }, []);
196
- (0, react_1.useEffect)(() => {
290
+ // THE RECORDING NEEDS A HANDLE ON THIS CARD.
291
+ //
292
+ // A <Modal> is its own UIWindow on iOS, and the host's captureScreen returns
293
+ // the topmost one — so a frame taken while this is up cannot be trusted to
294
+ // contain either the card or the app behind it. Handing the recorder the
295
+ // card's own view removes the guesswork. Registered while it is on screen
296
+ // and cleared on the way out, so no other frame is captured through it.
297
+ const cardViewRef = (0, react_2.useRef)(null);
298
+ (0, react_2.useEffect)(() => {
299
+ (0, mobileRecorder_1.registerSurveyView)(cardViewRef);
300
+ return () => (0, mobileRecorder_1.clearSurveyView)();
301
+ }, []);
302
+ (0, react_2.useEffect)(() => {
197
303
  let cancelled = false;
198
304
  // Device locale → the survey's matching offered language, when the respondent
199
305
  // has no saved pick — so an Arabic device opens the survey in Arabic instead of
200
306
  // defaultLanguage. A saved manual switch still wins over the device locale.
201
307
  let deviceLang;
202
- try {
203
- const raw = (0, deviceInfo_1.getDeviceInfo)();
204
- const primary = (typeof raw.language === 'string' ? raw.language : '').split(/[-_]/)[0].toLowerCase();
205
- deviceLang = primary
308
+ const offered = (tag) => {
309
+ const primary = (typeof tag === 'string' ? tag : '').split(/[-_]/)[0].toLowerCase();
310
+ return primary
206
311
  ? survey.languages?.find((l) => l.split(/[-_]/)[0].toLowerCase() === primary)
207
312
  : undefined;
313
+ };
314
+ try {
315
+ // identify({ language }) FIRST, then the device locale. A host that knows
316
+ // its signed-in reader prefers Arabic has said something more reliable
317
+ // than the handset's setting, and it was the one signal never asked for
318
+ // — the same gap theme had, on the other half of the same cascade.
319
+ deviceLang = offered(CXPinsight_1.CXPinsight._getUserIdentity()?.language)
320
+ || offered((0, deviceInfo_1.getDeviceInfo)().language);
208
321
  }
209
322
  catch { /* no locale available → stays defaultLanguage */ }
210
323
  storage.get(types_1.STORAGE_KEYS.SURVEY_LANGUAGE).then((saved) => {
@@ -231,7 +344,7 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
231
344
  const presentationMode = typeof survey.settings?.presentationMode === 'string'
232
345
  ? survey.settings.presentationMode
233
346
  : undefined;
234
- const pages = (0, react_1.useMemo)(() => {
347
+ const pages = (0, react_2.useMemo)(() => {
235
348
  if (survey.rawQuestions) {
236
349
  return (0, pagesFromV1_1.pagesFromV1)({ questions: survey.rawQuestions, sections: survey.rawSections }, currentFlow, presentationMode);
237
350
  }
@@ -258,20 +371,24 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
258
371
  // deviceType), for authors keying on layout tier
259
372
  // A change listener re-mounts the attribute context on OS theme toggles
260
373
  // so mid-session `{{device.theme}}` conditions stay accurate.
261
- const [colorScheme, setColorScheme] = (0, react_1.useState)(() => react_native_1.Appearance.getColorScheme() ?? undefined);
262
- (0, react_1.useEffect)(() => {
374
+ const [colorScheme, setColorScheme] = (0, react_2.useState)(() => react_native_1.Appearance.getColorScheme() ?? undefined);
375
+ (0, react_2.useEffect)(() => {
263
376
  const sub = react_native_1.Appearance.addChangeListener((prefs) => {
264
377
  setColorScheme(prefs.colorScheme ?? undefined);
265
378
  });
266
379
  return () => { sub?.remove?.(); };
267
380
  }, []);
268
- const attributeContext = (0, react_1.useMemo)(() => {
381
+ const attributeContext = (0, react_2.useMemo)(() => {
269
382
  const identity = (CXPinsight_1.CXPinsight._getUserIdentity() ?? {});
270
383
  const { id: _omitId, ...userTraits } = identity;
271
384
  const raw = (0, deviceInfo_1.getDeviceInfo)();
272
385
  const langTag = typeof raw.language === 'string' ? raw.language : 'en';
273
386
  const langPrimary = langTag.split(/[-_]/)[0].toLowerCase();
274
387
  const screenClass = raw.deviceType || 'mobile';
388
+ // The triggering event, under the same names web exposes — {{eventName}},
389
+ // {{eventData.total}}, {{userProperties.plan}} — so one authored string
390
+ // resolves the same on both runtimes.
391
+ const trig = survey.triggerContext;
275
392
  const deviceSignals = {
276
393
  ...raw,
277
394
  language: langPrimary, // override wire language with primary tag
@@ -284,9 +401,21 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
284
401
  user: userTraits,
285
402
  });
286
403
  }, [colorScheme]);
404
+ // Merged OVER the attribute floor rather than into it, because the trigger
405
+ // changes per firing while the floor is memoised on the colour scheme.
406
+ const interpolationFloor = (0, react_2.useMemo)(() => {
407
+ const trig = survey.triggerContext;
408
+ if (!trig)
409
+ return attributeContext;
410
+ return {
411
+ ...attributeContext,
412
+ ...(trig.eventName != null ? { eventName: trig.eventName } : null),
413
+ ...(trig.eventData ? { eventData: trig.eventData } : null),
414
+ };
415
+ }, [attributeContext, survey]);
287
416
  const evalCtx = {
288
417
  answers: responses,
289
- variables: (0, variablesResolver_1.resolveVariables)(survey.variables, { sdkIdentifyData: CXPinsight_1.CXPinsight._getUserIdentity() }, responses, attributeContext),
418
+ variables: (0, variablesResolver_1.resolveVariables)(survey.variables, { sdkIdentifyData: CXPinsight_1.CXPinsight._getUserIdentity() }, responses, interpolationFloor),
290
419
  };
291
420
  // showWhen / hideWhen filter (spec §7). Web renderer semantics:
292
421
  // showWhen truthy AND hideWhen falsy → visible. A missing condition
@@ -357,7 +486,7 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
357
486
  // and the React renderer so a Notify/Offer/Comply template behaves the same
358
487
  // on a phone as it does on a website. Suppresses the footer below.
359
488
  const impliedNow = (0, survey_spec_1.impliedStepActions)(currentQuestions);
360
- const updateResponse = (0, react_1.useCallback)((name, value) => {
489
+ const updateResponse = (0, react_2.useCallback)((name, value) => {
361
490
  setResponses((prev) => ({ ...prev, [name]: value }));
362
491
  // Live-clear the error entry the moment the new answer passes
363
492
  // validation. Re-runs the same validator handleNext would fire,
@@ -486,6 +615,12 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
486
615
  return; // keep the survey open for a retry
487
616
  }
488
617
  setSubmitError('');
618
+ // See the provider: a notification has no thank-you ending. Skipping the
619
+ // state entirely also skips the 2.5s window the card would otherwise sit
620
+ // there re-rendering after the person has already acted.
621
+ if (String(survey.interactionType || '')
622
+ .toLowerCase() === 'notify')
623
+ return;
489
624
  setShowThankYou(true);
490
625
  const duration = survey.settings?.thankYouDuration ?? 2500;
491
626
  setTimeout(() => {
@@ -508,8 +643,8 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
508
643
  * them to the problem and they must swipe back through every question before
509
644
  * it. Web and link surveys move the cursor; this brings the app in line.
510
645
  */
511
- const questionRefs = react_1.default.useRef({});
512
- const handleNextRef = react_1.default.useRef(() => { });
646
+ const questionRefs = react_2.default.useRef({});
647
+ const handleNextRef = react_2.default.useRef(() => { });
513
648
  const handleNext = () => {
514
649
  const pageErrors = validateCurrentPage();
515
650
  if (Object.keys(pageErrors).length > 0) {
@@ -605,7 +740,68 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
605
740
  };
606
741
  // Kept fresh so the memoized pan responder never dismisses through a stale
607
742
  // closure — the same reason handleNextRef exists.
608
- const handleDismissRef = (0, react_1.useRef)(() => { });
743
+ /**
744
+ * WHAT PRESSING AN IMPLIED ACTION DOES — the press, separated from the thing
745
+ * pressed.
746
+ *
747
+ * This lived inside the action button's own `onPress`, so it could only ever
748
+ * happen if that button was on screen. Hiding it — which is the whole point
749
+ * of handing the press to the card — left the phone with no way to act on
750
+ * the interaction at all, because unlike web the card here was never
751
+ * pressable in the first place. Same defect, one step worse.
752
+ */
753
+ const pressImplied = (0, react_2.useCallback)((key, a) => {
754
+ // "Remind me later" dismisses instead of answering, so the cadence records
755
+ // a SKIP and brings the interaction back after the author's wait.
756
+ // Answering would record a decision this person explicitly declined to
757
+ // make and retire it against maxSubmissions. It does not follow the
758
+ // action's link either — postponing is not a request to go anywhere.
759
+ if ((0, survey_spec_1.isDeferChoice)(a, survey.interactionType)) {
760
+ // A postponement, not a refusal — the server tallies it separately and
761
+ // the cadence treats it as a skip rather than an answer.
762
+ handleDismissRef.current('defer');
763
+ return;
764
+ }
765
+ updateResponse(key, a.value);
766
+ // Deferred a tick so setResponses has flushed before handleNext validates
767
+ // what it just replaced.
768
+ setTimeout(() => handleNextRef.current(), 0);
769
+ // Follow the action's deep link. `myapp://cart` is the point of this on a
770
+ // phone; Linking refuses a scheme the OS has no handler for, so a bad
771
+ // target fails quietly rather than crashing the survey. The href already
772
+ // passed safeActionHref in the shared spec.
773
+ if (a.href) {
774
+ react_native_1.Linking.openURL(a.href).catch(() => { });
775
+ }
776
+ }, [survey, updateResponse]);
777
+ const handleDismissRef = (0, react_2.useRef)(() => { });
778
+ // A PRESS ON A CONTROL IS A PRESS, NOT A DRAG.
779
+ //
780
+ // The card's swipe reads raw touch events, and those BUBBLE — a touch that
781
+ // begins on the action button reaches the card too, so the card followed the
782
+ // finger while someone was simply pressing "Resume". That is the whole
783
+ // surface behaving as a handle in the one place it must not.
784
+ //
785
+ // `onPressIn`, NOT `onTouchStart`: TouchableOpacity enumerates the props it
786
+ // passes to its underlying view and onTouchStart is not among them, so a
787
+ // handler put there is accepted by the type (it extends ViewProps) and then
788
+ // silently dropped at runtime. onPressIn is consumed by Pressability itself,
789
+ // which is why it arrives.
790
+ //
791
+ // Written so the ORDER of the two events cannot matter, because it is not
792
+ // guaranteed: if the press registers first the card never starts a swipe, and
793
+ // if the card gets there first the press takes the swipe back — safely,
794
+ // because at that point the finger has not travelled and nothing has moved.
795
+ const pressingControl = (0, react_2.useRef)(false);
796
+ const touchSwipe = (0, react_2.useRef)(null);
797
+ const swipeExempt = {
798
+ onPressIn: () => {
799
+ pressingControl.current = true;
800
+ if (touchSwipe.current && !touchSwipe.current.moved)
801
+ touchSwipe.current = null;
802
+ },
803
+ onPressOut: () => { pressingControl.current = false; },
804
+ };
609
805
  /**
610
806
  * Whether a compliance interaction currently lets the respondent leave.
611
807
  *
@@ -614,7 +810,7 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
614
810
  * would treat "Continue" as consent. Mirrors the web SDK and the survey
615
811
  * service; the shared gate makes the decision, this only finds its inputs.
616
812
  */
617
- const complianceVerdict = (0, react_1.useMemo)(() => {
813
+ const complianceVerdict = (0, react_2.useMemo)(() => {
618
814
  const qs = (survey.rawQuestions ?? []);
619
815
  const acts = (0, survey_spec_1.impliedStepActions)(qs) || [];
620
816
  let action = null;
@@ -635,7 +831,7 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
635
831
  compliance: sv.compliance,
636
832
  });
637
833
  }, [survey, responses]);
638
- const handleDismiss = () => {
834
+ const handleDismiss = (reason) => {
639
835
  // A compliance check that closes on request is not a compliance check.
640
836
  // Say why instead of appearing broken — being held with no explanation is
641
837
  // indistinguishable from the surface having frozen.
@@ -654,14 +850,9 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
654
850
  setSubmitError('');
655
851
  setCurrentFlow(undefined);
656
852
  setForkStack([]);
657
- onDismiss();
853
+ onDismiss(reason);
658
854
  };
659
- handleDismissRef.current = (() => {
660
- if (__DEV__) { /* eslint-disable-next-line no-console */
661
- console.log('[CXP-SW] handleDismiss INVOKED');
662
- }
663
- return handleDismiss();
664
- });
855
+ handleDismissRef.current = handleDismiss;
665
856
  // Normalize the v1.1 options[] {value,label} and the legacy
666
857
  // choices[] (string | {value,text}) into a single shape that
667
858
  // MultipleChoiceQuestion accepts. hasOther appends a synthetic
@@ -866,43 +1057,46 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
866
1057
  // can handle.
867
1058
  const actions = (0, survey_spec_1.impliedActions)(question, (t) => (0, interpolate_1.interpolatePlain)(t, evalCtx));
868
1059
  const list = actions.length ? actions : [{ value: 'acknowledged', label: 'Continue' }];
1060
+ // THE CARD MAY BE CARRYING THE ACTION ALONE.
1061
+ //
1062
+ // One rule, in the shared spec, so the three renderers cannot disagree
1063
+ // about when a button disappears. It refuses the hide unless the whole
1064
+ // card can take the press, so this can never leave a surface with
1065
+ // something to say and no way to say yes to it.
1066
+ if (!(0, survey_spec_1.showActionButton)(display, list))
1067
+ return null;
869
1068
  // Consent choices render identically — see impliedEqualProminence.
870
1069
  const equal = (0, survey_spec_1.impliedEqualProminence)([question]);
871
- return ((0, jsx_runtime_1.jsx)(react_native_1.View, { style: styles.impliedActions, children: list.map((a, i) => ((0, jsx_runtime_1.jsx)(react_native_1.TouchableOpacity, {
1070
+ return ((0, jsx_runtime_1.jsx)(react_native_1.View, { style: styles.impliedActions, children: list.map((a, i) => ((0, react_1.createElement)(react_native_1.TouchableOpacity, { ...swipeExempt, key: a.value || String(i),
872
1071
  // The press is both the answer and the Next — the footer is
873
1072
  // hidden on this step. Deferred a tick so setResponses has
874
1073
  // flushed before handleNext validates what it just replaced.
875
- onPress: () => {
876
- // "Remind me later" dismisses instead of answering, so the
877
- // cadence records a SKIP and brings the interaction back
878
- // after the author's wait. Answering would record a decision
879
- // this person explicitly declined to make and retire it
880
- // against maxSubmissions. It does not follow the action's
881
- // link either — postponing is not a request to go anywhere.
882
- // Through isDeferChoice, not the raw flag: it is false for a
883
- // compliance check, and a compliance check you postpone by
884
- // pressing a button is one you never have to answer.
885
- if ((0, survey_spec_1.isDeferChoice)(a, survey.interactionType)) {
886
- handleDismissRef.current();
887
- return;
888
- }
889
- updateResponse(key, a.value);
890
- setTimeout(() => handleNextRef.current(), 0);
891
- // Follow the action's deep link. `myapp://cart` is the point
892
- // of this on a phone; Linking refuses a scheme the OS has no
893
- // handler for, so a bad target fails quietly rather than
894
- // crashing the survey. The href already passed
895
- // safeActionHref in the shared spec.
896
- if (a.href) {
897
- react_native_1.Linking.openURL(a.href).catch(() => { });
898
- }
899
- }, accessibilityRole: "button", accessibilityState: { selected: val === a.value }, style: [
1074
+ // The press is both the answer and the Next — the footer is
1075
+ // hidden on this step. Its body is pressImplied, so the CARD
1076
+ // can do exactly the same thing when this button is not drawn.
1077
+ onPress: () => pressImplied(key, a), accessibilityRole: "button", accessibilityState: { selected: val === a.value }, style: [
900
1078
  styles.impliedBtn,
1079
+ // BESIDE THE TEXT, THE BUTTON IS AS WIDE AS ITS LABEL.
1080
+ //
1081
+ // impliedBtn is built for the full-width layout underneath the
1082
+ // content: flexGrow 1 over a 140pt basis, so two actions share
1083
+ // the row evenly. Beside the body that basis is most of the
1084
+ // card, and every point it takes is a point the text wraps
1085
+ // into. Here it grows from nothing, and its side padding is
1086
+ // halved — a banner action is a word or two, and 18pt a side
1087
+ // was sized for a button standing on its own.
1088
+ notifyBeside && {
1089
+ flexGrow: 0,
1090
+ flexBasis: 'auto',
1091
+ paddingHorizontal: Math.max(6, Math.round(contract.buttonPaddingX / 2)),
1092
+ },
901
1093
  // The first action is the affirmative one. An author who
902
1094
  // lists "Decline" first gets it emphasised — their call.
903
1095
  (i === 0 && !equal) ? styles.impliedPrimary : styles.impliedSecondary,
1096
+ (i === 0 && !equal) && authoredBtnBorder ? { borderColor: authoredBtnBorder } : null,
904
1097
  val === a.value && styles.impliedChosen,
905
- ], children: (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: (i === 0 && !equal) ? styles.impliedPrimaryLabel : styles.impliedSecondaryLabel, children: a.label }) }, a.value || String(i)))) }));
1098
+ ] },
1099
+ (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: (i === 0 && !equal) ? styles.impliedPrimaryLabel : styles.impliedSecondaryLabel, children: a.label })))) }));
906
1100
  },
907
1101
  'signature': () => {
908
1102
  if (!(0, SignatureQuestion_1.isSignatureAvailable)())
@@ -1003,10 +1197,10 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
1003
1197
  // A ref carries the latest render values so the (stable) controller always
1004
1198
  // reads fresh page/question state — commit advances the page and awaits the
1005
1199
  // re-render before the session reads the next question.
1006
- const voiceRef = react_1.default.useRef({ currentQuestions: [], isLastPage: false, language: 'en', intro: '', outro: '', updateResponse: () => { }, handleNext: () => { } });
1200
+ const voiceRef = react_2.default.useRef({ currentQuestions: [], isLastPage: false, language: 'en', intro: '', outro: '', updateResponse: () => { }, handleNext: () => { } });
1007
1201
  const voiceLangM = currentLanguage || survey.defaultLanguage || 'en';
1008
1202
  // Voice-only viability across the whole survey (all pages' questions).
1009
- const voiceAudit = (0, react_1.useMemo)(() => (0, voiceParsers_1.auditVoiceQuestions)(pages.flatMap((p) => p.elements)), [pages]);
1203
+ const voiceAudit = (0, react_2.useMemo)(() => (0, voiceParsers_1.auditVoiceQuestions)(pages.flatMap((p) => p.elements)), [pages]);
1010
1204
  const voiceIntroM = [
1011
1205
  String(survey.title || '').replace(/<[^>]*>/g, '').trim(),
1012
1206
  String(survey.description || '').replace(/<[^>]*>/g, '').trim(),
@@ -1014,7 +1208,7 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
1014
1208
  ].filter(Boolean).join('. ');
1015
1209
  const voiceOutroM = String((survey.settings?.thankYouMessage) || survey.thankYouMessage || '').replace(/<[^>]*>/g, '').trim();
1016
1210
  voiceRef.current = { currentQuestions, isLastPage, language: voiceLangM, intro: voiceIntroM, outro: voiceOutroM, updateResponse, handleNext };
1017
- const buildVoiceController = (0, react_1.useCallback)(() => {
1211
+ const buildVoiceController = (0, react_2.useCallback)(() => {
1018
1212
  const advance = () => {
1019
1213
  voiceIdxRef.current++;
1020
1214
  const v = voiceRef.current;
@@ -1053,7 +1247,7 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
1053
1247
  onStop: () => { setVoiceActive(false); setVoicePhase('idle'); setVoiceTranscript(''); },
1054
1248
  };
1055
1249
  }, [currentLanguage]); // eslint-disable-line react-hooks/exhaustive-deps
1056
- const toggleVoice = (0, react_1.useCallback)(() => {
1250
+ const toggleVoice = (0, react_2.useCallback)(() => {
1057
1251
  if ((0, handsFreeVoice_1.isVoiceActive)()) {
1058
1252
  (0, handsFreeVoice_1.stopVoice)();
1059
1253
  return;
@@ -1070,9 +1264,9 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
1070
1264
  }, [buildVoiceController, voiceAudit.blocking.length]);
1071
1265
  // Reset the walker when the page changes via MANUAL navigation (voice manages
1072
1266
  // its own index across its advances); always stop voice on unmount.
1073
- (0, react_1.useEffect)(() => { if (!(0, handsFreeVoice_1.isVoiceActive)())
1267
+ (0, react_2.useEffect)(() => { if (!(0, handsFreeVoice_1.isVoiceActive)())
1074
1268
  voiceIdxRef.current = 0; }, [currentPage, currentFlow]);
1075
- (0, react_1.useEffect)(() => () => { (0, handsFreeVoice_1.stopVoice)(); }, []);
1269
+ (0, react_2.useEffect)(() => () => { (0, handsFreeVoice_1.stopVoice)(); }, []);
1076
1270
  const voiceOn = (0, handsFreeVoice_1.isVoiceSupported)() && survey.voiceMode !== false;
1077
1271
  const showVoiceMic = voiceOn && !(history.length > 0 || currentPage > 0);
1078
1272
  // branding.typography.fontFamily overrides the system font on every
@@ -1090,10 +1284,21 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
1090
1284
  // application — see the note in the web SDK's appDefaults().
1091
1285
  const _detached = Boolean(survey.settings?.themeDetached);
1092
1286
  const _asdMobile = _detached ? {} : (survey.applicationSurveyDefaults || {});
1287
+ // The platform slice of branding.style, read early because the font is
1288
+ // resolved above the block that computes it for everything else.
1289
+ const _authoredStyleFont = stylePlatformSlice(survey.branding?.style).fontFamily;
1093
1290
  const _fontByLang = survey.branding?.typography?.fontByLanguage;
1094
1291
  const _langPrimary = (currentLanguage || '').split('-')[0] || '';
1095
1292
  const fontFamily = (_fontByLang && ((currentLanguage ? _fontByLang[currentLanguage] : undefined) || _fontByLang[_langPrimary])) ||
1096
1293
  survey.branding?.typography?.fontFamily ||
1294
+ // …and branding.style.fontFamily, which is what the component editor's
1295
+ // Font family control actually writes. Web has read it all along
1296
+ // (resolveSurveyStyle.fontFamily); mobile read only the older typography
1297
+ // field, so the control moved the preview and left the phone alone.
1298
+ //
1299
+ // AFTER typography, not before: a survey that already set the older field
1300
+ // keeps rendering exactly as it does today.
1301
+ (typeof _authoredStyleFont === 'string' && _authoredStyleFont.trim() ? _authoredStyleFont.trim() : undefined) ||
1097
1302
  _asdMobile.font ||
1098
1303
  undefined;
1099
1304
  const fontStyle = fontFamily ? { fontFamily } : undefined;
@@ -1164,8 +1369,6 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
1164
1369
  })();
1165
1370
  const scaled = (n) => Math.round(n * baseScale);
1166
1371
  const fs = baseScale !== 1;
1167
- const titleFS = fs ? { fontSize: scaled(20) } : undefined;
1168
- const descFS = fs ? { fontSize: scaled(14) } : undefined;
1169
1372
  const qTitleFS = fs ? { fontSize: scaled(15) } : undefined;
1170
1373
  const qDescFS = fs ? { fontSize: scaled(13) } : undefined;
1171
1374
  const thankYouFS = fs ? { fontSize: scaled(18) } : undefined;
@@ -1223,6 +1426,10 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
1223
1426
  comf; // 'comfortable' or undefined
1224
1427
  const padHeader = density ? { padding: dens(16, 24, 32), paddingBottom: dens(8, 12, 16) } : undefined;
1225
1428
  const padBody = density ? { paddingHorizontal: dens(16, 24, 32) } : undefined;
1429
+ // A picture band sits in the same column as the text — the header's
1430
+ // horizontal padding, without its vertical padding (the band carries its own
1431
+ // margin on the side that faces the words).
1432
+ const padBand = density ? { paddingHorizontal: dens(16, 24, 32) } : undefined;
1226
1433
  const padFooter = density ? { padding: dens(16, 24, 32), paddingTop: dens(12, 16, 20) } : undefined;
1227
1434
  const gapQBlock = density ? { marginBottom: dens(12, 20, 28) } : undefined;
1228
1435
  // Theme palette (spec §24). Dark is the SDK default; light flips
@@ -1242,23 +1449,36 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
1242
1449
  // changes mid-session re-render via the useState/Appearance listener
1243
1450
  // above (attributeContext is memoized on colorScheme too).
1244
1451
  const authorTheme = survey.branding?.theme;
1245
- // For 'auto'/absent, follow the end-user's preference, highest first:
1246
- // 1. user.theme — an identify() trait the host keeps in sync with its own
1247
- // theme toggle: CXPinsight.identify({ theme: 'light'|'dark' }). Recommended.
1248
- // 2. init({ theme }) config — still honored for backward compat.
1249
- // 3. the OS colour scheme (Appearance). init({theme}) is also reported as
1250
- // device.theme for analytics/targeting regardless.
1251
- const _pref = (v) => {
1252
- const t = typeof v === 'string' ? v.toLowerCase() : '';
1253
- return t === 'light' || t === 'dark' ? t : undefined;
1254
- };
1255
- const _userTheme = _pref(CXPinsight_1.CXPinsight._getUserIdentity()?.theme);
1256
- const _initTheme = _pref(CXPinsight_1.CXPinsight._getConfig()?.theme);
1452
+ // WHO DECIDES LIGHT OR DARK — the spec answers it, so the phone and the
1453
+ // browser cannot rank the five candidates differently. They did, and the
1454
+ // same card came out white here and dark there. See resolveTheme for why
1455
+ // the device's own setting is last.
1456
+ //
1457
+ // An experiment variation still overrules the lot: it is not a preference,
1458
+ // it is the thing being measured.
1459
+ const _themeDecision = (0, survey_spec_1.resolveThemeDecision)({
1460
+ pinned: authorTheme,
1461
+ user: CXPinsight_1.CXPinsight._getUserIdentity()?.theme,
1462
+ declared: CXPinsight_1.CXPinsight._getConfig()?.theme,
1463
+ appDefault: survey.branding?.themeDefault,
1464
+ device: colorScheme,
1465
+ });
1466
+ // Under `debug`, say WHICH candidate won. From the outside every tier
1467
+ // produces the same two words, so "why is this card light?" is otherwise
1468
+ // answered by guessing — which is how it got answered wrongly twice.
1469
+ if (CXPinsight_1.CXPinsight._getConfig()?.debug) {
1470
+ // eslint-disable-next-line no-console
1471
+ console.log('[CXPinsight] card theme:', _themeDecision.theme, 'from', _themeDecision.from, JSON.stringify({
1472
+ pinned: authorTheme ?? null,
1473
+ user: CXPinsight_1.CXPinsight._getUserIdentity()?.theme ?? null,
1474
+ declared: CXPinsight_1.CXPinsight._getConfig()?.theme ?? null,
1475
+ appDefault: survey.branding?.themeDefault ?? null,
1476
+ device: colorScheme ?? null,
1477
+ }));
1478
+ }
1257
1479
  const resolvedTheme = activeOverrides?.theme === 'light' || activeOverrides?.theme === 'dark'
1258
1480
  ? activeOverrides.theme
1259
- : authorTheme === 'light' || authorTheme === 'dark'
1260
- ? authorTheme
1261
- : (_userTheme || _initTheme || (colorScheme === 'light' ? 'light' : colorScheme === 'dark' ? 'dark' : 'dark'));
1481
+ : _themeDecision.theme;
1262
1482
  const isLight = resolvedTheme === 'light';
1263
1483
  // Layer palette overrides: SDK-init (host app) > survey.branding.palette
1264
1484
  // > built-in defaults. themePalette applies precedence per-key so a
@@ -1292,7 +1512,7 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
1292
1512
  // returns a fresh object each call, so an unmemoized value would give
1293
1513
  // `styles` a new identity every render and call StyleSheet.create() every
1294
1514
  // time — which leaks entries in RN's style registry over a long session.
1295
- const contract = (0, react_1.useMemo)(() => (0, styleContract_1.resolveStyleContract)({
1515
+ const contract = (0, react_2.useMemo)(() => (0, styleContract_1.resolveStyleContract)({
1296
1516
  theme: resolvedTheme,
1297
1517
  primaryColor,
1298
1518
  // survey.branding.palette is written by SurveyPaletteEditor in the
@@ -1319,7 +1539,7 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
1319
1539
  [resolvedTheme, primaryColor,
1320
1540
  JSON.stringify(surveyPaletteOverride), JSON.stringify(initPaletteOverride),
1321
1541
  JSON.stringify(survey.branding?.style)]);
1322
- const styles = (0, react_1.useMemo)(() => makeStyles(contract), [contract]);
1542
+ const styles = (0, react_2.useMemo)(() => makeStyles(contract), [contract]);
1323
1543
  const themeCard = isLight ? { backgroundColor: palette.card, borderColor: palette.cardBorder } : undefined;
1324
1544
  const themeTextPrimary = isLight ? { color: palette.textPrimary } : undefined;
1325
1545
  const themeTextSecondary = isLight ? { color: palette.textSecondary } : undefined;
@@ -1346,7 +1566,14 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
1346
1566
  // and (since the parallel fix) the web SDK, and died here. Precedence matches
1347
1567
  // the other two exactly: survey ▶ application ▶ built-in, field by field, with
1348
1568
  // '' meaning inherit rather than override-with-nothing.
1349
- const display = (0, react_1.useMemo)(() => {
1569
+ // DEFINED AFTER THE CONTRACT, because they now read it. These override
1570
+ // the contract sizes when typography.baseFontSize is set, so they have to
1571
+ // scale the AUTHORED size — scaling a literal threw the author's heading
1572
+ // and body sizes away the moment the coarse control was touched.
1573
+ // (they were scaled(20) and scaled(14), literals.)
1574
+ const titleFS = fs ? { fontSize: scaled(contract.fontSizeHeading) } : undefined;
1575
+ const descFS = fs ? { fontSize: scaled(contract.fontSizeDescription) } : undefined;
1576
+ const display = (0, react_2.useMemo)(() => {
1350
1577
  const a = _asdMobile;
1351
1578
  // Both layers may store display per platform — web and mobile positions are
1352
1579
  // different decisions, and a survey that could only store one made editing
@@ -1423,10 +1650,355 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
1423
1650
  const allQuestions = (survey.rawQuestions
1424
1651
  || survey.questions
1425
1652
  || []);
1426
- const notifyImageQ = allQuestions.find((q) => String(q?.id ?? '') === 'notify_image' && String(q?.type ?? '').toLowerCase() === 'image');
1427
- // Keyed off the composed question, not the type alone: an Ask that happens to
1428
- // carry an image question must keep rendering it as a question.
1429
- const isNotifyCard = interactionKind === 'notify' && !!notifyImageQ?.imageUrl;
1653
+ // FOUND BY TYPE, NOT BY THE ID THE BUILDER HAPPENS TO WRITE.
1654
+ //
1655
+ // `notify_image` is one authoring tool's convention, and matching it meant an
1656
+ // offer carrying exactly the same image question composed nothing — the
1657
+ // picture fell through to the question list full-bleed, which is the shape
1658
+ // that started this.
1659
+ const cardImageQ = allQuestions.find((q) => String(q?.type ?? '').toLowerCase() === 'image');
1660
+ // WHERE THE PICTURE GOES — asked once, in the spec, so the phone and the
1661
+ // preview cannot answer it differently. 'none' leaves it to the question
1662
+ // flow, which is still right for an ask: there the image is content the
1663
+ // respondent is being shown, not decoration on a card.
1664
+ const imgD = (0, survey_spec_1.imageDesign)(display, interactionKind, !!cardImageQ?.imageUrl);
1665
+ const imgLayout = imgD.layout;
1666
+ const composesImage = imgLayout !== 'none';
1667
+ const besideImage = imgLayout === 'beside';
1668
+ // THE BANNER'S TYPE SCALE BELONGS TO THE BANNER, NOT TO ITS PICTURE.
1669
+ //
1670
+ // This was keyed off having an image, so a notification without one got the
1671
+ // survey's 20px heading while the preview gave every notification 15px — the
1672
+ // `.cxp-notify` narrowing on web has never cared whether there is a picture.
1673
+ const isNotifyBanner = interactionKind === 'notify';
1674
+ // A BAND — as wide as the card, above the message or below it. Its height is
1675
+ // imageHeight, NOT imageSize: the square's 44 is the right answer to a
1676
+ // different question, and reusing it turned "Above" into a 44px strip.
1677
+ /**
1678
+ * A COVER-CROPPED PICTURE WITH A FOCAL POINT — measured, because React
1679
+ * Native has no object-position.
1680
+ *
1681
+ * The web SDK writes one CSS declaration for this. Here the only exact way
1682
+ * is to work it out: ask the file for its natural size, ask the box for its
1683
+ * laid-out size, scale the way `cover` would, and shift by the part of the
1684
+ * overflow the author asked to keep. Approximating it with a fixed
1685
+ * translation would be a control that is nearly right, which in an editor is
1686
+ * worse than one that is absent.
1687
+ *
1688
+ * Falls back to plain `cover` until both measurements arrive, so the first
1689
+ * paint is the centre crop every renderer already does rather than a blank.
1690
+ */
1691
+ const FocalImage = ({ uri, alt, fit, focusX, focusY, radius }) => {
1692
+ const [box, setBox] = (0, react_2.useState)({ w: 0, h: 0 });
1693
+ const [nat, setNat] = (0, react_2.useState)({ w: 0, h: 0 });
1694
+ (0, react_2.useEffect)(() => {
1695
+ if (!uri || fit !== 'cover')
1696
+ return;
1697
+ let alive = true;
1698
+ // Never throws for a bad URI — it calls the failure handler, and then we
1699
+ // simply stay on the centre crop.
1700
+ react_native_1.Image.getSize(uri, (w, h) => { if (alive)
1701
+ setNat({ w, h }); }, () => { });
1702
+ return () => { alive = false; };
1703
+ }, [uri, fit]);
1704
+ const centred = fit !== 'cover' || !box.w || !box.h || !nat.w || !nat.h
1705
+ || (focusX === 50 && focusY === 50);
1706
+ let inner = null;
1707
+ if (!centred) {
1708
+ const scale = Math.max(box.w / nat.w, box.h / nat.h);
1709
+ const w = nat.w * scale;
1710
+ const h = nat.h * scale;
1711
+ // What cover hides, and how much of it the author wants on each side.
1712
+ inner = {
1713
+ width: w,
1714
+ height: h,
1715
+ left: -(w - box.w) * (focusX / 100),
1716
+ top: -(h - box.h) * (focusY / 100),
1717
+ };
1718
+ }
1719
+ return ((0, jsx_runtime_1.jsx)(react_native_1.View, { style: { width: '100%', height: '100%', overflow: 'hidden', borderRadius: radius }, onLayout: (e) => {
1720
+ const { width, height } = e.nativeEvent.layout;
1721
+ setBox((p) => (Math.abs(p.w - width) > 1 || Math.abs(p.h - height) > 1
1722
+ ? { w: width, h: height } : p));
1723
+ }, children: (0, jsx_runtime_1.jsx)(react_native_1.Image, { source: { uri }, accessibilityLabel: alt || undefined, accessible: !!alt, resizeMode: centred ? fit : 'cover', style: inner
1724
+ ? { position: 'absolute', ...inner }
1725
+ : { width: '100%', height: '100%' } }) }));
1726
+ };
1727
+ const cardImageBand = (imgLayout === 'above' || imgLayout === 'below') ? ((0, jsx_runtime_1.jsx)(react_native_1.View, { style: {
1728
+ // A band narrower than the card can sit left, centre or right — which
1729
+ // is what Alignment means once Width is not always 100%.
1730
+ width: `${imgD.width}%`,
1731
+ height: contract.imageHeight,
1732
+ borderRadius: contract.imageRadius,
1733
+ // Margin only on the side facing the text, so choosing "Below" does not
1734
+ // leave a gap above the picture as well as under it.
1735
+ marginBottom: imgLayout === 'above' ? 12 : 0,
1736
+ marginTop: imgLayout === 'above' ? 0 : 12,
1737
+ alignSelf: imgD.align === 'start' ? 'flex-start'
1738
+ : imgD.align === 'end' ? 'flex-end' : 'center',
1739
+ overflow: 'hidden',
1740
+ }, children: (0, jsx_runtime_1.jsx)(FocalImage, { uri: cardImageQ?.imageUrl, alt: cardImageQ?.alt, fit: imgD.fit, focusX: imgD.focusX, focusY: imgD.focusY, radius: contract.imageRadius }) })) : null;
1741
+ /**
1742
+ * THE SEAM, SOFTENED — without a gradient primitive.
1743
+ *
1744
+ * React Native has none, and `react-native-svg` is an OPTIONAL peer this SDK
1745
+ * cannot depend on. So the fade is built from what every build has: a short
1746
+ * stack of absolutely-positioned Views in the card's own colour, each a
1747
+ * little more opaque than the last. Eight steps is enough that the banding
1748
+ * is invisible at the sizes a card uses, and it costs eight empty Views.
1749
+ *
1750
+ * The alternative was a Fade control that worked in the preview and did
1751
+ * nothing here, which is the thing this codebase keeps having to undo.
1752
+ */
1753
+ const fadeSteps = (towards) => {
1754
+ if (!imgD.fade)
1755
+ return null;
1756
+ const span = Math.max(2, Math.min(96, imgD.fade)) / 100;
1757
+ const vertical = towards === 'top' || towards === 'bottom';
1758
+ // A REAL GRADIENT WHERE THE APP HAS ONE. react-native-svg is an optional
1759
+ // peer, already loaded this way by the signature question, and it draws
1760
+ // the thing rather than approximating it.
1761
+ if ((0, fadeGradient_1.hasGradient)()) {
1762
+ const { Svg, Defs, LinearGradient, Stop, Rect } = fadeGradient_1.SvgParts;
1763
+ // Runs FROM transparent TO the card colour, so the solid end is the edge
1764
+ // the words are on.
1765
+ const x1 = vertical ? '0' : (towards === 'right' ? '0' : '1');
1766
+ const x2 = vertical ? '0' : (towards === 'right' ? '1' : '0');
1767
+ const y1 = vertical ? (towards === 'bottom' ? '0' : '1') : '0';
1768
+ const y2 = vertical ? (towards === 'bottom' ? '1' : '0') : '0';
1769
+ return ((0, jsx_runtime_1.jsx)(react_native_1.View, { pointerEvents: "none", style: {
1770
+ position: 'absolute',
1771
+ ...(vertical
1772
+ ? { left: 0, right: 0, height: `${span * 100}%`,
1773
+ ...(towards === 'bottom' ? { bottom: 0 } : { top: 0 }) }
1774
+ : { top: 0, bottom: 0, width: `${span * 100}%`,
1775
+ ...(towards === 'right' ? { right: 0 } : { left: 0 }) }),
1776
+ }, children: (0, jsx_runtime_1.jsxs)(Svg, { width: "100%", height: "100%", children: [(0, jsx_runtime_1.jsx)(Defs, { children: (0, jsx_runtime_1.jsxs)(LinearGradient, { id: "cxpFade", x1: x1, y1: y1, x2: x2, y2: y2, children: [(0, jsx_runtime_1.jsx)(Stop, { offset: "0", stopColor: contract.card, stopOpacity: "0" }), (0, jsx_runtime_1.jsx)(Stop, { offset: "1", stopColor: contract.card, stopOpacity: "1" })] }) }), (0, jsx_runtime_1.jsx)(Rect, { x: "0", y: "0", width: "100%", height: "100%", fill: "url(#cxpFade)" })] }) }));
1777
+ }
1778
+ // NO SVG IN THIS APP — bands. SLICES, side by side, not rectangles stacked
1779
+ // on top of each other.
1780
+ //
1781
+ // They used to be stacked: band i was `span * (i+1)/n` long, every one
1782
+ // anchored to the same edge, each with a solved opacity. The arithmetic
1783
+ // was right for a stack and the geometry was backwards — the LARGEST
1784
+ // rectangle got the HIGHEST opacity, and band 27 was the full span at
1785
+ // opacity 1/(28-27) = 1. A single opaque card-coloured block over the
1786
+ // whole fade, with twenty-seven invisible bands underneath it. Reported,
1787
+ // exactly, as "not a gradient, just a solid dark cover over the image".
1788
+ //
1789
+ // Slices cannot do that to themselves. Each covers one band of the span
1790
+ // and nothing overlaps, so the alpha at that distance IS the alpha you
1791
+ // write: 1 at the edge the words are on, 0 where the picture takes over.
1792
+ // At 28 steps each one is a 3.6% change, which is under what the eye
1793
+ // picks out against a photograph.
1794
+ const pctOf = (v) => `${v * 100}%`;
1795
+ const thickness = pctOf(span / fadeGradient_1.FADE_STEPS);
1796
+ return ((0, jsx_runtime_1.jsx)(jsx_runtime_1.Fragment, { children: Array.from({ length: fadeGradient_1.FADE_STEPS }, (_, i) => {
1797
+ const offset = pctOf((span * i) / fadeGradient_1.FADE_STEPS);
1798
+ // Sampled mid-slice, so the ramp is centred on the band rather than
1799
+ // starting a half-step early.
1800
+ const alpha = 1 - (i + 0.5) / fadeGradient_1.FADE_STEPS;
1801
+ return ((0, jsx_runtime_1.jsx)(react_native_1.View, { pointerEvents: "none", style: {
1802
+ position: 'absolute',
1803
+ ...(towards === 'bottom' ? { left: 0, right: 0, bottom: offset, height: thickness }
1804
+ : towards === 'top' ? { left: 0, right: 0, top: offset, height: thickness }
1805
+ : towards === 'right' ? { top: 0, bottom: 0, right: offset, width: thickness }
1806
+ : { top: 0, bottom: 0, left: offset, width: thickness }),
1807
+ backgroundColor: contract.card,
1808
+ opacity: alpha,
1809
+ } }, i));
1810
+ }) }));
1811
+ };
1812
+ // THE SMALL PRINT, RESOLVED ONCE.
1813
+ //
1814
+ // rewardTerms falls back to the legacy `offer_terms` content question so an
1815
+ // offer made before this keeps its conditions — and that question is still
1816
+ // in the flow, so without the filter below it rendered a SECOND time
1817
+ // underneath, one copy styled as small print and one not. Reported as
1818
+ // duplicated and dimmed. The picture solved the same problem with the same
1819
+ // shape; this needed it too.
1820
+ const cardTerms = (0, survey_spec_1.rewardTerms)(survey);
1821
+ // HALF THE CARD — the picture bleeds to the card's own edges and the words
1822
+ // take the rest. Not a band with a big height: the difference is the bleed,
1823
+ // and it is what makes the image read as the offer rather than as an
1824
+ // illustration of it. `top` turns the row into a column, which is the same
1825
+ // layout rotated and the one a narrow phone usually wants.
1826
+ // HOISTED so a bleeding picture can go above it. On a half card the image
1827
+ // runs to the card's top edge, and a language row rendered before it pushed
1828
+ // the picture down behind a band of empty card — which reads as "only part
1829
+ // of the image is showing" and is invisible in the web preview, because the
1830
+ // web popup has no switcher at all (it takes the browser's language).
1831
+ const langSwitcher = survey.languages && survey.languages.length > 1 ? ((0, jsx_runtime_1.jsx)(react_native_1.View, { style: [
1832
+ styles.langSwitcher,
1833
+ isRtl && { paddingRight: 12, paddingLeft: 48, flexDirection: 'row-reverse' },
1834
+ ], children: survey.languages.map((code) => {
1835
+ const isActive = code === currentLanguage;
1836
+ return ((0, react_1.createElement)(react_native_1.TouchableOpacity, { ...swipeExempt, key: code, onPress: () => setCurrentLanguage(code), activeOpacity: 0.7, style: [
1837
+ styles.langChip,
1838
+ // Light theme: the chip's default white-on-white border/fill
1839
+ // is invisible on the light card — repaint from the palette.
1840
+ isLight && { borderColor: palette.inputBorder, backgroundColor: palette.input },
1841
+ isActive && { borderColor: primaryColor, backgroundColor: `${primaryColor}1A` },
1842
+ ] },
1843
+ (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [
1844
+ styles.langChipText,
1845
+ isLight && { color: palette.textSecondary },
1846
+ isActive && { color: primaryColor },
1847
+ ], children: code.toUpperCase() })));
1848
+ }) })) : null;
1849
+ // A SIDE SPLIT IS A SIDE SPLIT, on the phone too.
1850
+ //
1851
+ // This used to rotate: Left and Right both became a band across the top, on
1852
+ // the reasoning that half of a ~360pt card leaves the headline about 150pt
1853
+ // and that is a column of two-word lines rather than a layout. That is a
1854
+ // fair worry and it was not mine to settle by overruling the control — an
1855
+ // author who picks Left and gets a top band has been told something false
1856
+ // about their own card. Share is theirs to set: at 30% the words still get
1857
+ // ~250pt, and if 70% looks cramped that is visible in the frame and fixable
1858
+ // in one drag.
1859
+ const halfSide = imgLayout === 'half' && (imgD.side === 'left' || imgD.side === 'right');
1860
+ // SHARE IN POINTS, NOT PER CENT. The card's width is decided in JS a few
1861
+ // lines up, so the column can be told what it is instead of being handed a
1862
+ // percentage and left to negotiate with a `flex: 1` sibling — which is a
1863
+ // conversation it was losing: Share at 33 was arriving on screen at 13.
1864
+ const cardWidth = isFullscreen
1865
+ ? SCREEN_WIDTH
1866
+ : maxWidth !== undefined
1867
+ ? Math.min(SCREEN_WIDTH * CARD_VIEWPORT_FRACTION, maxWidth)
1868
+ : Math.min(SCREEN_WIDTH - 40, 420);
1869
+ // SHARE MEASURED THE WAY THE WEB CARD MEASURES IT.
1870
+ //
1871
+ // Web puts the picture in the row INSIDE the card's padding — `flex: 0 0
1872
+ // split%` of the content box — and then bleeds it back out to the card's
1873
+ // edge with a negative margin. So the width you see is a share of the
1874
+ // content, plus the padding it reclaims on the outer side. Taking a share of
1875
+ // the whole card instead makes the phone's picture narrower than the frame's
1876
+ // by exactly one padding, which is a column of text roughly one word narrower
1877
+ // — and a description that fits two lines in the preview wrapping onto three.
1878
+ const halfPadL = contract.cardPaddingLeft ?? contract.cardPadding;
1879
+ const halfPadR = contract.cardPaddingRight ?? contract.cardPadding;
1880
+ const halfOuter = imgD.side === 'right' ? halfPadR : halfPadL;
1881
+ const halfWidth = Math.round(((cardWidth - halfPadL - halfPadR) * imgD.split) / 100) + halfOuter;
1882
+ const cardImageHalf = imgLayout === 'half' ? ((0, jsx_runtime_1.jsxs)(react_native_1.View, { style: {
1883
+ position: 'relative',
1884
+ overflow: 'hidden',
1885
+ // HEIGHT ALONE DECIDES THIS, and Share does not touch it.
1886
+ //
1887
+ // Share used to scale it (split/50), so an authored Height of 225 with
1888
+ // Share at 23 quietly became 103 and the picture came back as a strip.
1889
+ // Nobody would guess that from a control called "Share", and across the
1890
+ // top of the card there is no width to share in the first place.
1891
+ // NO NEGATIVE MARGINS. The web card carries its padding on the content
1892
+ // element, so bleeding to the edge there means pulling BACK by it. This
1893
+ // card carries none — padding lives on the header, the body and the
1894
+ // footer — so the same negative margins pushed the picture 16 to 32
1895
+ // points outside the card on three sides, where `overflow: hidden`
1896
+ // clipped it. The picture is already at the card's edge here.
1897
+ ...(halfSide
1898
+ // Beside the words: Share is a width, and the height is whatever the
1899
+ // words next to it come to — `stretch`, exactly as the web card's
1900
+ // `align-self: stretch` does it.
1901
+ ? { width: halfWidth,
1902
+ // It must not be renegotiated either: a flex sibling may not
1903
+ // shrink it, and nothing may grow it.
1904
+ flexGrow: 0, flexShrink: 0, alignSelf: 'stretch' }
1905
+ // Across the top: full width, and Height alone decides the depth.
1906
+ : { width: '100%', height: contract.imageHeight, marginBottom: 12 }),
1907
+ }, children: [halfSide ? ((0, jsx_runtime_1.jsx)(react_native_1.View, { style: react_native_1.StyleSheet.absoluteFill, children: (0, jsx_runtime_1.jsx)(FocalImage, { uri: cardImageQ?.imageUrl, alt: cardImageQ?.alt, fit: imgD.fit, focusX: imgD.focusX, focusY: imgD.focusY }) })) : ((0, jsx_runtime_1.jsx)(FocalImage, { uri: cardImageQ?.imageUrl, alt: cardImageQ?.alt, fit: imgD.fit, focusX: imgD.focusX, focusY: imgD.focusY })), imgD.scrim ? ((0, jsx_runtime_1.jsx)(react_native_1.View, { pointerEvents: "none", style: [react_native_1.StyleSheet.absoluteFill,
1908
+ { backgroundColor: contract.card, opacity: imgD.scrim / 100 }] })) : null, fadeSteps(halfSide ? (imgD.side === 'left' ? 'right' : 'left') : 'bottom')] })) : null;
1909
+ // BEHIND EVERYTHING, WITH THE CARD'S OWN COLOUR PAINTED BACK OVER IT.
1910
+ //
1911
+ // Absolute children drawn FIRST, because React Native paints siblings in
1912
+ // order and has no dependable negative z-index — so "behind" is a position
1913
+ // in the tree, not a number. Without the scrim the author has no control that
1914
+ // would make white text readable on a pale photograph, so it is authored
1915
+ // rather than assumed, and defaults to something visible.
1916
+ const cardImageBack = imgLayout === 'background' ? ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)(react_native_1.Image, { source: { uri: cardImageQ?.imageUrl }, style: react_native_1.StyleSheet.absoluteFill, resizeMode: "cover", accessible: false, importantForAccessibility: "no" }), (0, jsx_runtime_1.jsx)(react_native_1.View, { pointerEvents: "none", style: [
1917
+ react_native_1.StyleSheet.absoluteFill,
1918
+ { backgroundColor: contract.card, opacity: imgD.scrim / 100 },
1919
+ ] })] })) : null;
1920
+ // THE BUTTON SITS BESIDE THE TEXT WHEN THE AUTHOR SAID SO — ON A PHONE TOO.
1921
+ //
1922
+ // `buttonAlign` is the notification's "Where it sits": 'full' is below the
1923
+ // content, anything else is next to it. Mobile ignored it completely and
1924
+ // always rendered the action down in the question list, so the control moved
1925
+ // the web preview and nothing on the device.
1926
+ //
1927
+ // No fall-back to stacking when the label is long: beside means beside, and
1928
+ // the text takes whatever width is left. An author who wants the full width
1929
+ // for their headline has the other option to choose.
1930
+ const notifyBeside = interactionKind === 'notify'
1931
+ && String(display?.buttonAlign || 'right') !== 'full';
1932
+ const notifyActionQ = notifyBeside
1933
+ ? currentQuestions.find((q) => String(q?.type ?? '').toLowerCase() === 'implied')
1934
+ : undefined;
1935
+ // THE CARD DOES THE BUTTON'S WORK — when there is no button.
1936
+ //
1937
+ // Web has had this for a while and the phone never did, so hiding the action
1938
+ // button here removed the action entirely: no button, no card press, and on a
1939
+ // notification the ✕ is off too. Reported as a notification nothing could
1940
+ // dismiss, which is exactly what it was.
1941
+ //
1942
+ // Both halves of the rule come from the spec, so the two renderers cannot
1943
+ // disagree about when the card takes over: cardIsClickable insists on exactly
1944
+ // ONE action — the notification shape, which excludes every consent choice,
1945
+ // because a stray tap anywhere on a two-action takeover must never
1946
+ // manufacture agreement.
1947
+ const cardImpliedQ = currentQuestions.find((q) => String(q?.type ?? '').toLowerCase() === 'implied');
1948
+ const cardImpliedList = cardImpliedQ
1949
+ ? (() => {
1950
+ const acts = (0, survey_spec_1.impliedActions)(cardImpliedQ, (t) => (0, interpolate_1.interpolatePlain)(t, evalCtx));
1951
+ return acts.length ? acts : [{ value: 'acknowledged', label: 'Continue' }];
1952
+ })()
1953
+ : [];
1954
+ const cardTakesPress = !!cardImpliedQ
1955
+ && !(0, survey_spec_1.showActionButton)(display, cardImpliedList)
1956
+ && (0, survey_spec_1.cardIsClickable)(display, cardImpliedList);
1957
+ const cardImpliedKey = String(cardImpliedQ?.name
1958
+ ?? cardImpliedQ?.id ?? '');
1959
+ // AN EMPTY FOOTER IS STILL A FOOTER — 16pt on top and a card padding below.
1960
+ //
1961
+ // All three of its children can be suppressed at once, and on a notification
1962
+ // they are: no voice mic, no Back on a single page, and the primary hidden
1963
+ // because an implied step's own actions ARE its Next. What was left was a
1964
+ // padded empty row, which is the blank strip under a notification on the
1965
+ // phone that the preview does not have — web renders no footer at all there.
1966
+ //
1967
+ // Asked as "has anything to draw", not "is this a notification", so the same
1968
+ // answer holds for any step that happens to suppress all three.
1969
+ const footerHasContent = showVoiceMic
1970
+ || (display?.showBack !== false && (history.length > 0 || currentPage > 0))
1971
+ || impliedNow.length === 0;
1972
+ // The room the ✕ needs, on the line it actually overlaps. Sized from the
1973
+ // button itself, so shrinking it gives the headline the space back.
1974
+ // WHETHER THE ✕ IS DRAWN, asked once and used twice — by the mark itself and
1975
+ // by the gesture that replaces it.
1976
+ //
1977
+ // This read `showClose !== false` in both places, so an unset notification
1978
+ // kept its ✕ here and lost it in the preview, and — because this display is a
1979
+ // plain merge that leaves the value undefined — never enabled the swipe
1980
+ // either. One authored survey, dismissable two different ways depending on
1981
+ // which screen you were looking at.
1982
+ const closeShown = (0, survey_spec_1.showsClose)(display, interactionKind, survey.delivery);
1983
+ const closeReserve = !closeShown ? null : {
1984
+ [isRtl ? 'paddingLeft' : 'paddingRight']: contract.closeSize + contract.closeInset + 8,
1985
+ };
1986
+ // Whether the BODY may scroll is a different question from whether a
1987
+ // thumbnail was composed: see surfaceScrolls.
1988
+ const bodyScrolls = surfaceScrolls(interactionKind);
1989
+ // AND WHEN IT DOES NOT SCROLL, IT IS NOT A SCROLLVIEW.
1990
+ //
1991
+ // `scrollEnabled={false}` was not enough, and on iOS that is not a cosmetic
1992
+ // distinction: the prop only tells a real UIScrollView to stop scrolling, it
1993
+ // does not take it out of the touch path. A UIScrollView cancels touches it
1994
+ // inherits from inert content while leaving a Touchable's own touch alone,
1995
+ // and that asymmetry is exactly the reported "it only swipes while I am
1996
+ // pressing the button" — the button is the one child that owns its touch, so
1997
+ // the flick survived there and nowhere else on the card.
1998
+ //
1999
+ // Rendering a plain View removes the competitor rather than asking it to
2000
+ // stand down. styles.body carries no flex, so the two lay out identically.
2001
+ const BodyContainer = (bodyScrolls ? react_native_1.ScrollView : react_native_1.View);
1430
2002
  // A NOTIFICATION'S TYPE SITS ONE SIZE DOWN from a survey's — web says so
1431
2003
  // explicitly (notifyFsHeading/notifyFsDesc, defaulting to 15/13), and mobile
1432
2004
  // had no equivalent: it used the survey title and a hardcoded 14 description,
@@ -1434,6 +2006,9 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
1434
2006
  // type there. An authored size still wins, on both.
1435
2007
  const _authoredStyle = stylePlatformSlice(survey.branding?.style);
1436
2008
  const _num = (v) => (typeof v === 'number' && v > 0 ? v : undefined);
2009
+ // Only an AUTHORED border draws — see styles.impliedPrimary.
2010
+ const authoredBtnBorder = typeof _authoredStyle.primaryBorder === 'string'
2011
+ && _authoredStyle.primaryBorder.trim() ? String(_authoredStyle.primaryBorder).trim() : null;
1437
2012
  // SHARED VALUE, PROPORTIONATE RENDERING.
1438
2013
  //
1439
2014
  // `fontSizeHeading` is stored once for every surface, so a size chosen while
@@ -1449,7 +2024,7 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
1449
2024
  const REFERENCE_CARD_PT = 480;
1450
2025
  const notifyCardW = Math.min(SCREEN_WIDTH * CARD_VIEWPORT_FRACTION, typeof maxWidth === 'number' ? maxWidth : SCREEN_WIDTH);
1451
2026
  const authoredHeadingPt = _num(_authoredStyle.fontSizeHeading);
1452
- const notifyTitleFS = isNotifyCard
2027
+ const notifyTitleFS = isNotifyBanner
1453
2028
  ? {
1454
2029
  fontSize: authoredHeadingPt != null
1455
2030
  // Floored so a very narrow card cannot shrink the headline below the
@@ -1458,8 +2033,16 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
1458
2033
  : 15,
1459
2034
  }
1460
2035
  : null;
1461
- const notifyDescFS = isNotifyCard
1462
- ? { fontSize: _num(_authoredStyle.fontSizeDescription) ?? 13, lineHeight: 18 } : null;
2036
+ const notifyDescFS = isNotifyBanner
2037
+ ? (() => {
2038
+ // The banner's own type size, and a line height DERIVED from it rather
2039
+ // than written in — otherwise the Line height control works on a survey
2040
+ // and silently does nothing on the notification, which is the surface
2041
+ // most likely to be tuned.
2042
+ const fs = _num(_authoredStyle.fontSizeDescription) ?? 13;
2043
+ return { fontSize: fs, lineHeight: Math.round(fs * contract.lineHeightBody) };
2044
+ })()
2045
+ : null;
1463
2046
  const rawPos = display?.position;
1464
2047
  const placement = rawPos === 'top' ? 'top'
1465
2048
  : rawPos === 'bottom' ? 'bottom'
@@ -1490,7 +2073,7 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
1490
2073
  const hasPopupY = !isNaN(popupY);
1491
2074
  const effPlacement = hasPopupY ? 'top' : placement;
1492
2075
  // Measured card height; the authored height is the first-frame stand-in.
1493
- const [measuredCardH, setMeasuredCardH] = (0, react_1.useState)(0);
2076
+ const [measuredCardH, setMeasuredCardH] = (0, react_2.useState)(0);
1494
2077
  const authoredH = Number.parseFloat(String(display?.height ?? ''));
1495
2078
  const estCardH = measuredCardH || (Number.isFinite(authoredH) && authoredH > 0 ? authoredH : 220);
1496
2079
  const overlayPlacement = {
@@ -1515,10 +2098,31 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
1515
2098
  // is what the card actually uses.
1516
2099
  const needsTopSlide = animationType === 'slide'
1517
2100
  && ((isSheet && placement === 'top') || (!isDrawer && !isFullscreen && effPlacement === 'top'));
1518
- const modalAnimation = needsTopSlide ? 'none' : animationType;
1519
- const enterAnim = (0, react_1.useRef)(new react_native_1.Animated.Value(1)).current;
1520
- (0, react_1.useEffect)(() => {
1521
- if (!needsTopSlide)
2101
+ // WHERE IT ENTERS FROM, and how long it takes.
2102
+ //
2103
+ // `animationFrom` names the EDGE IT COMES FROM; the travel is its opposite,
2104
+ // so entering from the left means moving right. 'auto' keeps the old
2105
+ // behaviour exactly — its own nearest edge — so no existing survey moves.
2106
+ //
2107
+ // RN's <Modal animationType="slide"> only knows bottom-up and has no duration,
2108
+ // so anything the author actually chose has to be animated here instead. We
2109
+ // take over only when they HAVE chosen: an explicit edge, an explicit
2110
+ // duration, or the top case that already needed its own animation.
2111
+ const _disp = display;
2112
+ const animFromRaw = String(_disp?.animationFrom || 'auto').toLowerCase();
2113
+ const animEdgeAuthored = ['top', 'right', 'bottom', 'left'].includes(animFromRaw);
2114
+ const _msRaw = Number(_disp?.animationDuration);
2115
+ const animMsAuthored = Number.isFinite(_msRaw) && _msRaw > 0;
2116
+ const animMs = animMsAuthored ? Math.max(80, Math.min(1000, Math.round(_msRaw))) : 280;
2117
+ const slideEdge = animEdgeAuthored
2118
+ ? animFromRaw
2119
+ : (effPlacement === 'top' ? 'top' : 'bottom');
2120
+ const customSlide = animationType === 'slide'
2121
+ && (needsTopSlide || animEdgeAuthored || animMsAuthored);
2122
+ const modalAnimation = customSlide ? 'none' : animationType;
2123
+ const enterAnim = (0, react_2.useRef)(new react_native_1.Animated.Value(1)).current;
2124
+ (0, react_2.useEffect)(() => {
2125
+ if (!customSlide)
1522
2126
  return;
1523
2127
  if (!visible) {
1524
2128
  enterAnim.setValue(0);
@@ -1527,13 +2131,13 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
1527
2131
  enterAnim.setValue(0);
1528
2132
  react_native_1.Animated.timing(enterAnim, {
1529
2133
  toValue: 1,
1530
- duration: 280,
2134
+ duration: animMs,
1531
2135
  easing: react_native_1.Easing.out(react_native_1.Easing.cubic),
1532
2136
  // Transform and opacity only, so this runs off the JS thread and does not
1533
2137
  // stutter while the survey's first page is still laying out.
1534
2138
  useNativeDriver: true,
1535
2139
  }).start();
1536
- }, [visible, needsTopSlide, enterAnim]);
2140
+ }, [visible, customSlide, animMs, enterAnim]);
1537
2141
  // ── Swipe to dismiss ────────────────────────────────────────────────
1538
2142
  //
1539
2143
  // A push notification goes away when you flick it. The mobile display slice
@@ -1543,100 +2147,124 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
1543
2147
  // canSwipeToDismiss requires a popup and vetoes `mustShow`, which is what
1544
2148
  // keeps a compliance takeover — showClose:false for the OPPOSITE reason —
1545
2149
  // from ever becoming flickable.
1546
- const swipeable = (0, survey_spec_1.canSwipeToDismiss)(display, survey.delivery);
1547
- // TEMP-DIAG
1548
- if (__DEV__) {
1549
- // eslint-disable-next-line no-console
1550
- console.log('[CXP-SW]', JSON.stringify({
1551
- swipeable,
1552
- mode: display?.mode, showClose: display?.showClose,
1553
- mustShow: survey.delivery?.mustShow,
1554
- isNotifyCard,
1555
- }));
1556
- }
1557
- const swipeXY = (0, react_1.useRef)(new react_native_1.Animated.ValueXY({ x: 0, y: 0 })).current;
1558
- const swipeResponder = (0, react_1.useMemo)(() => react_native_1.PanResponder.create({
1559
- onStartShouldSetPanResponder: () => false,
1560
- // Claim the gesture only once it is clearly a drag, so a tap on the action
1561
- // button still presses the button.
1562
- //
1563
- // CAPTURE, not bubble. The card's content sits in a ScrollView, and a
1564
- // ScrollView claims a vertical drag first — so the flick that is supposed
1565
- // to dismiss a notification was being consumed as a scroll of content that
1566
- // does not even overflow, and the banner could not be swiped away at all.
1567
- // The 8px threshold still protects the button: a tap never reaches here.
1568
- // CAPTURE, so the flick works from ANYWHERE on the card.
1569
- //
1570
- // On the bubble phase the card is only asked once some descendant has
1571
- // already become the responder — which on this card means the action
1572
- // button and nothing else. Touch the title, the image or the body and no
1573
- // view claims the touch, so the card was never consulted and the banner
1574
- // could only be dragged by its button. Capture asks the card first, on
1575
- // every touch, wherever it landed.
1576
- //
1577
- // The 8px threshold is what keeps the button a button: a press that does
1578
- // not travel is never claimed here, so it reaches the button untouched. A
1579
- // press that DOES travel drags the card, which is what a notification is
1580
- // expected to do — the whole surface is the handle.
1581
- onMoveShouldSetPanResponderCapture: (_e, g) => {
1582
- const claim = swipeable && (Math.abs(g.dx) > 8 || Math.abs(g.dy) > 8);
1583
- if (__DEV__) { /* eslint-disable-next-line no-console */
1584
- console.log('[CXP-SW] move', JSON.stringify({ dx: Math.round(g.dx), dy: Math.round(g.dy), claim }));
1585
- }
1586
- return claim;
1587
- },
1588
- // ONCE THE CARD HAS THE FLICK, IT KEEPS IT.
1589
- //
1590
- // The default is to grant a termination request, so a view underneath could
1591
- // take the gesture back mid-drag: the card followed the finger and then
1592
- // simply stopped, with onPanResponderRelease never firing and the dismissal
1593
- // never running. That is the reported "it moves but it doesn't swipe away".
1594
- onPanResponderTerminationRequest: () => false,
1595
- onPanResponderTerminate: (_e, g) => {
1596
- if (__DEV__) { /* eslint-disable-next-line no-console */
1597
- console.log('[CXP-SW] TERMINATED mid-gesture', JSON.stringify({ dx: Math.round(g.dx), dy: Math.round(g.dy) }));
1598
- }
1599
- react_native_1.Animated.spring(swipeXY, { toValue: { x: 0, y: 0 }, useNativeDriver: true, bounciness: 6 }).start();
1600
- },
1601
- onPanResponderMove: (_e, g) => {
1602
- const horizontal = Math.abs(g.dx) > Math.abs(g.dy);
1603
- // A top banner travels up, never down — dragging it down is a pull.
1604
- const dy = placement === 'top' ? Math.min(0, g.dy) : placement === 'bottom' ? Math.max(0, g.dy) : g.dy;
1605
- swipeXY.setValue(horizontal ? { x: g.dx, y: 0 } : { x: 0, y: dy });
1606
- },
1607
- onPanResponderRelease: (_e, g) => {
1608
- if (__DEV__) { /* eslint-disable-next-line no-console */
1609
- console.log('[CXP-SW] RELEASE', JSON.stringify({ dx: Math.round(g.dx), dy: Math.round(g.dy), vx: g.vx.toFixed(2), vy: g.vy.toFixed(2) }));
1610
- }
1611
- const horizontal = Math.abs(g.dx) > Math.abs(g.dy);
1612
- const moved = horizontal ? g.dx : g.dy;
1613
- const fast = Math.abs(horizontal ? g.vx : g.vy) > 0.5;
1614
- if (Math.abs(moved) >= 56 || fast) {
1615
- if (__DEV__) { /* eslint-disable-next-line no-console */
1616
- console.log('[CXP-SW] threshold PASSED — animating out');
1617
- }
1618
- const to = moved < 0 ? -SCREEN_HEIGHT : SCREEN_HEIGHT;
1619
- react_native_1.Animated.timing(swipeXY, {
1620
- toValue: horizontal ? { x: moved < 0 ? -SCREEN_WIDTH : SCREEN_WIDTH, y: 0 } : { x: 0, y: to },
1621
- duration: 180, useNativeDriver: true,
1622
- }).start(({ finished }) => {
1623
- if (__DEV__) { /* eslint-disable-next-line no-console */
1624
- console.log('[CXP-SW] animation done, finished=' + finished + ' — calling dismiss');
1625
- }
1626
- swipeXY.setValue({ x: 0, y: 0 });
1627
- handleDismissRef.current();
1628
- });
1629
- return;
2150
+ // RESOLVED, not raw: canSwipeToDismiss asks for `showClose === false`, and an
2151
+ // unset notification is exactly the case that has to reach it as false.
2152
+ const swipeable = (0, survey_spec_1.canSwipeToDismiss)({ ...(display || {}), showClose: !closeShown }, survey.delivery);
2153
+ // ── TEMPORARY: swipe diagnosis ──────────────────────────────────────────
2154
+ //
2155
+ // The report ("it only swipes while I press the button") has survived three
2156
+ // fixes, each aimed at a different candidate. These five lines separate the
2157
+ // candidates in ONE pass on a device, because the order they appear in is
2158
+ // itself the answer:
2159
+ //
2160
+ // no `touch` at all → the gesture never reaches the card; the problem
2161
+ // is hit-testing, not the responder
2162
+ // `touch` but no `ask` → the card is not in the negotiation
2163
+ // `ask claim=false` → dx/dy never crossed the claim threshold
2164
+ // `ask claim=true`, no `grant` → something else won the responder
2165
+ // `grant` then `terminate` → it was taken back mid-drag
2166
+ // `grant` then `release` → read the numbers; it is the threshold
2167
+ //
2168
+ // Remove once the cause is known.
2169
+ const swipeXY = (0, react_2.useRef)(new react_native_1.Animated.ValueXY({ x: 0, y: 0 })).current;
2170
+ // RAW TOUCH, NOT PanResponder.
2171
+ //
2172
+ // PanResponder was the idiomatic choice and it does not work here. Measured
2173
+ // on the device: `onTouchStart` fires on this card for a touch anywhere on
2174
+ // it, and `onMoveShouldSetPanResponderCapture` is then never called even
2175
+ // once — the card receives the touch and is never asked for the gesture. The
2176
+ // one place the gesture did work was the action button, because a Touchable
2177
+ // claims the responder itself and the negotiation that follows does reach
2178
+ // this card.
2179
+ //
2180
+ // So the swipe is driven by the events we can prove arrive. Touch events
2181
+ // bubble from whatever was touched up to this card independently of the
2182
+ // responder system, which is exactly the property the gesture needs: the
2183
+ // whole surface becomes the handle without anything having to win a
2184
+ // negotiation first.
2185
+ //
2186
+ // Nothing here claims the responder, so a press on the action button is
2187
+ // untouched — it presses. A press that travels moves the card as well, and
2188
+ // past the dismissal threshold the card leaves and takes the press with it.
2189
+ const onCardTouchStart = (e) => {
2190
+ engagedRef.current = true;
2191
+ const n = e.nativeEvent;
2192
+ // TRACKED FOR A TAP TOO, not only for a drag. This returned early unless
2193
+ // the card could be swiped, so on a card that only needed to be PRESSED
2194
+ // nothing was ever recorded and the press could not be recognised.
2195
+ if (!swipeable && !cardTakesPress)
2196
+ return;
2197
+ // A control already owns this touch — it is a press, not a drag.
2198
+ if (pressingControl.current)
2199
+ return;
2200
+ touchSwipe.current = { x0: n.pageX, y0: n.pageY, t0: Date.now(), moved: false };
2201
+ };
2202
+ const onCardTouchMove = (e) => {
2203
+ const st = touchSwipe.current;
2204
+ if (!st)
2205
+ return;
2206
+ const n = e.nativeEvent;
2207
+ const dx = n.pageX - st.x0;
2208
+ const dy = n.pageY - st.y0;
2209
+ // Below the claim threshold this is still a press, so the card stays put.
2210
+ if (!st.moved && !shouldClaimSwipe(true, dx, dy))
2211
+ return;
2212
+ // Travelled far enough to be a drag. On a card that cannot be swiped that
2213
+ // only means "this was not a tap" — the card must not follow the finger.
2214
+ st.moved = true;
2215
+ if (!swipeable)
2216
+ return;
2217
+ swipeXY.setValue(swipeOffset(placement, dx, dy));
2218
+ };
2219
+ const onCardTouchEnd = (e) => {
2220
+ const st = touchSwipe.current;
2221
+ touchSwipe.current = null;
2222
+ pressingControl.current = false;
2223
+ if (!st)
2224
+ return;
2225
+ // A TAP ON THE CARD IS THE BUTTON'S PRESS. It never travelled, so it was
2226
+ // never a drag — and the threshold that decides is the same one the swipe
2227
+ // uses, which is what keeps a deliberate flick from also counting as a tap.
2228
+ if (!st.moved) {
2229
+ if (cardTakesPress && cardImpliedList.length) {
2230
+ pressImplied(cardImpliedKey, cardImpliedList[0]);
1630
2231
  }
1631
- react_native_1.Animated.spring(swipeXY, { toValue: { x: 0, y: 0 }, useNativeDriver: true, bounciness: 6 }).start();
1632
- },
1633
- }), [swipeable, placement, swipeXY]);
2232
+ return;
2233
+ }
2234
+ if (!swipeable)
2235
+ return;
2236
+ const n = e.nativeEvent;
2237
+ const dx = n.pageX - st.x0;
2238
+ const dy = n.pageY - st.y0;
2239
+ // px/ms, the same unit PanResponder reports, so the tuned threshold carries
2240
+ // over unchanged. Floored so a very fast flick cannot divide by ~0.
2241
+ const dt = Math.max(16, Date.now() - st.t0);
2242
+ const { dismiss, horizontal, moved } = swipeRelease({ dx, dy, vx: dx / dt, vy: dy / dt });
2243
+ if (dismiss) {
2244
+ react_native_1.Animated.timing(swipeXY, {
2245
+ toValue: horizontal
2246
+ ? { x: moved < 0 ? -SCREEN_WIDTH : SCREEN_WIDTH, y: 0 }
2247
+ : { x: 0, y: moved < 0 ? -SCREEN_HEIGHT : SCREEN_HEIGHT },
2248
+ duration: 180, useNativeDriver: true,
2249
+ }).start(() => { swipeXY.setValue({ x: 0, y: 0 }); handleDismissRef.current('swipe'); });
2250
+ return;
2251
+ }
2252
+ react_native_1.Animated.spring(swipeXY, { toValue: { x: 0, y: 0 }, useNativeDriver: true, bounciness: 6 }).start();
2253
+ };
2254
+ const onCardTouchCancel = () => {
2255
+ const st = touchSwipe.current;
2256
+ touchSwipe.current = null;
2257
+ pressingControl.current = false;
2258
+ if (!st || !st.moved)
2259
+ return;
2260
+ react_native_1.Animated.spring(swipeXY, { toValue: { x: 0, y: 0 }, useNativeDriver: true, bounciness: 6 }).start();
2261
+ };
1634
2262
  // Auto-dismiss. Same rule as web (autoDismissMs vetoes fullscreen and
1635
2263
  // mustShow, clamps to 2s). Cancelled for good once the respondent touches
1636
2264
  // anything — a timer that fires after someone has started answering would
1637
2265
  // pull the survey out from under them.
1638
2266
  // Which ending this interaction gets — see the render block below.
1639
- const endingKind = (0, react_1.useMemo)(() => {
2267
+ const endingKind = (0, react_2.useMemo)(() => {
1640
2268
  const sv = survey;
1641
2269
  const qs = (survey.rawQuestions ?? []);
1642
2270
  const acts = (0, survey_spec_1.impliedStepActions)(qs) || [];
@@ -1652,14 +2280,16 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
1652
2280
  });
1653
2281
  }, [survey, responses]);
1654
2282
  const dismissMs = (0, survey_spec_1.autoDismissMs)(display, survey.delivery);
1655
- const engagedRef = (0, react_1.useRef)(false);
1656
- (0, react_1.useEffect)(() => {
2283
+ const engagedRef = (0, react_2.useRef)(false);
2284
+ (0, react_2.useEffect)(() => {
1657
2285
  if (!visible || !dismissMs)
1658
2286
  return;
1659
2287
  engagedRef.current = false;
1660
2288
  const t = setTimeout(() => {
2289
+ // NOBODY HOME IS NOT A DISMISSAL — the card left on a timer. Named so
2290
+ // the cadence does not spend an attempt on a showing nobody saw.
1661
2291
  if (!engagedRef.current)
1662
- handleDismissRef.current();
2292
+ handleDismissRef.current('auto');
1663
2293
  }, dismissMs);
1664
2294
  return () => clearTimeout(t);
1665
2295
  }, [visible, dismissMs]);
@@ -1668,22 +2298,30 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
1668
2298
  // REPLACES an earlier one rather than merging. That was harmless while only
1669
2299
  // sheets animated themselves, but a notification is both top-placed and
1670
2300
  // swipeable, so the entrance translate would have been silently dropped.
1671
- const enterStyle = needsTopSlide
1672
- ? {
1673
- opacity: enterAnim,
1674
- transform: [
1675
- {
1676
- translateY: enterAnim.interpolate({
1677
- inputRange: [0, 1],
1678
- // Its own height is not known before layout, so it travels a screen
1679
- // third — far enough to read as entering from off-screen, short
1680
- // enough that the easing still lands crisply.
1681
- outputRange: [-SCREEN_HEIGHT / 3, 0],
1682
- }),
1683
- },
1684
- ...(swipeable ? swipeXY.getTranslateTransform() : []),
1685
- ],
1686
- }
2301
+ const enterStyle = customSlide
2302
+ ? (() => {
2303
+ // Its own size is not known before layout, so it travels a screen third
2304
+ // (or a width third sideways) — far enough to read as entering from
2305
+ // off-screen, short enough that the easing still lands crisply.
2306
+ const dY = SCREEN_HEIGHT / 3;
2307
+ const dX = SCREEN_WIDTH / 3;
2308
+ const OFFSET = {
2309
+ top: { x: 0, y: -dY }, bottom: { x: 0, y: dY },
2310
+ left: { x: -dX, y: 0 }, right: { x: dX, y: 0 },
2311
+ };
2312
+ const off = OFFSET[slideEdge] || OFFSET.bottom;
2313
+ const travel = (v) => enterAnim.interpolate({ inputRange: [0, 1], outputRange: [v, 0] });
2314
+ return {
2315
+ opacity: enterAnim,
2316
+ transform: [
2317
+ ...(off.y !== 0 ? [{ translateY: travel(off.y) }] : []),
2318
+ ...(off.x !== 0 ? [{ translateX: travel(off.x) }] : []),
2319
+ // The swipe transform rides LAST and on the same array — a second
2320
+ // `transform` entry would replace this one rather than compose.
2321
+ ...(swipeable ? swipeXY.getTranslateTransform() : []),
2322
+ ],
2323
+ };
2324
+ })()
1687
2325
  : null;
1688
2326
  const sheetRadius = cornerRadius !== undefined ? cornerRadius : 16;
1689
2327
  const sheetStyle = isSheet ? {
@@ -1854,9 +2492,9 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
1854
2492
  * At zero it stops rendering rather than sitting at 0:00 beside a button that
1855
2493
  * no longer works.
1856
2494
  */
1857
- const [nowTs, setNowTs] = (0, react_1.useState)(() => Date.now());
2495
+ const [nowTs, setNowTs] = (0, react_2.useState)(() => Date.now());
1858
2496
  const countdown = (0, survey_spec_1.deadlineCountdown)(survey, display, nowTs);
1859
- (0, react_1.useEffect)(() => {
2497
+ (0, react_2.useEffect)(() => {
1860
2498
  if (!countdown)
1861
2499
  return undefined;
1862
2500
  // Ticking every second only while the TEXT CONTAINS SECONDS, which
@@ -1877,7 +2515,19 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
1877
2515
  styles.progressBarFill,
1878
2516
  { width: `${((currentPage + 1) / totalPages) * 100}%`, backgroundColor: primaryColor },
1879
2517
  ] }) })] })) : null;
1880
- return ((0, jsx_runtime_1.jsx)(react_native_1.Modal, { visible: visible, transparent: true, animationType: modalAnimation, onRequestClose: handleDismiss, children: (0, jsx_runtime_1.jsxs)(react_native_1.KeyboardAvoidingView, { pointerEvents: isPopupOverlay ? 'box-none' : 'auto', style: [
2518
+ return ((0, jsx_runtime_1.jsx)(react_native_1.Modal, { visible: visible, transparent: true, animationType: modalAnimation,
2519
+ // Android's back gesture. Wrapped rather than passed: the handler now
2520
+ // takes a reason and this prop hands it a native event, which would
2521
+ // arrive as the reason and be recorded as one.
2522
+ onRequestClose: () => handleDismiss('close'), children: (0, jsx_runtime_1.jsxs)(react_native_1.KeyboardAvoidingView
2523
+ // The OVERLAY, not the card — a screen-shaped image with the card in
2524
+ // its real position, rather than a card-shaped one the replay player
2525
+ // would stretch across the whole device frame.
2526
+ , {
2527
+ // The OVERLAY, not the card — a screen-shaped image with the card in
2528
+ // its real position, rather than a card-shaped one the replay player
2529
+ // would stretch across the whole device frame.
2530
+ ref: (v) => { cardViewRef.current = v; }, pointerEvents: isPopupOverlay ? 'box-none' : 'auto', style: [
1881
2531
  styles.overlay,
1882
2532
  // Transparent for a popup — the app behind it is not being taken over.
1883
2533
  isPopupOverlay && { backgroundColor: 'transparent' },
@@ -1888,36 +2538,49 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
1888
2538
  // A sheet sits flush against its edge, so the overlay loses its gutter.
1889
2539
  isSheet && { paddingHorizontal: 0, paddingTop: 0, paddingBottom: 0 },
1890
2540
  isFullscreen && styles.overlayFullscreen,
1891
- ], behavior: react_native_1.Platform.OS === 'ios' ? 'padding' : 'height', children: [(0, jsx_runtime_1.jsxs)(react_native_1.Animated.View, { onTouchStart: () => { engagedRef.current = true; },
2541
+ ], behavior: react_native_1.Platform.OS === 'ios' ? 'padding' : 'height', children: [(0, jsx_runtime_1.jsxs)(react_native_1.Animated.View
2542
+ // Explicit, because the overlay above is `box-none` on a popup so the
2543
+ // host app keeps its touches. That makes hit-testing on this subtree
2544
+ // worth stating rather than inheriting: the card must be a target, or
2545
+ // a flick that starts on the title falls straight through it.
2546
+ , {
2547
+ // Explicit, because the overlay above is `box-none` on a popup so the
2548
+ // host app keeps its touches. That makes hit-testing on this subtree
2549
+ // worth stating rather than inheriting: the card must be a target, or
2550
+ // a flick that starts on the title falls straight through it.
2551
+ pointerEvents: "auto", onTouchStart: onCardTouchStart, onTouchMove: onCardTouchMove, onTouchEnd: onCardTouchEnd, onTouchCancel: onCardTouchCancel,
1892
2552
  // Feeds the authored-coordinate maths above. Only tracked when a
1893
2553
  // coordinate is actually in play, and only on a real change, so the
1894
2554
  // common case costs nothing and this cannot loop.
1895
2555
  onLayout: hasPopupY ? (e) => {
1896
2556
  const h = Math.round(e.nativeEvent.layout.height);
1897
2557
  setMeasuredCardH((prev) => (Math.abs(prev - h) > 1 ? h : prev));
1898
- } : undefined, ...(swipeable ? swipeResponder.panHandlers : {}), style: [
2558
+ } : undefined, style: [
1899
2559
  styles.card,
1900
2560
  themeCard,
1901
2561
  enterStyle,
1902
- swipeable && !needsTopSlide && { transform: swipeXY.getTranslateTransform() },
2562
+ swipeable && !customSlide && { transform: swipeXY.getTranslateTransform() },
1903
2563
  maxWidth !== undefined && !isFullscreen && { width: Math.min(SCREEN_WIDTH * CARD_VIEWPORT_FRACTION, maxWidth), maxWidth },
1904
2564
  cornerRadius !== undefined && !isFullscreen && { borderRadius: cornerRadius },
1905
2565
  // cardFullscreen fills; an explicit content height/width below OVERRIDES
1906
2566
  // the fill so a fullscreen survey can be contained to that size.
1907
2567
  isFullscreen && styles.cardFullscreen,
2568
+ // The background picture is a child, so the card must clip it or it
2569
+ // squares off the corners the author rounded.
2570
+ imgLayout === 'background' && { overflow: 'hidden' },
1908
2571
  sheetStyle,
1909
2572
  heightStyle,
1910
2573
  widthStyle,
1911
2574
  safePad,
1912
- ], children: [display?.showClose !== false && ((0, jsx_runtime_1.jsx)(react_native_1.TouchableOpacity, { style: [
2575
+ ], children: [cardImageBack, closeShown && ((0, jsx_runtime_1.jsx)(react_native_1.TouchableOpacity, { ...swipeExempt, style: [
1913
2576
  styles.closeButton,
1914
- isRtl ? { left: 12 } : { right: 12 },
1915
- ], onPress: handleDismiss, activeOpacity: 0.7, children: (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [styles.closeText, themeTextSecondary], children: "\u2715" }) })), showThankYou && endingKind !== 'thanks' ? (() => {
2577
+ isRtl ? { left: contract.closeInset } : { right: contract.closeInset },
2578
+ ], onPress: () => handleDismiss('close'), activeOpacity: 0.7, children: (0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.closeGlyph, children: [(0, jsx_runtime_1.jsx)(react_native_1.View, { style: [styles.closeBar, { transform: [{ rotate: '45deg' }] }] }), (0, jsx_runtime_1.jsx)(react_native_1.View, { style: [styles.closeBar, { transform: [{ rotate: '-45deg' }] }] })] }) })), showThankYou && endingKind !== 'thanks' && (0, survey_spec_1.showsConfirmation)(survey) ? (() => {
1916
2579
  const rw = (0, survey_spec_1.rewardToShow)(survey);
1917
2580
  const comp = survey.compliance;
1918
2581
  if (endingKind === 'fulfil' && rw) {
1919
2582
  const exp = rw.expiresAt ? new Date(rw.expiresAt) : null;
1920
- return ((0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.thankYou, children: [(0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [styles.thankYouTitle, fontStyle, themeTextPrimary], children: "It\u2019s yours" }), (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [styles.thankYouSub, fontStyle, themeTextSecondary], children: "Use this at checkout." }), (0, jsx_runtime_1.jsx)(react_native_1.Text, { selectable: true, style: [styles.rewardCode, fontStyle, themeTextPrimary], children: rw.value }), !!rw.whereValid && ((0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [styles.thankYouSub, fontStyle, themeTextSecondary], children: rw.whereValid })), !!exp && ((0, jsx_runtime_1.jsxs)(react_native_1.Text, { style: [styles.thankYouSub, fontStyle, themeTextSecondary], children: ["Expires ", exp.toLocaleString()] }))] }));
2583
+ return ((0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.thankYou, children: [(0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [styles.thankYouTitle, fontStyle, themeTextPrimary], children: (0, survey_spec_1.confirmationText)(survey, 'It’s yours') }), (0, jsx_runtime_1.jsx)(react_native_1.Text, { selectable: true, style: [styles.rewardCode, fontStyle, themeTextPrimary], children: rw.value }), !!rw.whereValid && ((0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [styles.thankYouSub, fontStyle, themeTextSecondary], children: rw.whereValid })), !!exp && ((0, jsx_runtime_1.jsxs)(react_native_1.Text, { style: [styles.thankYouSub, fontStyle, themeTextSecondary], children: ["Expires ", exp.toLocaleString()] }))] }));
1921
2584
  }
1922
2585
  if (endingKind === 'hold') {
1923
2586
  return ((0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.thankYou, accessibilityLiveRegion: "polite", children: [(0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [styles.thankYouTitle, fontStyle, themeTextPrimary], children: comp?.reviewRequired ? 'Under review' : 'Not completed' }), (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [styles.thankYouSub, fontStyle, themeTextSecondary], children: comp?.blockedMessage
@@ -1940,157 +2603,189 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
1940
2603
  const imgSrc = firstImageSrc(rawThankYou);
1941
2604
  const thankYouText = stripHtml(rawThankYou);
1942
2605
  return ((0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.thankYou, children: [imgSrc ? ((0, jsx_runtime_1.jsx)(react_native_1.Image, { source: { uri: imgSrc }, style: styles.thankYouImage, resizeMode: "contain" })) : ((0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [styles.thankYouEmoji, { color: primaryColor }], children: "\u2713" })), thankYouText ? ((0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [styles.thankYouTitle, dirText, fontStyle, themeTextPrimary, thankYouFS], children: thankYouText })) : null] }));
1943
- })() : ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [survey.languages && survey.languages.length > 1 && ((0, jsx_runtime_1.jsx)(react_native_1.View, { style: [
1944
- styles.langSwitcher,
1945
- isRtl && { paddingRight: 12, paddingLeft: 48, flexDirection: 'row-reverse' },
1946
- ], children: survey.languages.map((code) => {
1947
- const isActive = code === currentLanguage;
1948
- return ((0, jsx_runtime_1.jsx)(react_native_1.TouchableOpacity, { onPress: () => setCurrentLanguage(code), activeOpacity: 0.7, style: [
1949
- styles.langChip,
1950
- // Light theme: the chip's default white-on-white border/fill
1951
- // is invisible on the light card — repaint from the palette.
1952
- isLight && { borderColor: palette.inputBorder, backgroundColor: palette.input },
1953
- isActive && { borderColor: primaryColor, backgroundColor: `${primaryColor}1A` },
1954
- ], children: (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [
1955
- styles.langChipText,
1956
- isLight && { color: palette.textSecondary },
1957
- isActive && { color: primaryColor },
1958
- ], children: code.toUpperCase() }) }, code));
1959
- }) })), (0, jsx_runtime_1.jsxs)(react_native_1.View, { style: [
1960
- styles.header,
1961
- padHeader,
1962
- isRtl && { paddingLeft: 48, paddingRight: 24 },
1963
- // Thumbnail beside the text, as `.cxp-notify-row` does on web.
1964
- isNotifyCard && { flexDirection: 'row', alignItems: 'center' },
1965
- ], children: [((survey.branding?.showLogo !== false && survey.branding?.logo)
1966
- || survey.branding?.brandName) && ((0, jsx_runtime_1.jsxs)(react_native_1.View, { style: [
1967
- styles.brandRow,
1968
- // Logo position, same rule as the other two renderers.
1969
- (survey.branding?.logoAlign === 'center'
1970
- ? { justifyContent: 'center' }
1971
- : survey.branding?.logoAlign === 'right'
1972
- ? { justifyContent: 'flex-end' }
1973
- : null),
1974
- ], children: [survey.branding?.showLogo !== false && survey.branding?.logo ? ((0, jsx_runtime_1.jsx)(react_native_1.Image, { source: { uri: survey.branding.logo }, style: styles.brandLogo, resizeMode: "contain", accessible: !!survey.branding.brandName, accessibilityLabel: survey.branding.brandName })) : null, survey.branding?.brandName ? ((0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [styles.brandName, dirText, fontStyle, themeTextSecondary], children: survey.branding.brandName })) : null] })), isNotifyCard ? ((0, jsx_runtime_1.jsx)(react_native_1.Image, { source: { uri: notifyImageQ?.imageUrl }, accessibilityLabel: notifyImageQ?.alt || undefined, accessible: !!notifyImageQ?.alt, style: {
1975
- width: contract.imageSize,
1976
- height: contract.imageSize,
1977
- borderRadius: contract.imageRadius,
1978
- marginRight: 12,
1979
- flexShrink: 0,
1980
- }, resizeMode: "cover" })) : null, (0, jsx_runtime_1.jsxs)(react_native_1.View, { style: isNotifyCard ? { flex: 1, minWidth: 0 } : undefined, children: [(0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [styles.title, dirText, fontStyle, themeTextPrimary, titleFS, notifyTitleFS,
1981
- survey.titleAlign
1982
- ? { textAlign: survey.titleAlign }
1983
- : null], children: (0, interpolate_1.interpolatePlain)(
1984
- // Title is plain text; strip any stray rich-text markup +
1985
- // decode &nbsp; so it never shows literal HTML on native.
1986
- String((0, translationLookup_1.surveyText)(survey, currentLanguage, 'title', survey.title) || '')
1987
- .replace(/<[^>]*>/g, '').replace(/&nbsp;/gi, ' '), evalCtx) }), (() => {
1988
- // Description is authored as rich HTML; native Text can't render
1989
- // markup, so strip tags + decode &nbsp; to plain text.
1990
- const descRaw = (0, translationLookup_1.surveyText)(survey, currentLanguage, 'description', survey.description);
1991
- const desc = String(descRaw || '').replace(/<[^>]*>/g, '').replace(/&nbsp;/gi, ' ').trim();
1992
- return desc ? ((0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [styles.description, dirText, fontStyle, themeTextSecondary, descFS, notifyDescFS], children: (0, interpolate_1.interpolatePlain)(desc, evalCtx) })) : null;
1993
- })()] }), deadlineEl, showProgressTop && progressEl] }), (0, jsx_runtime_1.jsx)(react_native_1.ScrollView, { style: [styles.body, padBody, fsContent], showsVerticalScrollIndicator: false, children: currentQuestions.filter((q) => {
1994
- // The notification's thumbnail is drawn in the header above;
1995
- // leaving it here too would render it twice, once full-bleed.
1996
- if (isNotifyCard && String(q?.id ?? '') === 'notify_image')
1997
- return false;
1998
- return true;
1999
- }).map((q) => {
2000
- // v1.1 uses `id`+`required`; legacy uses `name`+`isRequired`.
2001
- // The provider normalizes both onto the same object so
2002
- // either lookup works.
2003
- const rowKey = q.id || q.name;
2004
- // Through forValidation, so requiredWhen counts. A question
2005
- // that becomes required part-way through was previously
2006
- // marked with nothing at all.
2007
- const isRequired = !!forValidation(q).required
2008
- || q.required || q.isRequired;
2009
- // An implied question has no prompt: its ACTIONS are the UI,
2010
- // and its `text` is bookkeeping the author never meant to
2011
- // show. Web renders the whole block display:none and lifts
2012
- // the buttons into the nav bar; here the buttons render in
2013
- // place, so only the label and description are suppressed.
2014
- // Without this the stray authoring text ("tte") appeared as a
2015
- // question above the notification's own button.
2016
- const isImplied = String(q?.type ?? '').toLowerCase() === 'implied';
2017
- const qLabel = (0, interpolate_1.interpolatePlain)((0, translationLookup_1.questionText)(survey, currentLanguage, rowKey, 'text', q.title), evalCtx);
2018
- return ((0, jsx_runtime_1.jsxs)(react_native_1.View, { ref: (el) => { questionRefs.current[rowKey] = el; }, style: [
2019
- styles.questionBlock, gapQBlock,
2020
- // A NOTIFICATION'S ACTION IS NOT A QUESTION IN A LIST.
2021
- //
2022
- // questionBlock carries `marginBottom: sectionGap` so
2023
- // consecutive questions breathe. On a notification the
2024
- // only "question" is the implied action, so that gap
2025
- // landed UNDER the button as dead space at the bottom of
2026
- // the card — the reported extra room the preview does not
2027
- // have. Web spends 12px above the action and nothing
2028
- // below it (`.cxp-notify-action-below`); this matches.
2029
- isNotifyCard && { marginTop: 12, marginBottom: 0 },
2030
- ], children: [isImplied ? null : ((0, jsx_runtime_1.jsxs)(react_native_1.Text, { style: [styles.questionTitle, dirText, fontStyle, themeTextPrimary, qTitleFS],
2031
- // The asterisk below is decoration: a screen reader
2032
- // reads it as "star", or skips it, so a required
2033
- // question sounded exactly like an optional one until
2034
- // submitting failed. The label says the word.
2035
- accessibilityLabel: isRequired ? `${qLabel}, required` : qLabel, children: [questionNumberById[rowKey] ? `Q${questionNumberById[rowKey]}. ` : '', (0, interpolate_1.interpolatePlain)((0, translationLookup_1.questionText)(survey, currentLanguage, rowKey, 'text', q.title), evalCtx), isRequired && (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.required, children: " *" })] })), (() => {
2036
- if (isImplied)
2037
- return null;
2038
- const qDesc = (0, translationLookup_1.questionText)(survey, currentLanguage, rowKey, 'description', q.description);
2039
- return qDesc ? ((0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [styles.questionDesc, dirText, fontStyle, themeTextMuted, qDescFS], children: (0, interpolate_1.interpolatePlain)(qDesc, evalCtx) })) : null;
2040
- })(), renderQuestion(q), errors[rowKey] ? ((0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [styles.errorText, dirText, fontStyle],
2041
- // Announced when it appears, and reachable afterwards:
2042
- // a message that is only spoken once leaves someone who
2043
- // moves back to the field with no way to find out what
2044
- // was wrong with their answer.
2045
- accessibilityRole: "alert", accessibilityLiveRegion: "polite", children: errors[rowKey] })) : null] }, rowKey));
2046
- }) }), submitError ? ((0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [styles.submitError, dirText, fontStyle],
2047
- // Announced, not merely displayed. A respondent who cannot see
2048
- // the panel is otherwise held with no way to discover why.
2049
- accessibilityRole: "alert", accessibilityLiveRegion: "polite", children: submitError })) : null, showProgressBottom && (0, jsx_runtime_1.jsx)(react_native_1.View, { style: padFooter, children: progressEl }), (0, jsx_runtime_1.jsxs)(react_native_1.View, { style: [
2050
- styles.footer,
2051
- padFooter,
2052
- // Flip button order for RTL so the primary (Next / Submit)
2053
- // action sits on the left where an Arabic reader
2054
- // expects to find the forward affordance in their
2055
- // text-flow direction.
2056
- isRtl && { flexDirection: 'row-reverse' },
2057
- footerAlign,
2058
- ], children: [showVoiceMic && ((0, jsx_runtime_1.jsx)(react_native_1.TouchableOpacity, { style: [
2059
- styles.voiceMic,
2060
- { backgroundColor: voiceActive ? '#ef4444' : primaryColor },
2061
- buttonAlign === 'right' && { marginRight: 'auto' },
2062
- ], onPress: toggleVoice, accessibilityLabel: voiceActive ? 'Stop hands-free voice' : 'Answer with voice', activeOpacity: 0.7, children: (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.voiceMicGlyph, children: "\uD83C\uDFA4" }) })), display?.showBack !== false && (history.length > 0 || currentPage > 0) && ((0, jsx_runtime_1.jsx)(react_native_1.TouchableOpacity, { style: [
2063
- styles.backButton,
2064
- isLight && { backgroundColor: palette.input, borderColor: palette.inputBorder },
2065
- btnAutoWidth,
2066
- // Right-align parity with web: Back hugs the left edge while
2067
- // Next/Submit sits on the right (web uses margin-right:auto).
2068
- buttonAlign === 'right' && { marginRight: 'auto' },
2069
- ], onPress: handleBack, activeOpacity: 0.7, children: (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [styles.backButtonText, fontStyle, themeTextSecondary, buttonFS], children: "Back" }) })), impliedNow.length > 0 ? null : (() => {
2070
- // Reflect the actual outcome of pressing Next: if a
2071
- // branch resolves to __end__ we're submitting even
2072
- // when we're not on the last page. Cheap to evaluate
2073
- // here — same call handleNext will make.
2074
- const target = resolveNextTarget();
2075
- // Reflect the actual outcome of pressing Next. A __end__
2076
- // branch always submits. A fork branch or a page/linear-
2077
- // next never submits. Otherwise: last-page + no fork
2078
- // frame left on the stack = submit.
2079
- const willSubmit = target?.kind === 'end' ||
2080
- (target == null && isLastPage && forkStack.length === 0);
2081
- const isSingleButton = history.length === 0 && currentPage === 0;
2082
- return ((0, jsx_runtime_1.jsx)(react_native_1.TouchableOpacity
2083
- // Full-width ONLY when alignment is unset (native default) or
2084
- // explicitly 'full'. When the author picked right/center/left,
2085
- // respect it even on the first page (was forced full-width,
2086
- // so mobile ignored the setting on page 1 — unlike web).
2087
- , {
2088
- // Full-width ONLY when alignment is unset (native default) or
2089
- // explicitly 'full'. When the author picked right/center/left,
2090
- // respect it even on the first page (was forced full-width,
2091
- // so mobile ignored the setting on page 1 — unlike web).
2092
- style: [styles.nextButton, ((isSingleButton && !alignsAuto) || buttonAlign === 'full') && styles.nextButtonFull, btnAutoWidth, { backgroundColor: primaryColor }], onPress: handleNext, activeOpacity: 0.7, children: (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [styles.nextButtonText, fontStyle, buttonFS], children: willSubmit ? 'Submit' : 'Next' }) }));
2093
- })()] })] }))] }), (0, jsx_runtime_1.jsx)(handsFreeVoice_1.VoiceOverlay, { visible: voiceActive, phase: voicePhase, transcript: voiceTranscript, onStop: () => (0, handsFreeVoice_1.stopVoice)() })] }) }));
2606
+ })() : ((0, jsx_runtime_1.jsxs)(react_native_1.View, { style: halfSide
2607
+ ? { flexDirection: imgD.side === 'right' ? 'row-reverse' : 'row',
2608
+ alignItems: 'stretch' }
2609
+ : undefined, children: [imgLayout === 'half' ? cardImageHalf : null, (0, jsx_runtime_1.jsxs)(react_native_1.View, { style: halfSide
2610
+ ? {
2611
+ flex: 1,
2612
+ // minWidth 0, or one long unbroken word in the headline
2613
+ // refuses to shrink and shoves the picture off its share.
2614
+ minWidth: 0,
2615
+ // THE WORDS SIT 12 FROM THE PICTURE, as they do on the web
2616
+ // card — `.cxp-half-text` carries `padding-left:12px` and
2617
+ // nothing else, because the card's own padding is already
2618
+ // spent on the far side. Here the header, body and footer
2619
+ // each carry the full card padding, so the text column
2620
+ // started a whole padding further in and came out 12pt
2621
+ // narrower than the frame beside it. That is about one word
2622
+ // per line — enough to wrap a two-line description onto
2623
+ // three, which is exactly how it was reported.
2624
+ ...(imgD.side === 'right'
2625
+ ? { marginRight: 12 - halfPadR }
2626
+ : { marginLeft: 12 - halfPadL }),
2627
+ }
2628
+ : undefined, children: [langSwitcher, imgLayout === 'above' ? (0, jsx_runtime_1.jsx)(react_native_1.View, { style: padBand, children: cardImageBand }) : null, (0, jsx_runtime_1.jsxs)(react_native_1.View, { style: [
2629
+ styles.header,
2630
+ padHeader,
2631
+ // No ✕ reservation here in either direction — the title carries
2632
+ // it now (closeReserve), so the row below is free for the action.
2633
+ // Thumbnail beside the text, as `.cxp-notify-row` does on web.
2634
+ // A row for the thumbnail, and equally for a button that sits
2635
+ // beside the text — a notification with no image still needs
2636
+ // one if its action is next to the content.
2637
+ (besideImage || notifyBeside) && { flexDirection: 'row', alignItems: 'center' },
2638
+ ], children: [((survey.branding?.showLogo !== false && survey.branding?.logo)
2639
+ || survey.branding?.brandName) && ((0, jsx_runtime_1.jsxs)(react_native_1.View, { style: [
2640
+ styles.brandRow,
2641
+ // Logo position, same rule as the other two renderers.
2642
+ (survey.branding?.logoAlign === 'center'
2643
+ ? { justifyContent: 'center' }
2644
+ : survey.branding?.logoAlign === 'right'
2645
+ ? { justifyContent: 'flex-end' }
2646
+ : null),
2647
+ ], children: [survey.branding?.showLogo !== false && survey.branding?.logo ? ((0, jsx_runtime_1.jsx)(react_native_1.Image, { source: { uri: survey.branding.logo }, style: styles.brandLogo, resizeMode: "contain", accessible: !!survey.branding.brandName, accessibilityLabel: survey.branding.brandName })) : null, survey.branding?.brandName ? ((0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [styles.brandName, dirText, fontStyle, themeTextSecondary], children: survey.branding.brandName })) : null] })), besideImage ? ((0, jsx_runtime_1.jsx)(react_native_1.Image, { source: { uri: cardImageQ?.imageUrl }, accessibilityLabel: cardImageQ?.alt || undefined, accessible: !!cardImageQ?.alt, style: {
2648
+ width: contract.imageSize,
2649
+ height: contract.imageSize,
2650
+ borderRadius: contract.imageRadius,
2651
+ marginRight: 12,
2652
+ flexShrink: 0,
2653
+ // A square beside the text sits level with the headline
2654
+ // it belongs to unless the author says otherwise.
2655
+ alignSelf: imgD.align === 'start' ? 'flex-start'
2656
+ : imgD.align === 'end' ? 'flex-end' : 'center',
2657
+ }, resizeMode: imgD.fit })) : null, (0, jsx_runtime_1.jsxs)(react_native_1.View, { style: (besideImage || notifyBeside) ? { flex: 1, minWidth: 0 } : undefined, children: [(0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [styles.title, dirText, fontStyle, themeTextPrimary, titleFS, notifyTitleFS,
2658
+ closeReserve,
2659
+ survey.titleAlign
2660
+ ? { textAlign: survey.titleAlign }
2661
+ : null], children: (0, interpolate_1.interpolatePlain)(
2662
+ // Title is plain text; strip any stray rich-text markup +
2663
+ // decode &nbsp; so it never shows literal HTML on native.
2664
+ String((0, translationLookup_1.surveyText)(survey, currentLanguage, 'title', survey.title) || '')
2665
+ .replace(/<[^>]*>/g, '').replace(/&nbsp;/gi, ' '), evalCtx) }), (0, jsx_runtime_1.jsxs)(react_native_1.View, { style: notifyBeside ? {
2666
+ flexDirection: 'row',
2667
+ alignItems: 'center',
2668
+ justifyContent: 'space-between',
2669
+ } : undefined, children: [(() => {
2670
+ // Description is authored as rich HTML; native Text can't render
2671
+ // markup, so strip tags + decode &nbsp; to plain text.
2672
+ const descRaw = (0, translationLookup_1.surveyText)(survey, currentLanguage, 'description', survey.description);
2673
+ const desc = String(descRaw || '').replace(/<[^>]*>/g, '').replace(/&nbsp;/gi, ' ').trim();
2674
+ return desc ? ((0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [styles.description, dirText, fontStyle, themeTextSecondary, descFS, notifyDescFS,
2675
+ // Takes the width the button leaves, and no more.
2676
+ notifyBeside && { flex: 1, minWidth: 0, marginTop: 2 }], children: (0, interpolate_1.interpolatePlain)(desc, evalCtx) })) : null;
2677
+ })(), notifyActionQ ? ((0, jsx_runtime_1.jsx)(react_native_1.View, { style: { flexShrink: 0, marginLeft: 12 }, children: renderQuestion(notifyActionQ) })) : null] })] }), deadlineEl, (() => {
2678
+ const terms = cardTerms;
2679
+ return terms ? ((0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [
2680
+ styles.description, dirText, fontStyle, themeTextMuted,
2681
+ { fontSize: Math.round((_num(_authoredStyle.fontSizeDescription) ?? 14) * 0.85),
2682
+ marginTop: 6 },
2683
+ ], children: (0, interpolate_1.interpolatePlain)(terms, evalCtx) })) : null;
2684
+ })(), showProgressTop && progressEl] }), imgLayout === 'below' ? (0, jsx_runtime_1.jsx)(react_native_1.View, { style: padBand, children: cardImageBand }) : null, (0, jsx_runtime_1.jsx)(BodyContainer, { style: [styles.body, padBody, fsContent], showsVerticalScrollIndicator: false, children: currentQuestions.filter((q) => {
2685
+ // The card composes the picture itself — beside the text, in
2686
+ // a band, or behind everything. Leaving it here too would
2687
+ // render it twice, the second time full-bleed.
2688
+ if (composesImage && cardImageQ && q === cardImageQ)
2689
+ return false;
2690
+ // Drawn as the offer's conditions above — see cardTerms.
2691
+ if (cardTerms && String(q?.id ?? '') === 'offer_terms'
2692
+ && String(q?.type ?? '').toLowerCase() === 'content')
2693
+ return false;
2694
+ // Drawn up in the header row instead — leaving it here too
2695
+ // would render the action twice.
2696
+ if (notifyActionQ && q === notifyActionQ)
2697
+ return false;
2698
+ return true;
2699
+ }).map((q) => {
2700
+ // v1.1 uses `id`+`required`; legacy uses `name`+`isRequired`.
2701
+ // The provider normalizes both onto the same object so
2702
+ // either lookup works.
2703
+ const rowKey = q.id || q.name;
2704
+ // Through forValidation, so requiredWhen counts. A question
2705
+ // that becomes required part-way through was previously
2706
+ // marked with nothing at all.
2707
+ const isRequired = !!forValidation(q).required
2708
+ || q.required || q.isRequired;
2709
+ // An implied question has no prompt: its ACTIONS are the UI,
2710
+ // and its `text` is bookkeeping the author never meant to
2711
+ // show. Web renders the whole block display:none and lifts
2712
+ // the buttons into the nav bar; here the buttons render in
2713
+ // place, so only the label and description are suppressed.
2714
+ // Without this the stray authoring text ("tte") appeared as a
2715
+ // question above the notification's own button.
2716
+ const isImplied = String(q?.type ?? '').toLowerCase() === 'implied';
2717
+ const qLabel = (0, interpolate_1.interpolatePlain)((0, translationLookup_1.questionText)(survey, currentLanguage, rowKey, 'text', q.title), evalCtx);
2718
+ return ((0, jsx_runtime_1.jsxs)(react_native_1.View, { ref: (el) => { questionRefs.current[rowKey] = el; }, style: [
2719
+ styles.questionBlock, gapQBlock,
2720
+ // A NOTIFICATION'S ACTION IS NOT A QUESTION IN A LIST.
2721
+ //
2722
+ // questionBlock carries `marginBottom: sectionGap` so
2723
+ // consecutive questions breathe. On a notification the
2724
+ // only "question" is the implied action, so that gap
2725
+ // landed UNDER the button as dead space at the bottom of
2726
+ // the card — the reported extra room the preview does not
2727
+ // have. Web spends 12px above the action and nothing
2728
+ // below it (`.cxp-notify-action-below`); this matches.
2729
+ isNotifyBanner && { marginTop: 12, marginBottom: 0 },
2730
+ ], children: [!(0, survey_spec_1.showsQuestionLabel)(q) ? null : ((0, jsx_runtime_1.jsxs)(react_native_1.Text, { style: [styles.questionTitle, dirText, fontStyle, themeTextPrimary, qTitleFS],
2731
+ // The asterisk below is decoration: a screen reader
2732
+ // reads it as "star", or skips it, so a required
2733
+ // question sounded exactly like an optional one until
2734
+ // submitting failed. The label says the word.
2735
+ accessibilityLabel: isRequired ? `${qLabel}, required` : qLabel, children: [questionNumberById[rowKey] ? `Q${questionNumberById[rowKey]}. ` : '', (0, interpolate_1.interpolatePlain)((0, translationLookup_1.questionText)(survey, currentLanguage, rowKey, 'text', q.title), evalCtx), isRequired && (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.required, children: " *" })] })), (() => {
2736
+ if (isImplied)
2737
+ return null;
2738
+ const qDesc = (0, translationLookup_1.questionText)(survey, currentLanguage, rowKey, 'description', q.description);
2739
+ return qDesc ? ((0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [styles.questionDesc, dirText, fontStyle, themeTextMuted, qDescFS], children: (0, interpolate_1.interpolatePlain)(qDesc, evalCtx) })) : null;
2740
+ })(), renderQuestion(q), errors[rowKey] ? ((0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [styles.errorText, dirText, fontStyle],
2741
+ // Announced when it appears, and reachable afterwards:
2742
+ // a message that is only spoken once leaves someone who
2743
+ // moves back to the field with no way to find out what
2744
+ // was wrong with their answer.
2745
+ accessibilityRole: "alert", accessibilityLiveRegion: "polite", children: errors[rowKey] })) : null] }, rowKey));
2746
+ }) }), submitError ? ((0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [styles.submitError, dirText, fontStyle],
2747
+ // Announced, not merely displayed. A respondent who cannot see
2748
+ // the panel is otherwise held with no way to discover why.
2749
+ accessibilityRole: "alert", accessibilityLiveRegion: "polite", children: submitError })) : null, showProgressBottom && (0, jsx_runtime_1.jsx)(react_native_1.View, { style: padFooter, children: progressEl }), footerHasContent && (0, jsx_runtime_1.jsxs)(react_native_1.View, { style: [
2750
+ styles.footer,
2751
+ padFooter,
2752
+ // Flip button order for RTL so the primary (Next / Submit)
2753
+ // action sits on the left where an Arabic reader
2754
+ // expects to find the forward affordance in their
2755
+ // text-flow direction.
2756
+ isRtl && { flexDirection: 'row-reverse' },
2757
+ footerAlign,
2758
+ ], children: [showVoiceMic && ((0, jsx_runtime_1.jsx)(react_native_1.TouchableOpacity, { ...swipeExempt, style: [
2759
+ styles.voiceMic,
2760
+ { backgroundColor: voiceActive ? '#ef4444' : primaryColor },
2761
+ buttonAlign === 'right' && { marginRight: 'auto' },
2762
+ ], onPress: toggleVoice, accessibilityLabel: voiceActive ? 'Stop hands-free voice' : 'Answer with voice', activeOpacity: 0.7, children: (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.voiceMicGlyph, children: "\uD83C\uDFA4" }) })), display?.showBack !== false && (history.length > 0 || currentPage > 0) && ((0, jsx_runtime_1.jsx)(react_native_1.TouchableOpacity, { ...swipeExempt, style: [
2763
+ styles.backButton,
2764
+ isLight && { backgroundColor: palette.input, borderColor: palette.inputBorder },
2765
+ btnAutoWidth,
2766
+ // Right-align parity with web: Back hugs the left edge while
2767
+ // Next/Submit sits on the right (web uses margin-right:auto).
2768
+ buttonAlign === 'right' && { marginRight: 'auto' },
2769
+ ], onPress: handleBack, activeOpacity: 0.7, children: (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [styles.backButtonText, fontStyle, themeTextSecondary, buttonFS], children: "Back" }) })), impliedNow.length > 0 ? null : (() => {
2770
+ // Reflect the actual outcome of pressing Next: if a
2771
+ // branch resolves to __end__ we're submitting even
2772
+ // when we're not on the last page. Cheap to evaluate
2773
+ // here — same call handleNext will make.
2774
+ const target = resolveNextTarget();
2775
+ // Reflect the actual outcome of pressing Next. A __end__
2776
+ // branch always submits. A fork branch or a page/linear-
2777
+ // next never submits. Otherwise: last-page + no fork
2778
+ // frame left on the stack = submit.
2779
+ const willSubmit = target?.kind === 'end' ||
2780
+ (target == null && isLastPage && forkStack.length === 0);
2781
+ const isSingleButton = history.length === 0 && currentPage === 0;
2782
+ return ((0, jsx_runtime_1.jsx)(react_native_1.TouchableOpacity, { ...swipeExempt,
2783
+ // Full-width ONLY when alignment is unset (native default) or
2784
+ // explicitly 'full'. When the author picked right/center/left,
2785
+ // respect it even on the first page (was forced full-width,
2786
+ // so mobile ignored the setting on page 1 — unlike web).
2787
+ style: [styles.nextButton, ((isSingleButton && !alignsAuto) || buttonAlign === 'full') && styles.nextButtonFull, btnAutoWidth, { backgroundColor: primaryColor }], onPress: handleNext, activeOpacity: 0.7, children: (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [styles.nextButtonText, fontStyle, buttonFS], children: willSubmit ? 'Submit' : 'Next' }) }));
2788
+ })()] })] })] }))] }), (0, jsx_runtime_1.jsx)(handsFreeVoice_1.VoiceOverlay, { visible: voiceActive, phase: voicePhase, transcript: voiceTranscript, onStop: () => (0, handsFreeVoice_1.stopVoice)() })] }) }));
2094
2789
  }
2095
2790
  const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = react_native_1.Dimensions.get('window');
2096
2791
  /** Web draws a popup at `width:90vw`; the card matches so both read the same. */
@@ -2108,7 +2803,9 @@ const CARD_VIEWPORT_FRACTION = 0.9;
2108
2803
  const makeStyles = (c) => react_native_1.StyleSheet.create({
2109
2804
  overlay: {
2110
2805
  flex: 1,
2111
- backgroundColor: c.ground,
2806
+ // The scrim, not the page. See SurveyStyleContract.overlay — this used to
2807
+ // read `ground`, which web spends on the widget surface instead.
2808
+ backgroundColor: c.overlay,
2112
2809
  justifyContent: 'center',
2113
2810
  alignItems: 'center',
2114
2811
  padding: 20,
@@ -2117,7 +2814,12 @@ const makeStyles = (c) => react_native_1.StyleSheet.create({
2117
2814
  width: Math.min(SCREEN_WIDTH - 40, 420),
2118
2815
  maxHeight: '85%',
2119
2816
  backgroundColor: c.card,
2120
- borderRadius: c.cardRadius,
2817
+ // FOUR CORNERS, each falling back to the single value — so unlinking one
2818
+ // corner cannot silently square the other three.
2819
+ borderTopLeftRadius: c.cardRadiusTopLeft ?? c.cardRadius,
2820
+ borderTopRightRadius: c.cardRadiusTopRight ?? c.cardRadius,
2821
+ borderBottomRightRadius: c.cardRadiusBottomRight ?? c.cardRadius,
2822
+ borderBottomLeftRadius: c.cardRadiusBottomLeft ?? c.cardRadius,
2121
2823
  borderWidth: 1,
2122
2824
  borderColor: c.cardBorder,
2123
2825
  overflow: 'hidden',
@@ -2137,20 +2839,35 @@ const makeStyles = (c) => react_native_1.StyleSheet.create({
2137
2839
  },
2138
2840
  closeButton: {
2139
2841
  position: 'absolute',
2140
- top: 12,
2842
+ // From the contract now — see SurveyStyleContract.closeSize. At the old
2843
+ // hardcoded 12 the circle sat a third of the way into the header's own
2844
+ // padding, so it read as part of the text block rather than as the card's
2845
+ // own control; 8 is the default it ships with.
2846
+ top: c.closeInset,
2141
2847
  // left / right applied inline based on survey.direction — see
2142
2848
  // the closeButton render site above.
2143
2849
  zIndex: 10,
2144
- width: 32,
2145
- height: 32,
2146
- borderRadius: 16,
2850
+ width: c.closeSize,
2851
+ height: c.closeSize,
2852
+ borderRadius: c.closeRadius,
2147
2853
  backgroundColor: c.closeBg,
2148
2854
  alignItems: 'center',
2149
2855
  justifyContent: 'center',
2150
2856
  },
2151
- closeText: {
2152
- color: c.closeText,
2153
- fontSize: 16,
2857
+ // The two bars that make the cross. Sized off the button, not off a font.
2858
+ // Sized off the disc, so a smaller button keeps its proportions.
2859
+ closeGlyph: {
2860
+ width: Math.round(c.closeSize * 0.38),
2861
+ height: Math.round(c.closeSize * 0.38),
2862
+ alignItems: 'center',
2863
+ justifyContent: 'center',
2864
+ },
2865
+ closeBar: {
2866
+ position: 'absolute',
2867
+ width: Math.round(c.closeSize * 0.38),
2868
+ height: 1.5,
2869
+ borderRadius: 1,
2870
+ backgroundColor: c.closeText,
2154
2871
  },
2155
2872
  header: {
2156
2873
  // Per-side padding maps onto the three stacked regions this modal already
@@ -2160,9 +2877,15 @@ const makeStyles = (c) => react_native_1.StyleSheet.create({
2160
2877
  paddingTop: c.cardPaddingTop ?? c.cardPadding,
2161
2878
  paddingLeft: c.cardPaddingLeft ?? c.cardPadding,
2162
2879
  paddingBottom: 12,
2163
- // 48 is the room the close button needs; a larger authored right padding
2164
- // wins, a smaller one would put the title under the ✕.
2165
- paddingRight: Math.max(48, c.cardPaddingRight ?? c.cardPadding),
2880
+ // THE ✕'s ROOM IS THE TITLE'S PROBLEM, NOT THE HEADER'S.
2881
+ //
2882
+ // This reserved 48pt on the whole header so a title could not slide under
2883
+ // the close button. That was right while the header held only text — and
2884
+ // wrong once a notification's action moved up into it, because the
2885
+ // reservation then squeezed the BUTTON as well, and moving the ✕ visibly
2886
+ // shifted an action sitting nowhere near it. Web has always reserved on the
2887
+ // title alone (`titlePad`); this now matches.
2888
+ paddingRight: c.cardPaddingRight ?? c.cardPadding,
2166
2889
  },
2167
2890
  // Branding row above the title — logo image + optional brand name
2168
2891
  // caption. Center-aligned; the logo is capped small enough that
@@ -2185,6 +2908,14 @@ const makeStyles = (c) => react_native_1.StyleSheet.create({
2185
2908
  marginTop: 6,
2186
2909
  },
2187
2910
  title: {
2911
+ // Unset is transparent — see SurveyStyleContract.titleBackground.
2912
+ ...(c.titleBackground ? {
2913
+ backgroundColor: c.titleBackground,
2914
+ paddingHorizontal: 8,
2915
+ paddingVertical: 4,
2916
+ borderRadius: 4,
2917
+ overflow: 'hidden',
2918
+ } : null),
2188
2919
  color: c.headingColor,
2189
2920
  fontSize: c.fontSizeHeading,
2190
2921
  // 600, matching web's `.cxp-widget-title`. At 700 the same string is about
@@ -2195,10 +2926,26 @@ const makeStyles = (c) => react_native_1.StyleSheet.create({
2195
2926
  fontWeight: '600',
2196
2927
  },
2197
2928
  description: {
2929
+ // A RATIO, from the contract. Web multiplies lineHeightBody by the font
2930
+ // size; mobile had 20 written in, so the builder's Line height control
2931
+ // moved one renderer and not the other. Rounded because RN wants points.
2932
+ lineHeight: Math.round(c.fontSizeDescription * c.lineHeightBody),
2933
+ ...(c.descriptionBackground ? {
2934
+ backgroundColor: c.descriptionBackground,
2935
+ paddingHorizontal: 8,
2936
+ paddingVertical: 4,
2937
+ borderRadius: 4,
2938
+ overflow: 'hidden',
2939
+ } : null),
2198
2940
  color: c.descriptionColor,
2199
- fontSize: 14,
2941
+ // FROM THE CONTRACT, like the title two rules up. This was 14, written in,
2942
+ // so `fontSizeDescription` reached the web card and the notification's own
2943
+ // scale and nothing else: an offer authored at heading 11 / body 10 drew
2944
+ // the heading at 11 and the body at 14, which is a body LARGER than its
2945
+ // own headline. The authored numbers were never wrong; one of the two was
2946
+ // simply not being read.
2947
+ fontSize: c.fontSizeDescription,
2200
2948
  marginTop: 6,
2201
- lineHeight: 20,
2202
2949
  },
2203
2950
  progress: {
2204
2951
  color: c.progressText,
@@ -2291,7 +3038,14 @@ const makeStyles = (c) => react_native_1.StyleSheet.create({
2291
3038
  // weight rather than looking like an option list.
2292
3039
  impliedActions: { flexDirection: 'row', flexWrap: 'wrap', gap: 10, marginTop: 4 },
2293
3040
  impliedBtn: {
2294
- flexGrow: 1, flexBasis: 140, paddingVertical: 13, paddingHorizontal: 18,
3041
+ // flexShrink so a long label narrows the button rather than pushing the
3042
+ // text off the card when the action sits beside the content.
3043
+ flexGrow: 1, flexShrink: 1, flexBasis: 140, minWidth: 0,
3044
+ // ALL THREE SIZE CONTROLS, from the contract. Back and Next have read
3045
+ // buttonPaddingY and buttonFontSize throughout; this button carried its own
3046
+ // literals, so the builder's Size group moved the preview and left the
3047
+ // action a notification actually offers exactly where it was.
3048
+ paddingVertical: c.buttonPaddingY, paddingHorizontal: c.buttonPaddingX,
2295
3049
  // From the contract, like every other button. A literal 8 here meant an
2296
3050
  // authored button radius applied to Next and Back but not to the action a
2297
3051
  // notification actually offers.
@@ -2300,11 +3054,27 @@ const makeStyles = (c) => react_native_1.StyleSheet.create({
2300
3054
  // The brand accent, not a hardcoded blue. `#2b6cb0` ignored primaryColor
2301
3055
  // entirely, so a notification's only action was off-brand on every survey —
2302
3056
  // the same button renders in the authored accent on web.
3057
+ // borderColor too: the button carries a 1px border (transparent by default),
3058
+ // so an authored border colour has somewhere to land.
3059
+ // NO borderColor HERE. `primaryBorder` is derived from the brand colour when
3060
+ // none is authored, so setting it unconditionally drew a border on every
3061
+ // button — while web draws one only when the author asked for it. The
3062
+ // authored value is applied at the render site instead, where "authored" can
3063
+ // still be told apart from "derived". impliedBtn's transparent edge stands
3064
+ // otherwise, so the 1px reserves its space without showing.
2303
3065
  impliedPrimary: { backgroundColor: c.primaryBg },
2304
- impliedSecondary: { backgroundColor: 'transparent', borderColor: c.inputBorder },
3066
+ // THE SECOND BUTTON READS THE CONTRACT, like the first.
3067
+ //
3068
+ // The contract has carried secondaryBg/Text/Border all along and this drew
3069
+ // from `transparent` and the INPUT border instead — so an offer's decline
3070
+ // action was the one thing on the card an author could not colour, while the
3071
+ // panel showed a single button's settings for a card with two.
3072
+ impliedSecondary: { backgroundColor: c.secondaryBg, borderColor: c.secondaryBorder },
2305
3073
  impliedChosen: { opacity: 0.75 },
2306
- impliedPrimaryLabel: { color: c.primaryText, fontWeight: '600', fontSize: 15 },
2307
- impliedSecondaryLabel: { color: c.textPrimary, fontWeight: '600', fontSize: 15 },
3074
+ // fontSize from the contract for the same reason — its default IS 15, so an
3075
+ // unstyled notification renders identically and an authored one now moves.
3076
+ impliedPrimaryLabel: { color: c.primaryText, fontWeight: '600', fontSize: c.buttonFontSize },
3077
+ impliedSecondaryLabel: { color: c.secondaryText, fontWeight: '600', fontSize: c.buttonFontSize },
2308
3078
  desktopHint: {
2309
3079
  color: c.hintText,
2310
3080
  fontSize: 13,