@cxpinsight/react-native 0.17.6 → 0.17.8

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.
@@ -33,6 +33,9 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.stylePlatformSlice = stylePlatformSlice;
37
+ exports.topInsetForHeight = topInsetForHeight;
38
+ exports.resolvePopupTop = resolvePopupTop;
36
39
  exports.SurveyModal = SurveyModal;
37
40
  const jsx_runtime_1 = require("react/jsx-runtime");
38
41
  const react_1 = __importStar(require("react"));
@@ -94,6 +97,54 @@ function stripHtml(input) {
94
97
  .replace(/\n{3,}/g, '\n\n')
95
98
  .trim();
96
99
  }
100
+ /**
101
+ * `branding.style` flattened for THIS platform: shared keys first, the `mobile`
102
+ * slice on top. Mirrors how `settings.display` is sliced — the two are written
103
+ * by the same studio controls and have to be read the same way. `web` is
104
+ * dropped rather than merged; it describes a different form factor.
105
+ */
106
+ function stylePlatformSlice(style) {
107
+ if (!style || typeof style !== 'object')
108
+ return {};
109
+ const out = {};
110
+ for (const [k, v] of Object.entries(style)) {
111
+ if (k === 'web' || k === 'mobile' || k === 'link')
112
+ continue;
113
+ out[k] = v;
114
+ }
115
+ const m = style.mobile;
116
+ if (m && typeof m === 'object')
117
+ Object.assign(out, m);
118
+ return out;
119
+ }
120
+ /**
121
+ * iOS top safe-area inset, derived from the screen height.
122
+ *
123
+ * This package declares no dependencies, so react-native-safe-area-context is
124
+ * not available and the height is the only signal. 852/932pt are the Dynamic
125
+ * Island sizes (59pt), 812-896 the notch generation (44pt), the rest pre-notch.
126
+ */
127
+ function topInsetForHeight(screenH) {
128
+ return screenH >= 852 ? 59 : screenH >= 812 ? 44 : 20;
129
+ }
130
+ /**
131
+ * Resolve an authored `y` (percent of screen, marking the card's CENTRE — the
132
+ * web SDK's `top:y%` + `translate(-50%,-50%)`) to a paddingTop in points, with
133
+ * the whole card kept inside the safe area.
134
+ *
135
+ * Returns null when there is no authored coordinate, so the caller falls back
136
+ * to flex placement.
137
+ */
138
+ function resolvePopupTop(opts) {
139
+ const { y, screenH, cardH, topInset, bottomInset, gutter } = opts;
140
+ if (!Number.isFinite(y))
141
+ return null;
142
+ const minTop = topInset + gutter;
143
+ const maxTop = screenH - bottomInset - gutter - cardH;
144
+ const wanted = (y / 100) * screenH - cardH / 2;
145
+ // A card taller than the safe band pins to the top rather than going negative.
146
+ return Math.max(minTop, maxTop > minTop ? Math.min(wanted, maxTop) : minTop);
147
+ }
97
148
  function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
98
149
  const [currentPage, setCurrentPage] = (0, react_1.useState)(0);
99
150
  const [responses, setResponses] = (0, react_1.useState)({});
@@ -605,7 +656,12 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
605
656
  setForkStack([]);
606
657
  onDismiss();
607
658
  };
608
- handleDismissRef.current = handleDismiss;
659
+ handleDismissRef.current = (() => {
660
+ if (__DEV__) { /* eslint-disable-next-line no-console */
661
+ console.log('[CXP-SW] handleDismiss INVOKED');
662
+ }
663
+ return handleDismiss();
664
+ });
609
665
  // Normalize the v1.1 options[] {value,label} and the legacy
610
666
  // choices[] (string | {value,text}) into a single shape that
611
667
  // MultipleChoiceQuestion accepts. hasOther appends a synthetic
@@ -1249,7 +1305,12 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
1249
1305
  // radius set once applies in both themes, exactly as it does on web.
