@cxpinsight/react-native 0.17.4 → 0.17.7

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)({});
@@ -1249,7 +1300,12 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
1249
1300
  // radius set once applies in both themes, exactly as it does on web.
1250
1301
  surveyOverride: {
1251
1302
  ...(0, styleContract_1.fromAuthoredPalette)(surveyPaletteOverride),
1252
- ...(survey.branding?.style || {}),
1303
+ // PER PLATFORM, like `display` below. branding.style carries web/mobile
1304
+ // slices — the studio writes a size dragged on the phone frame into
1305
+ // style.mobile — and this spread was flat, so mobile silently used the
1306
+ // SHARED values. A notification authored with style.mobile.imageSize 53
1307
+ // and cardPadding 17 rendered at the root's 101 and 36.
1308
+ ...stylePlatformSlice(survey.branding?.style),
1253
1309
  },
1254
1310
  initOverride: initPaletteOverride,
1255
1311
  }),
@@ -1327,6 +1383,36 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
1327
1383
  // 420px of horizontal room a handset does not have, and the sheet is the
1328
1384
  // native equivalent. A drawer with nothing set anchors to the BOTTOM (the
1329
1385
  // platform convention); anything else centres, as before.
1386
+ // A NOTIFICATION IS NOT A FORM.
1387
+ //
1388
+ // Web composes one: `.cxp-notify-row` puts a small thumbnail beside the title
1389
+ // and description, hides the implied question that carries the action, and
1390
+ // puts that action full-width underneath. Mobile had none of it — `notify`
1391
+ // appeared nowhere in this file — so the same interaction rendered as a plain
1392
+ // question list: the avatar as a full-bleed image question, and the implied
1393
+ // question's `text` as a visible prompt above it (the reported stray "tte").
1394
+ const interactionKind = String(survey.interactionType || '').toLowerCase();
1395
+ // BOTH shapes: the wire payload carries `questions`, and the provider sets
1396
+ // `rawQuestions` only when it re-paginates. Reading one alone missed the
1397
+ // notification entirely — the composition below silently never engaged.
1398
+ const allQuestions = (survey.rawQuestions
1399
+ || survey.questions
1400
+ || []);
1401
+ const notifyImageQ = allQuestions.find((q) => String(q?.id ?? '') === 'notify_image' && String(q?.type ?? '').toLowerCase() === 'image');
1402
+ // Keyed off the composed question, not the type alone: an Ask that happens to
1403
+ // carry an image question must keep rendering it as a question.
1404
+ const isNotifyCard = interactionKind === 'notify' && !!notifyImageQ?.imageUrl;
1405
+ // A NOTIFICATION'S TYPE SITS ONE SIZE DOWN from a survey's — web says so
1406
+ // explicitly (notifyFsHeading/notifyFsDesc, defaulting to 15/13), and mobile
1407
+ // had no equivalent: it used the survey title and a hardcoded 14 description,
1408
+ // so an unstyled notification rendered at full survey type here and banner
1409
+ // type there. An authored size still wins, on both.
1410
+ const _authoredStyle = stylePlatformSlice(survey.branding?.style);
1411
+ const _num = (v) => (typeof v === 'number' && v > 0 ? v : undefined);
1412
+ const notifyTitleFS = isNotifyCard
1413
+ ? { fontSize: _num(_authoredStyle.fontSizeHeading) ?? 15 } : null;
1414
+ const notifyDescFS = isNotifyCard
1415
+ ? { fontSize: _num(_authoredStyle.fontSizeDescription) ?? 13, lineHeight: 18 } : null;
1330
1416
  const rawPos = display?.position;
1331
1417
  const placement = rawPos === 'top' ? 'top'
1332
1418
  : rawPos === 'bottom' ? 'bottom'
@@ -1334,12 +1420,32 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
1334
1420
  : rawPos === 'center' ? 'center'
1335
1421
  : isDrawer ? 'bottom' : 'center';
1336
1422
  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.