1250
1306
  surveyOverride: {
1251
1307
  ...(0, styleContract_1.fromAuthoredPalette)(surveyPaletteOverride),
1252
- ...(survey.branding?.style || {}),
1308
+ // PER PLATFORM, like `display` below. branding.style carries web/mobile
1309
+ // slices — the studio writes a size dragged on the phone frame into
1310
+ // style.mobile — and this spread was flat, so mobile silently used the
1311
+ // SHARED values. A notification authored with style.mobile.imageSize 53
1312
+ // and cardPadding 17 rendered at the root's 101 and 36.
1313
+ ...stylePlatformSlice(survey.branding?.style),
1253
1314
  },
1254
1315
  initOverride: initPaletteOverride,
1255
1316
  }),
@@ -1320,6 +1381,26 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
1320
1381
  const animationType = display?.animation === 'fade' ? 'fade' : display?.animation === 'none' ? 'none' : 'slide';
1321
1382
  const isFullscreen = display?.mode === 'fullscreen';
1322
1383
  const isDrawer = display?.mode === 'modal';
1384
+ /**
1385
+ * A popup does NOT dim the app, and does not take its touches.
1386
+ *
1387
+ * Web has said so for a while — the overlay is `transparent` with
1388
+ * `pointer-events:none` for a popup, and only a modal gets rgba(0,0,0,0.45).
1389
+ * Mobile painted `c.ground` (rgba(0,0,0,0.6) dark, 0.4 light) over the whole
1390
+ * screen for EVERY mode, so a push-style banner dimmed the app behind it and
1391
+ * blocked every tap that was not on the card.
1392
+ *
1393
+ * Visible in the recordings: the same login screen captured 47 seconds apart
1394
+ * is bright at `start` and washed out once a card is up — the lime button
1395
+ * renders dull olive. That darkened frame is what the replay shows, so the
1396
+ * "grey composite" in the timeline and this are one bug.
1397
+ *
1398
+ * box-none rather than none: the container stops capturing touches, the card
1399
+ * inside still receives them. `none` here would make the survey itself
1400
+ * untouchable, which is how web pairs `pointer-events:none` on the overlay
1401
+ * with `auto` on the card.
1402
+ */
1403
+ const isPopupOverlay = !isFullscreen && !isDrawer;
1323
1404
  // Position — previously unread here, which is why a phone could never show a
1324
1405
  // bottom sheet, the one presentation users actually expect on mobile.
1325
1406
  //
@@ -1327,6 +1408,58 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
1327
1408
  // 420px of horizontal room a handset does not have, and the sheet is the
1328
1409
  // native equivalent. A drawer with nothing set anchors to the BOTTOM (the
1329
1410
  // platform convention); anything else centres, as before.
1411
+ // A NOTIFICATION IS NOT A FORM.
1412
+ //
1413
+ // Web composes one: `.cxp-notify-row` puts a small thumbnail beside the title
1414
+ // and description, hides the implied question that carries the action, and
1415
+ // puts that action full-width underneath. Mobile had none of it — `notify`
1416
+ // appeared nowhere in this file — so the same interaction rendered as a plain
1417
+ // question list: the avatar as a full-bleed image question, and the implied
1418
+ // question's `text` as a visible prompt above it (the reported stray "tte").
1419
+ const interactionKind = String(survey.interactionType || '').toLowerCase();
1420
+ // BOTH shapes: the wire payload carries `questions`, and the provider sets
1421
+ // `rawQuestions` only when it re-paginates. Reading one alone missed the
1422
+ // notification entirely — the composition below silently never engaged.
1423
+ const allQuestions = (survey.rawQuestions
1424
+ || survey.questions
1425
+ || []);
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;
1430
+ // A NOTIFICATION'S TYPE SITS ONE SIZE DOWN from a survey's — web says so
1431
+ // explicitly (notifyFsHeading/notifyFsDesc, defaulting to 15/13), and mobile
1432
+ // had no equivalent: it used the survey title and a hardcoded 14 description,
1433
+ // so an unstyled notification rendered at full survey type here and banner
1434
+ // type there. An authored size still wins, on both.
1435
+ const _authoredStyle = stylePlatformSlice(survey.branding?.style);
1436
+ const _num = (v) => (typeof v === 'number' && v > 0 ? v : undefined);
1437
+ // SHARED VALUE, PROPORTIONATE RENDERING.
1438
+ //
1439
+ // `fontSizeHeading` is stored once for every surface, so a size chosen while
1440
+ // looking at the desktop frame is the size the phone gets too. 24 sits
1441
+ // comfortably in a 485px desktop card and overflows a ~354pt phone one, which
1442
+ // is why the headline wrapped here and not in the preview.
1443
+ //
1444
+ // The number stays shared — one value, as authored — and each surface renders
1445
+ // it in proportion to the card it actually has. Web is unchanged (its card is
1446
+ // at or above the reference), and only a card narrower than the reference
1447
+ // scales down. An unstyled notification keeps web's own banner default of 15
1448
+ // rather than being scaled, because 15 is already a banner size.
1449
+ const REFERENCE_CARD_PT = 480;
1450
+ const notifyCardW = Math.min(SCREEN_WIDTH * CARD_VIEWPORT_FRACTION, typeof maxWidth === 'number' ? maxWidth : SCREEN_WIDTH);
1451
+ const authoredHeadingPt = _num(_authoredStyle.fontSizeHeading);
1452
+ const notifyTitleFS = isNotifyCard
1453
+ ? {
1454
+ fontSize: authoredHeadingPt != null
1455
+ // Floored so a very narrow card cannot shrink the headline below the
1456
+ // body text and invert the hierarchy.
1457
+ ? Math.max(14, Math.round(authoredHeadingPt * Math.min(1, notifyCardW / REFERENCE_CARD_PT)))
1458
+ : 15,
1459
+ }
1460
+ : null;
1461
+ const notifyDescFS = isNotifyCard
1462
+ ? { fontSize: _num(_authoredStyle.fontSizeDescription) ?? 13, lineHeight: 18 } : null;
1330
1463
  const rawPos = display?.position;
1331
1464
  const placement = rawPos === 'top' ? 'top'
1332
1465
  : rawPos === 'bottom' ? 'bottom'
@@ -1334,12 +1467,32 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
1334
1467
  : rawPos === 'center' ? 'center'
1335
1468
  : isDrawer ? 'bottom' : 'center';
1336
1469
  const isSheet = isDrawer && (placement === 'top' || placement === 'bottom');
1337
- // Popup coordinate. RN cannot centre on a percentage the way the web SDK does
1338
- // — transform:translateY takes points, not percentages, and percentage margins
1339
- // resolve against the parent WIDTH — so the free y is bucketed into the three
1340
- // placements RN can express cleanly. x is ignored entirely; a phone centres.
1470
+ // Popup coordinate. `y` is a PERCENTAGE OF THE SCREEN and marks the CARD'S
1471
+ // CENTRE — the web SDK's `top:y%` + `translate(-50%,-50%)`. This used to be
1472
+ // bucketed into three placements (<33 top, >67 bottom, else centre) on the
1473
+ // belief that RN could not express it: translateY takes points, not
1474
+ // percentages, and percentage margins resolve against the parent WIDTH.
1475
+ //
1476
+ // Both are true and neither is binding. The screen dimensions are known here,
1477
+ // so the percentage resolves to POINTS and rides in as paddingTop on a
1478
+ // flex-start overlay — no absolute positioning, so KeyboardAvoidingView keeps
1479
+ // working. The card's own height comes from onLayout; until that first pass
1480
+ // the authored height (or a reasonable default) stands in, and the safe-area
1481
+ // clamp below usually decides the result anyway.
1482
+ //
1483
+ // The bucketing was a real mismatch, not a rounding one: an author who
1484
+ // dragged the card to y=17 in the studio got it flush against the top of the
1485
+ // device — 20dp instead of the ~145dp they placed it at on an 852dp screen.
1486
+ //
1487
+ // x stays ignored: the studio disables horizontal dragging on the mobile
1488
+ // frame (`moveX: platformForFrame(frame) !== 'mobile'`), so a phone centres.
1341
1489
  const popupY = !isDrawer && !isFullscreen && display?.y != null ? parseFloat(String(display.y)) : NaN;