1423
+ // Popup coordinate. `y` is a PERCENTAGE OF THE SCREEN and marks the CARD'S
1424
+ // CENTRE — the web SDK's `top:y%` + `translate(-50%,-50%)`. This used to be
1425
+ // bucketed into three placements (<33 top, >67 bottom, else centre) on the
1426
+ // belief that RN could not express it: translateY takes points, not
1427
+ // percentages, and percentage margins resolve against the parent WIDTH.
1428
+ //
1429
+ // Both are true and neither is binding. The screen dimensions are known here,
1430
+ // so the percentage resolves to POINTS and rides in as paddingTop on a
1431
+ // flex-start overlay — no absolute positioning, so KeyboardAvoidingView keeps
1432
+ // working. The card's own height comes from onLayout; until that first pass
1433
+ // the authored height (or a reasonable default) stands in, and the safe-area
1434
+ // clamp below usually decides the result anyway.
1435
+ //
1436
+ // The bucketing was a real mismatch, not a rounding one: an author who
1437
+ // dragged the card to y=17 in the studio got it flush against the top of the
1438
+ // device — 20dp instead of the ~145dp they placed it at on an 852dp screen.
1439
+ //
1440
+ // x stays ignored: the studio disables horizontal dragging on the mobile
1441
+ // frame (`moveX: platformForFrame(frame) !== 'mobile'`), so a phone centres.
1341
1442
  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;
1443
+ const hasPopupY = !isNaN(popupY);
1444
+ const effPlacement = hasPopupY ? 'top' : placement;
1445
+ // Measured card height; the authored height is the first-frame stand-in.
1446
+ const [measuredCardH, setMeasuredCardH] = (0, react_1.useState)(0);
1447
+ const authoredH = Number.parseFloat(String(display?.height ?? ''));
1448
+ const estCardH = measuredCardH || (Number.isFinite(authoredH) && authoredH > 0 ? authoredH : 220);
1343
1449
  const overlayPlacement = {
1344
1450
  justifyContent: effPlacement === 'top' ? 'flex-start'
1345
1451
  : effPlacement === 'bottom' ? 'flex-end'
@@ -1353,7 +1459,15 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
1353
1459
  // a bottom sheet and a centred popup both rise, and fade has no direction.
1354
1460
  // So the Modal animation is handed over ONLY for a top sheet, and the card
1355
1461
  // animates itself down from above instead.
1356
- const needsTopSlide = animationType === 'slide' && isSheet && placement === 'top';
1462
+ //
1463
+ // A POPUP resolved to the top has exactly the same problem and was not
1464
+ // covered: the gate asked for `isSheet`, which a popup never is. So a
1465
+ // notification authored near the top of the screen flew up from the BOTTOM
1466
+ // bezel, crossed the whole display, and settled backwards into place — the
1467
+ // reported "the animation is wrong". Keyed off the RESOLVED placement, which
1468
+ // is what the card actually uses.
1469
+ const needsTopSlide = animationType === 'slide'
1470
+ && ((isSheet && placement === 'top') || (!isDrawer && !isFullscreen && effPlacement === 'top'));
1357
1471
  const modalAnimation = needsTopSlide ? 'none' : animationType;
1358
1472
  const enterAnim = (0, react_1.useRef)(new react_native_1.Animated.Value(1)).current;
1359
1473
  (0, react_1.useEffect)(() => {
@@ -1388,6 +1502,21 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
1388
1502
  onStartShouldSetPanResponder: () => false,
1389
1503
  // Claim the gesture only once it is clearly a drag, so a tap on the action
1390
1504
  // button still presses the button.
1505
+ //
1506
+ // CAPTURE, not bubble. The card's content sits in a ScrollView, and a
1507
+ // ScrollView claims a vertical drag first — so the flick that is supposed
1508
+ // to dismiss a notification was being consumed as a scroll of content that
1509
+ // does not even overflow, and the banner could not be swiped away at all.
1510
+ // The 8px threshold still protects the button: a tap never reaches here.
1511
+ onMoveShouldSetPanResponderCapture: (_e, g) => {
1512
+ const claim = swipeable && (Math.abs(g.dx) > 8 || Math.abs(g.dy) > 8);
1513
+ // TEMP-DIAG
1514
+ if (__DEV__ && (Math.abs(g.dx) > 8 || Math.abs(g.dy) > 8)) {
1515
+ // eslint-disable-next-line no-console
1516
+ console.log('[CXP-SWIPE]', JSON.stringify({ swipeable, dx: Math.round(g.dx), dy: Math.round(g.dy), claim }));
1517
+ }
1518
+ return claim;
1519
+ },
1391
1520
  onMoveShouldSetPanResponder: (_e, g) => swipeable && (Math.abs(g.dx) > 8 || Math.abs(g.dy) > 8),
1392
1521
  onPanResponderMove: (_e, g) => {
1393
1522
  const horizontal = Math.abs(g.dx) > Math.abs(g.dy);
@@ -1442,10 +1571,16 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
1442
1571
  }, dismissMs);
1443
1572
  return () => clearTimeout(t);
1444
1573
  }, [visible, dismissMs]);