1342
- const effPlacement = !isNaN(popupY) ? (popupY < 33 ? 'top' : popupY > 67 ? 'bottom' : 'center') : placement;
1490
+ const hasPopupY = !isNaN(popupY);
1491
+ const effPlacement = hasPopupY ? 'top' : placement;
1492
+ // Measured card height; the authored height is the first-frame stand-in.
1493
+ const [measuredCardH, setMeasuredCardH] = (0, react_1.useState)(0);
1494
+ const authoredH = Number.parseFloat(String(display?.height ?? ''));
1495
+ const estCardH = measuredCardH || (Number.isFinite(authoredH) && authoredH > 0 ? authoredH : 220);
1343
1496
  const overlayPlacement = {
1344
1497
  justifyContent: effPlacement === 'top' ? 'flex-start'
1345
1498
  : effPlacement === 'bottom' ? 'flex-end'
@@ -1353,7 +1506,15 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
1353
1506
  // a bottom sheet and a centred popup both rise, and fade has no direction.
1354
1507
  // So the Modal animation is handed over ONLY for a top sheet, and the card
1355
1508
  // animates itself down from above instead.
1356
- const needsTopSlide = animationType === 'slide' && isSheet && placement === 'top';
1509
+ //
1510
+ // A POPUP resolved to the top has exactly the same problem and was not
1511
+ // covered: the gate asked for `isSheet`, which a popup never is. So a
1512
+ // notification authored near the top of the screen flew up from the BOTTOM
1513
+ // bezel, crossed the whole display, and settled backwards into place — the
1514
+ // reported "the animation is wrong". Keyed off the RESOLVED placement, which
1515
+ // is what the card actually uses.
1516
+ const needsTopSlide = animationType === 'slide'
1517
+ && ((isSheet && placement === 'top') || (!isDrawer && !isFullscreen && effPlacement === 'top'));
1357
1518
  const modalAnimation = needsTopSlide ? 'none' : animationType;
1358
1519
  const enterAnim = (0, react_1.useRef)(new react_native_1.Animated.Value(1)).current;
1359
1520
  (0, react_1.useEffect)(() => {
@@ -1383,12 +1544,60 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
1383
1544
  // keeps a compliance takeover — showClose:false for the OPPOSITE reason —
1384
1545
  // from ever becoming flickable.
1385
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
+ }
1386
1557
  const swipeXY = (0, react_1.useRef)(new react_native_1.Animated.ValueXY({ x: 0, y: 0 })).current;
1387
1558
  const swipeResponder = (0, react_1.useMemo)(() => react_native_1.PanResponder.create({
1388
1559
  onStartShouldSetPanResponder: () => false,
1389
1560
  // Claim the gesture only once it is clearly a drag, so a tap on the action
1390
1561
  // button still presses the button.
1391
- onMoveShouldSetPanResponder: (_e, g) => swipeable && (Math.abs(g.dx) > 8 || Math.abs(g.dy) > 8),
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
+ },
1392
1601
  onPanResponderMove: (_e, g) => {
1393
1602
  const horizontal = Math.abs(g.dx) > Math.abs(g.dy);
1394
1603
  // A top banner travels up, never down — dragging it down is a pull.
@@ -1396,15 +1605,27 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
1396
1605
  swipeXY.setValue(horizontal ? { x: g.dx, y: 0 } : { x: 0, y: dy });
1397
1606
  },
1398
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
+ }
1399
1611
  const horizontal = Math.abs(g.dx) > Math.abs(g.dy);
1400
1612
  const moved = horizontal ? g.dx : g.dy;
1401
1613
  const fast = Math.abs(horizontal ? g.vx : g.vy) > 0.5;
1402
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
+ }
1403
1618
  const to = moved < 0 ? -SCREEN_HEIGHT : SCREEN_HEIGHT;
1404
1619
  react_native_1.Animated.timing(swipeXY, {
1405
1620
  toValue: horizontal ? { x: moved < 0 ? -SCREEN_WIDTH : SCREEN_WIDTH, y: 0 } : { x: 0, y: to },
1406
1621
  duration: 180, useNativeDriver: true,
1407
- }).start(() => { swipeXY.setValue({ x: 0, y: 0 }); handleDismissRef.current(); });
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
+ });
1408
1629
  return;
1409
1630
  }
1410
1631
  react_native_1.Animated.spring(swipeXY, { toValue: { x: 0, y: 0 }, useNativeDriver: true, bounciness: 6 }).start();
@@ -1442,10 +1663,16 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
1442
1663
  }, dismissMs);
1443
1664
  return () => clearTimeout(t);
1444
1665
  }, [visible, dismissMs]);
1666
+ // COMPOSED, not stacked. The swipe transform used to be a separate entry in
1667
+ // the card's style array, applied after this one — and a later `transform`
1668
+ // REPLACES an earlier one rather than merging. That was harmless while only
1669
+ // sheets animated themselves, but a notification is both top-placed and
1670
+ // swipeable, so the entrance translate would have been silently dropped.
1445
1671
  const enterStyle = needsTopSlide
1446
1672
  ? {
1447
1673
  opacity: enterAnim,
1448
- transform: [{
1674
+ transform: [
1675
+ {
1449
1676
  translateY: enterAnim.interpolate({
1450
1677
  inputRange: [0, 1],
1451
1678
  // Its own height is not known before layout, so it travels a screen
@@ -1453,7 +1680,9 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
1453
1680
  // enough that the easing still lands crisply.
1454
1681
  outputRange: [-SCREEN_HEIGHT / 3, 0],
1455
1682
  }),
1456
- }],
1683
+ },
1684
+ ...(swipeable ? swipeXY.getTranslateTransform() : []),
1685
+ ],
1457
1686
  }
1458
1687
  : null;
1459
1688
  const sheetRadius = cornerRadius !== undefined ? cornerRadius : 16;
@@ -1468,13 +1697,28 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
1468
1697
  // Safe areas. Deliberately built from react-native primitives rather than
1469
1698
  // react-native-safe-area-context: this package declares no dependencies, and
1470
1699
  // adding one would force every host app to install it.
1471
- const topInset = react_native_1.Platform.OS === 'android' ? (react_native_1.StatusBar.currentHeight || 0) : 44;
1700
+ //
1701
+ // 44 was the notched-iPhone value and was applied to every iOS device, so on a
1702
+ // Dynamic Island phone (59pt) the card rode up under the island. Derived from
1703
+ // the screen height instead — the only signal available without the dependency:
1704
+ // 852pt/932pt are the Island sizes, 812-896 the notch generation, the rest pre-notch.
1705
+ const topInset = react_native_1.Platform.OS === 'android'
1706
+ ? (react_native_1.StatusBar.currentHeight || 0)
1707
+ : topInsetForHeight(SCREEN_HEIGHT);
1472
1708
  const bottomInset = react_native_1.Platform.OS === 'ios' ? 34 : 0;
1473
1709
  const safePad = isFullscreen
1474
1710
  ? { paddingTop: topInset, paddingBottom: bottomInset }
1475
1711
  : isSheet && placement === 'bottom' ? { paddingBottom: bottomInset }
1476
1712
  : isSheet && placement === 'top' ? { paddingTop: topInset }
1477
1713
  : null;
1714
+ // Resolve the authored `y` to a top offset in points, keeping the whole card
1715
+ // inside the safe area. GUTTER is the overlay's own padding, so the clamp is
1716
+ // expressed against the same edges the card would otherwise rest on.
1717
+ const GUTTER = 20;
1718
+ const popupTopPx = hasPopupY
1719
+ ? resolvePopupTop({ y: popupY, screenH: SCREEN_HEIGHT, cardH: estCardH, topInset, bottomInset, gutter: GUTTER })
1720
+ : null;
1721
+ const popupTop = popupTopPx == null ? null : { paddingTop: popupTopPx };
1478
1722
  // Action-button layout. UNSET keeps the native full-width split (default);