1574
+ // COMPOSED, not stacked. The swipe transform used to be a separate entry in
1575
+ // the card's style array, applied after this one — and a later `transform`
1576
+ // REPLACES an earlier one rather than merging. That was harmless while only
1577
+ // sheets animated themselves, but a notification is both top-placed and
1578
+ // swipeable, so the entrance translate would have been silently dropped.
1445
1579
  const enterStyle = needsTopSlide
1446
1580
  ? {
1447
1581
  opacity: enterAnim,
1448
- transform: [{
1582
+ transform: [
1583
+ {
1449
1584
  translateY: enterAnim.interpolate({
1450
1585
  inputRange: [0, 1],
1451
1586
  // Its own height is not known before layout, so it travels a screen
@@ -1453,7 +1588,9 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
1453
1588
  // enough that the easing still lands crisply.
1454
1589
  outputRange: [-SCREEN_HEIGHT / 3, 0],
1455
1590
  }),
1456
- }],
1591
+ },
1592
+ ...(swipeable ? swipeXY.getTranslateTransform() : []),
1593
+ ],
1457
1594
  }
1458
1595
  : null;
1459
1596
  const sheetRadius = cornerRadius !== undefined ? cornerRadius : 16;
@@ -1468,13 +1605,28 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
1468
1605
  // Safe areas. Deliberately built from react-native primitives rather than
1469
1606
  // react-native-safe-area-context: this package declares no dependencies, and
1470
1607
  // 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;
1608
+ //
1609
+ // 44 was the notched-iPhone value and was applied to every iOS device, so on a
1610
+ // Dynamic Island phone (59pt) the card rode up under the island. Derived from
1611
+ // the screen height instead — the only signal available without the dependency:
1612
+ // 852pt/932pt are the Island sizes, 812-896 the notch generation, the rest pre-notch.
1613
+ const topInset = react_native_1.Platform.OS === 'android'
1614
+ ? (react_native_1.StatusBar.currentHeight || 0)
1615
+ : topInsetForHeight(SCREEN_HEIGHT);
1472
1616
  const bottomInset = react_native_1.Platform.OS === 'ios' ? 34 : 0;
1473
1617
  const safePad = isFullscreen
1474
1618
  ? { paddingTop: topInset, paddingBottom: bottomInset }
1475
1619
  : isSheet && placement === 'bottom' ? { paddingBottom: bottomInset }
1476
1620
  : isSheet && placement === 'top' ? { paddingTop: topInset }
1477
1621
  : null;
1622
+ // Resolve the authored `y` to a top offset in points, keeping the whole card
1623
+ // inside the safe area. GUTTER is the overlay's own padding, so the clamp is
1624
+ // expressed against the same edges the card would otherwise rest on.
1625
+ const GUTTER = 20;
1626
+ const popupTopPx = hasPopupY
1627
+ ? resolvePopupTop({ y: popupY, screenH: SCREEN_HEIGHT, cardH: estCardH, topInset, bottomInset, gutter: GUTTER })
1628
+ : null;
1629
+ const popupTop = popupTopPx == null ? null : { paddingTop: popupTopPx };
1478
1630
  // Action-button layout. UNSET keeps the native full-width split (default);