1479
1723
  // 'full' is explicit full-width; right/center/left shrink to content + align.
1480
1724
  const buttonAlign = display?.buttonAlign;
@@ -1532,8 +1776,17 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
1532
1776
  if (pct)
1533
1777
  return { width: `${pct[1]}%` };
1534
1778
  const px = /^(\d+(?:\.\d+)?)px$/.exec(String(w));
1779
+ // The authored width is a CAP, not a target — web renders the popup as
1780
+ // `width:90vw; max-width:<authored>`, so the card is 90% of the viewport
1781
+ // until the author's number is the smaller of the two.
1782
+ //
1783
+ // I briefly clamped this to the full SCREEN instead, reading "390px on a
1784
+ // 393pt frame" as a request for full bleed. It is not: the preview showed
1785
+ // 351px (90vw of 390) and the device then drew 390, so the SAME 53pt
1786
+ // thumbnail sat in a wider card and read as too small. Matching web's 90vw
1787
+ // is what keeps the proportions identical on both.
1535
1788
  if (px)
1536
- return { width: Math.min(SCREEN_WIDTH - 40, Number(px[1])), maxWidth: Number(px[1]) };
1789
+ return { width: Math.min(SCREEN_WIDTH * CARD_VIEWPORT_FRACTION, Number(px[1])), maxWidth: Number(px[1]) };
1537
1790
  return null;
1538
1791
  })();
1539
1792
  // Fullscreen ONLY: the card fills (survey bg), and these constrain the CONTENT
@@ -1624,18 +1877,30 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
1624
1877
  styles.progressBarFill,
1625
1878
  { width: `${((currentPage + 1) / totalPages) * 100}%`, backgroundColor: primaryColor },
1626
1879
  ] }) })] })) : null;
1627
- 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, { style: [
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: [
1628
1881
  styles.overlay,
1882
+ // Transparent for a popup — the app behind it is not being taken over.
1883
+ isPopupOverlay && { backgroundColor: 'transparent' },
1629
1884
  overlayPlacement,
1885
+ // The authored coordinate, resolved to points. Before the sheet and
1886
+ // fullscreen overrides so those keep winning.
1887
+ popupTop,
1630
1888
  // A sheet sits flush against its edge, so the overlay loses its gutter.
1631
1889
  isSheet && { paddingHorizontal: 0, paddingTop: 0, paddingBottom: 0 },
1632
1890
  isFullscreen && styles.overlayFullscreen,
1633
- ], behavior: react_native_1.Platform.OS === 'ios' ? 'padding' : 'height', children: [(0, jsx_runtime_1.jsxs)(react_native_1.Animated.View, { onTouchStart: () => { engagedRef.current = true; }, ...(swipeable ? swipeResponder.panHandlers : {}), style: [
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; },
1892
+ // Feeds the authored-coordinate maths above. Only tracked when a
1893
+ // coordinate is actually in play, and only on a real change, so the
1894
+ // common case costs nothing and this cannot loop.
1895
+ onLayout: hasPopupY ? (e) => {
1896
+ const h = Math.round(e.nativeEvent.layout.height);
1897
+ setMeasuredCardH((prev) => (Math.abs(prev - h) > 1 ? h : prev));
1898
+ } : undefined, ...(swipeable ? swipeResponder.panHandlers : {}), style: [
1634
1899
  styles.card,
1635
1900
  themeCard,
1636
1901
  enterStyle,
1637
- swipeable && { transform: swipeXY.getTranslateTransform() },
1638
- maxWidth !== undefined && !isFullscreen && { width: Math.min(SCREEN_WIDTH - 40, maxWidth), maxWidth },
1902
+ swipeable && !needsTopSlide && { transform: swipeXY.getTranslateTransform() },
1903
+ maxWidth !== undefined && !isFullscreen && { width: Math.min(SCREEN_WIDTH * CARD_VIEWPORT_FRACTION, maxWidth), maxWidth },
1639
1904
  cornerRadius !== undefined && !isFullscreen && { borderRadius: cornerRadius },
1640
1905
  // cardFullscreen fills; an explicit content height/width below OVERRIDES
1641
1906
  // the fill so a fullscreen survey can be contained to that size.
@@ -1695,6 +1960,8 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
1695
1960
  styles.header,
1696
1961
  padHeader,
1697
1962
  isRtl && { paddingLeft: 48, paddingRight: 24 },
1963
+ // Thumbnail beside the text, as `.cxp-notify-row` does on web.
1964
+ isNotifyCard && { flexDirection: 'row', alignItems: 'center' },
1698
1965
  ], children: [((survey.branding?.showLogo !== false && survey.branding?.logo)
1699
1966
  || survey.branding?.brandName) && ((0, jsx_runtime_1.jsxs)(react_native_1.View, { style: [
1700
1967
  styles.brandRow,
@@ -1704,20 +1971,32 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
1704
1971
  : survey.branding?.logoAlign === 'right'
1705
1972
  ? { justifyContent: 'flex-end' }
1706
1973
  : null),
1707
- ], 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] })), (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [styles.title, dirText, fontStyle, themeTextPrimary, titleFS,
1708
- survey.titleAlign
1709
- ? { textAlign: survey.titleAlign }
1710
- : null], children: (0, interpolate_1.interpolatePlain)(
1711
- // Title is plain text; strip any stray rich-text markup +
1712
- // decode &nbsp; so it never shows literal HTML on native.
1713
- String((0, translationLookup_1.surveyText)(survey, currentLanguage, 'title', survey.title) || '')
1714
- .replace(/<[^>]*>/g, '').replace(/&nbsp;/gi, ' '), evalCtx) }), (() => {
1715
- // Description is authored as rich HTML; native Text can't render
1716
- // markup, so strip tags + decode &nbsp; to plain text.
1717
- const descRaw = (0, translationLookup_1.surveyText)(survey, currentLanguage, 'description', survey.description);
1718
- const desc = String(descRaw || '').replace(/<[^>]*>/g, '').replace(/&nbsp;/gi, ' ').trim();
1719
- return desc ? ((0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [styles.description, dirText, fontStyle, themeTextSecondary, descFS], children: (0, interpolate_1.interpolatePlain)(desc, evalCtx) })) : null;
1720
- })(), deadlineEl, showProgressTop && progressEl] }), (0, jsx_runtime_1.jsx)(react_native_1.ScrollView, { style: [styles.body, padBody, fsContent], showsVerticalScrollIndicator: false, children: currentQuestions.map((q) => {
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) => {
1721
2000
  // v1.1 uses `id`+`required`; legacy uses `name`+`isRequired`.
1722
2001
  // The provider normalizes both onto the same object so
1723
2002
  // either lookup works.
@@ -1727,13 +2006,35 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
1727
2006
  // marked with nothing at all.
1728
2007
  const isRequired = !!forValidation(q).required
1729
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';
1730
2017
  const qLabel = (0, interpolate_1.interpolatePlain)((0, translationLookup_1.questionText)(survey, currentLanguage, rowKey, 'text', q.title), evalCtx);
1731
- return ((0, jsx_runtime_1.jsxs)(react_native_1.View, { ref: (el) => { questionRefs.current[rowKey] = el; }, style: [styles.questionBlock, gapQBlock], children: [(0, jsx_runtime_1.jsxs)(react_native_1.Text, { style: [styles.questionTitle, dirText, fontStyle, themeTextPrimary, qTitleFS],
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],
1732
2031
  // The asterisk below is decoration: a screen reader
1733
2032
  // reads it as "star", or skips it, so a required
1734
2033
  // question sounded exactly like an optional one until
1735
2034
  // submitting failed. The label says the word.
1736
- 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: " *" })] }), (() => {
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;
1737
2038
  const qDesc = (0, translationLookup_1.questionText)(survey, currentLanguage, rowKey, 'description', q.description);
1738
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;
1739
2040
  })(), renderQuestion(q), errors[rowKey] ? ((0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [styles.errorText, dirText, fontStyle],
@@ -1792,6 +2093,8 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
1792
2093
  })()] })] }))] }), (0, jsx_runtime_1.jsx)(handsFreeVoice_1.VoiceOverlay, { visible: voiceActive, phase: voicePhase, transcript: voiceTranscript, onStop: () => (0, handsFreeVoice_1.stopVoice)() })] }) }));