1479
1631
  // 'full' is explicit full-width; right/center/left shrink to content + align.
1480
1632
  const buttonAlign = display?.buttonAlign;
@@ -1532,8 +1684,17 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
1532
1684
  if (pct)
1533
1685
  return { width: `${pct[1]}%` };
1534
1686
  const px = /^(\d+(?:\.\d+)?)px$/.exec(String(w));
1687
+ // The authored width is a CAP, not a target — web renders the popup as
1688
+ // `width:90vw; max-width:<authored>`, so the card is 90% of the viewport
1689
+ // until the author's number is the smaller of the two.
1690
+ //
1691
+ // I briefly clamped this to the full SCREEN instead, reading "390px on a
1692
+ // 393pt frame" as a request for full bleed. It is not: the preview showed
1693
+ // 351px (90vw of 390) and the device then drew 390, so the SAME 53pt
1694
+ // thumbnail sat in a wider card and read as too small. Matching web's 90vw
1695
+ // is what keeps the proportions identical on both.
1535
1696
  if (px)
1536
- return { width: Math.min(SCREEN_WIDTH - 40, Number(px[1])), maxWidth: Number(px[1]) };
1697
+ return { width: Math.min(SCREEN_WIDTH * CARD_VIEWPORT_FRACTION, Number(px[1])), maxWidth: Number(px[1]) };
1537
1698
  return null;
1538
1699
  })();
1539
1700
  // Fullscreen ONLY: the card fills (survey bg), and these constrain the CONTENT
@@ -1627,15 +1788,25 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
1627
1788
  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: [
1628
1789
  styles.overlay,
1629
1790
  overlayPlacement,
1791
+ // The authored coordinate, resolved to points. Before the sheet and
1792
+ // fullscreen overrides so those keep winning.
1793
+ popupTop,
1630
1794
  // A sheet sits flush against its edge, so the overlay loses its gutter.
1631
1795
  isSheet && { paddingHorizontal: 0, paddingTop: 0, paddingBottom: 0 },
1632
1796
  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: [
1797
+ ], behavior: react_native_1.Platform.OS === 'ios' ? 'padding' : 'height', children: [(0, jsx_runtime_1.jsxs)(react_native_1.Animated.View, { onTouchStart: () => { engagedRef.current = true; },
1798
+ // Feeds the authored-coordinate maths above. Only tracked when a
1799
+ // coordinate is actually in play, and only on a real change, so the
1800
+ // common case costs nothing and this cannot loop.
1801
+ onLayout: hasPopupY ? (e) => {
1802
+ const h = Math.round(e.nativeEvent.layout.height);
1803
+ setMeasuredCardH((prev) => (Math.abs(prev - h) > 1 ? h : prev));
1804
+ } : undefined, ...(swipeable ? swipeResponder.panHandlers : {}), style: [
1634
1805
  styles.card,
1635
1806
  themeCard,
1636
1807
  enterStyle,
1637
- swipeable && { transform: swipeXY.getTranslateTransform() },
1638
- maxWidth !== undefined && !isFullscreen && { width: Math.min(SCREEN_WIDTH - 40, maxWidth), maxWidth },
1808
+ swipeable && !needsTopSlide && { transform: swipeXY.getTranslateTransform() },
1809
+ maxWidth !== undefined && !isFullscreen && { width: Math.min(SCREEN_WIDTH * CARD_VIEWPORT_FRACTION, maxWidth), maxWidth },
1639
1810
  cornerRadius !== undefined && !isFullscreen && { borderRadius: cornerRadius },
1640
1811
  // cardFullscreen fills; an explicit content height/width below OVERRIDES
1641
1812
  // the fill so a fullscreen survey can be contained to that size.
@@ -1695,6 +1866,8 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
1695
1866
  styles.header,
1696
1867
  padHeader,
1697
1868
  isRtl && { paddingLeft: 48, paddingRight: 24 },
1869
+ // Thumbnail beside the text, as `.cxp-notify-row` does on web.
1870
+ isNotifyCard && { flexDirection: 'row', alignItems: 'center' },
1698
1871
  ], children: [((survey.branding?.showLogo !== false && survey.branding?.logo)
1699
1872
  || survey.branding?.brandName) && ((0, jsx_runtime_1.jsxs)(react_native_1.View, { style: [
1700
1873
  styles.brandRow,
@@ -1704,20 +1877,32 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
1704
1877
  : survey.branding?.logoAlign === 'right'
1705
1878
  ? { justifyContent: 'flex-end' }
1706
1879
  : 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) => {
1880
+ ], 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: {
1881
+ width: contract.imageSize,
1882
+ height: contract.imageSize,
1883
+ borderRadius: contract.imageRadius,
1884
+ marginRight: 12,
1885
+ flexShrink: 0,
1886
+ }, 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,
1887
+ survey.titleAlign
1888
+ ? { textAlign: survey.titleAlign }
1889
+ : null], children: (0, interpolate_1.interpolatePlain)(
1890
+ // Title is plain text; strip any stray rich-text markup +
1891
+ // decode &nbsp; so it never shows literal HTML on native.
1892
+ String((0, translationLookup_1.surveyText)(survey, currentLanguage, 'title', survey.title) || '')
1893
+ .replace(/<[^>]*>/g, '').replace(/&nbsp;/gi, ' '), evalCtx) }), (() => {
1894
+ // Description is authored as rich HTML; native Text can't render
1895
+ // markup, so strip tags + decode &nbsp; to plain text.
1896
+ const descRaw = (0, translationLookup_1.surveyText)(survey, currentLanguage, 'description', survey.description);
1897
+ const desc = String(descRaw || '').replace(/<[^>]*>/g, '').replace(/&nbsp;/gi, ' ').trim();
1898
+ 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;
1899
+ })()] }), deadlineEl, showProgressTop && progressEl] }), (0, jsx_runtime_1.jsx)(react_native_1.ScrollView, { style: [styles.body, padBody, fsContent], showsVerticalScrollIndicator: false, children: currentQuestions.filter((q) => {
1900
+ // The notification's thumbnail is drawn in the header above;
1901
+ // leaving it here too would render it twice, once full-bleed.
1902
+ if (isNotifyCard && String(q?.id ?? '') === 'notify_image')
1903
+ return false;
1904
+ return true;
1905
+ }).map((q) => {
1721
1906
  // v1.1 uses `id`+`required`; legacy uses `name`+`isRequired`.
1722
1907
  // The provider normalizes both onto the same object so
1723
1908
  // either lookup works.
@@ -1727,13 +1912,23 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
1727
1912
  // marked with nothing at all.
1728
1913
  const isRequired = !!forValidation(q).required
1729
1914
  || q.required || q.isRequired;
1915
+ // An implied question has no prompt: its ACTIONS are the UI,
1916
+ // and its `text` is bookkeeping the author never meant to
1917
+ // show. Web renders the whole block display:none and lifts
1918
+ // the buttons into the nav bar; here the buttons render in
1919
+ // place, so only the label and description are suppressed.
1920
+ // Without this the stray authoring text ("tte") appeared as a
1921
+ // question above the notification's own button.
1922
+ const isImplied = String(q?.type ?? '').toLowerCase() === 'implied';
1730
1923
  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],
1924
+ return ((0, jsx_runtime_1.jsxs)(react_native_1.View, { ref: (el) => { questionRefs.current[rowKey] = el; }, style: [styles.questionBlock, gapQBlock], children: [isImplied ? null : ((0, jsx_runtime_1.jsxs)(react_native_1.Text, { style: [styles.questionTitle, dirText, fontStyle, themeTextPrimary, qTitleFS],
1732
1925
  // The asterisk below is decoration: a screen reader
1733
1926
  // reads it as "star", or skips it, so a required
1734
1927
  // question sounded exactly like an optional one until
1735
1928
  // 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: " *" })] }), (() => {
1929
+ 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: " *" })] })), (() => {
1930
+ if (isImplied)
1931
+ return null;
1737
1932
  const qDesc = (0, translationLookup_1.questionText)(survey, currentLanguage, rowKey, 'description', q.description);
1738
1933
  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
1934
  })(), renderQuestion(q), errors[rowKey] ? ((0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [styles.errorText, dirText, fontStyle],
@@ -1792,6 +1987,8 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
1792
1987
  })()] })] }))] }), (0, jsx_runtime_1.jsx)(handsFreeVoice_1.VoiceOverlay, { visible: voiceActive, phase: voicePhase, transcript: voiceTranscript, onStop: () => (0, handsFreeVoice_1.stopVoice)() })] }) }));