1793
2094
  }
1794
2095
  const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = react_native_1.Dimensions.get('window');
2096
+ /** Web draws a popup at `width:90vw`; the card matches so both read the same. */
2097
+ const CARD_VIEWPORT_FRACTION = 0.9;
1795
2098
  /**
1796
2099
  * Contract-driven styles. Was a static StyleSheet with 31 hardcoded colours;
1797
2100
  * every one now resolves from the style contract, so a branded survey brands the
@@ -1884,7 +2187,12 @@ const makeStyles = (c) => react_native_1.StyleSheet.create({
1884
2187
  title: {
1885
2188
  color: c.headingColor,
1886
2189
  fontSize: c.fontSizeHeading,
1887
- fontWeight: '700',
2190
+ // 600, matching web's `.cxp-widget-title`. At 700 the same string is about
2191
+ // 5% wider, which is enough to tip a notification's headline from one line
2192
+ // to two — the device wrapped "Testing the notification" where the studio
2193
+ // preview kept it on one, and that single difference is what made the two
2194
+ // cards look unalike.
2195
+ fontWeight: '600',
1888
2196
  },
1889
2197
  description: {
1890
2198
  color: c.descriptionColor,
@@ -1984,13 +2292,19 @@ const makeStyles = (c) => react_native_1.StyleSheet.create({
1984
2292
  impliedActions: { flexDirection: 'row', flexWrap: 'wrap', gap: 10, marginTop: 4 },
1985
2293
  impliedBtn: {
1986
2294
  flexGrow: 1, flexBasis: 140, paddingVertical: 13, paddingHorizontal: 18,
1987
- borderRadius: 8, alignItems: 'center', borderWidth: 1, borderColor: 'transparent',
2295
+ // From the contract, like every other button. A literal 8 here meant an
2296
+ // authored button radius applied to Next and Back but not to the action a
2297
+ // notification actually offers.
2298
+ borderRadius: c.buttonRadius, alignItems: 'center', borderWidth: 1, borderColor: 'transparent',
1988
2299
  },
1989
- impliedPrimary: { backgroundColor: '#2b6cb0' },
1990
- impliedSecondary: { backgroundColor: 'transparent', borderColor: '#d4d4d4' },
2300
+ // The brand accent, not a hardcoded blue. `#2b6cb0` ignored primaryColor
2301
+ // entirely, so a notification's only action was off-brand on every survey —
2302
+ // the same button renders in the authored accent on web.
2303
+ impliedPrimary: { backgroundColor: c.primaryBg },
2304
+ impliedSecondary: { backgroundColor: 'transparent', borderColor: c.inputBorder },
1991
2305
  impliedChosen: { opacity: 0.75 },
1992
- impliedPrimaryLabel: { color: '#fff', fontWeight: '600', fontSize: 15 },
1993
- impliedSecondaryLabel: { color: '#1a1a1a', fontWeight: '600', fontSize: 15 },
2306
+ impliedPrimaryLabel: { color: c.primaryText, fontWeight: '600', fontSize: 15 },
2307
+ impliedSecondaryLabel: { color: c.textPrimary, fontWeight: '600', fontSize: 15 },
1994
2308
  desktopHint: {
1995
2309
  color: c.hintText,
1996
2310
  fontSize: 13,