1793
1988
  }
1794
1989
  const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = react_native_1.Dimensions.get('window');
1990
+ /** Web draws a popup at `width:90vw`; the card matches so both read the same. */
1991
+ const CARD_VIEWPORT_FRACTION = 0.9;
1795
1992
  /**
1796
1993
  * Contract-driven styles. Was a static StyleSheet with 31 hardcoded colours;
1797
1994
  * every one now resolves from the style contract, so a branded survey brands the
@@ -1884,7 +2081,12 @@ const makeStyles = (c) => react_native_1.StyleSheet.create({
1884
2081
  title: {
1885
2082
  color: c.headingColor,
1886
2083
  fontSize: c.fontSizeHeading,
1887
- fontWeight: '700',
2084
+ // 600, matching web's `.cxp-widget-title`. At 700 the same string is about
2085
+ // 5% wider, which is enough to tip a notification's headline from one line
2086
+ // to two — the device wrapped "Testing the notification" where the studio
2087
+ // preview kept it on one, and that single difference is what made the two
2088
+ // cards look unalike.
2089
+ fontWeight: '600',
1888
2090
  },
1889
2091
  description: {
1890
2092
  color: c.descriptionColor,
@@ -1984,13 +2186,19 @@ const makeStyles = (c) => react_native_1.StyleSheet.create({
1984
2186
  impliedActions: { flexDirection: 'row', flexWrap: 'wrap', gap: 10, marginTop: 4 },
1985
2187
  impliedBtn: {
1986
2188
  flexGrow: 1, flexBasis: 140, paddingVertical: 13, paddingHorizontal: 18,
1987
- borderRadius: 8, alignItems: 'center', borderWidth: 1, borderColor: 'transparent',
2189
+ // From the contract, like every other button. A literal 8 here meant an
2190
+ // authored button radius applied to Next and Back but not to the action a
2191
+ // notification actually offers.
2192
+ borderRadius: c.buttonRadius, alignItems: 'center', borderWidth: 1, borderColor: 'transparent',
1988
2193
  },
1989
- impliedPrimary: { backgroundColor: '#2b6cb0' },
1990
- impliedSecondary: { backgroundColor: 'transparent', borderColor: '#d4d4d4' },
2194
+ // The brand accent, not a hardcoded blue. `#2b6cb0` ignored primaryColor
2195
+ // entirely, so a notification's only action was off-brand on every survey —
2196
+ // the same button renders in the authored accent on web.
2197
+ impliedPrimary: { backgroundColor: c.primaryBg },
2198
+ impliedSecondary: { backgroundColor: 'transparent', borderColor: c.inputBorder },
1991
2199
  impliedChosen: { opacity: 0.75 },
1992
- impliedPrimaryLabel: { color: '#fff', fontWeight: '600', fontSize: 15 },
1993
- impliedSecondaryLabel: { color: '#1a1a1a', fontWeight: '600', fontSize: 15 },
2200
+ impliedPrimaryLabel: { color: c.primaryText, fontWeight: '600', fontSize: 15 },
2201
+ impliedSecondaryLabel: { color: c.textPrimary, fontWeight: '600', fontSize: 15 },
1994
2202
  desktopHint: {
1995
2203
  color: c.hintText,
1996
2204
  fontSize: 13,