@swype-org/deposit 0.3.26 → 0.3.32

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.
package/dist/react.cjs CHANGED
@@ -109,21 +109,84 @@ function parseCapabilities(value) {
109
109
  }
110
110
  return value.filter((entry) => typeof entry === "string");
111
111
  }
112
- function buildViewportMessage(viewportLvh, safeAreaBottom) {
113
- return { type: "blink:viewport", viewportLvh, safeAreaBottom };
112
+ function buildViewportMessage(viewportLvh, safeAreaBottom, embedMaxHeightPx) {
113
+ return {
114
+ type: "blink:viewport",
115
+ viewportLvh,
116
+ safeAreaBottom,
117
+ ...embedMaxHeightPx === void 0 ? {} : { embedMaxHeightPx }
118
+ };
119
+ }
120
+ var MAX_CONTENT_HEIGHT_PX = 2e4;
121
+ function parseContentHeight(data) {
122
+ if (!data || typeof data !== "object") {
123
+ return null;
124
+ }
125
+ const msg = data;
126
+ if (msg.type !== "blink:content-height") {
127
+ return null;
128
+ }
129
+ const raw = msg.heightPx;
130
+ if (typeof raw !== "number" || !Number.isFinite(raw)) {
131
+ return null;
132
+ }
133
+ const heightPx = Math.round(raw);
134
+ if (heightPx <= 0 || heightPx > MAX_CONTENT_HEIGHT_PX) {
135
+ return null;
136
+ }
137
+ return {
138
+ type: "blink:content-height",
139
+ heightPx,
140
+ ...parseWidthHint("preferredWidthPx", msg.preferredWidthPx),
141
+ ...parseWidthHint("minWidthPx", msg.minWidthPx)
142
+ };
143
+ }
144
+ function parseWidthHint(key, value) {
145
+ if (typeof value !== "number" || !Number.isFinite(value)) return {};
146
+ const px = Math.round(value);
147
+ if (px <= 0 || px > MAX_CONTENT_HEIGHT_PX) return {};
148
+ return { [key]: px };
114
149
  }
115
150
  function buildRevealMessage() {
116
151
  return { type: "blink:reveal" };
117
152
  }
118
- function buildSignedPayloadMessage(merchantId, payload, signature) {
153
+ function buildSignedPayloadMessage(merchantId, payload, signature, balance) {
119
154
  return {
120
155
  type: "blink:signed-payload",
121
156
  merchantId,
122
157
  payload,
123
- signature
158
+ signature,
159
+ ...balance === void 0 ? {} : { balance }
124
160
  };
125
161
  }
126
162
 
163
+ // src/brandParam.ts
164
+ var BRAND_COLOR_PARAM_KEYS = {
165
+ colorPrimary: "p",
166
+ colorBackground: "bg",
167
+ colorText: "t",
168
+ colorDanger: "d",
169
+ colorBorder: "b"
170
+ };
171
+ function normalizeBrandHex(value) {
172
+ if (typeof value !== "string") return null;
173
+ const hex = value.trim().toLowerCase();
174
+ if (!/^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/.test(hex)) return null;
175
+ const digits = hex.slice(1);
176
+ if (digits.length === 6) return `#${digits}`;
177
+ return `#${digits[0]}${digits[0]}${digits[1]}${digits[1]}${digits[2]}${digits[2]}`;
178
+ }
179
+
180
+ // src/balanceParam.ts
181
+ var MAX_BALANCE_USD = 1e12;
182
+ function normalizeDisplayBalance(value) {
183
+ if (typeof value !== "number" || !Number.isFinite(value)) return null;
184
+ if (value < 0 || value >= MAX_BALANCE_USD) return null;
185
+ const cents = Math.floor(Number((value * 100).toPrecision(15)));
186
+ if (cents < 0 || cents >= MAX_BALANCE_USD * 100) return null;
187
+ return (cents / 100).toFixed(2);
188
+ }
189
+
127
190
  // src/walletBridge/discover.ts
128
191
  function createWalletDiscoverer() {
129
192
  if (typeof window === "undefined") {
@@ -555,6 +618,84 @@ function measureViewportMetrics() {
555
618
  }
556
619
  }
557
620
 
621
+ // src/iframeCore.ts
622
+ function createFrameElement(url) {
623
+ const iframe = document.createElement("iframe");
624
+ iframe.src = url;
625
+ const iframeOrigin = new URL(url).origin;
626
+ iframe.allow = `publickey-credentials-get ${iframeOrigin}; publickey-credentials-create ${iframeOrigin}; clipboard-write ${iframeOrigin}; web-share ${iframeOrigin}`;
627
+ return { iframe, iframeOrigin };
628
+ }
629
+ function attachFrameBridges(options) {
630
+ const { iframe, iframeOrigin, postViewport, getEmbedMaxHeightPx, shouldRevealOnLoad } = options;
631
+ let detached = false;
632
+ const discoverer = createWalletDiscoverer();
633
+ let rpcHostHandle = null;
634
+ const attachBridge = () => {
635
+ if (rpcHostHandle) return;
636
+ if (!iframe.contentWindow) return;
637
+ rpcHostHandle = attachRpcHost({
638
+ iframeWindow: iframe.contentWindow,
639
+ iframeOrigin,
640
+ discoverer
641
+ });
642
+ };
643
+ attachBridge();
644
+ iframe.addEventListener("load", attachBridge);
645
+ function postReveal() {
646
+ if (detached) return;
647
+ iframe.contentWindow?.postMessage(buildRevealMessage(), iframeOrigin);
648
+ }
649
+ function postViewportMetrics() {
650
+ if (detached || !postViewport) return;
651
+ const metrics = measureViewportMetrics();
652
+ if (metrics.viewportLvh <= 0) return;
653
+ iframe.contentWindow?.postMessage(
654
+ buildViewportMessage(
655
+ metrics.viewportLvh,
656
+ metrics.safeAreaBottom,
657
+ getEmbedMaxHeightPx?.()
658
+ ),
659
+ iframeOrigin
660
+ );
661
+ }
662
+ let viewportPostScheduled = false;
663
+ const onViewportChange = () => {
664
+ if (viewportPostScheduled) return;
665
+ viewportPostScheduled = true;
666
+ requestAnimationFrame(() => {
667
+ viewportPostScheduled = false;
668
+ postViewportMetrics();
669
+ });
670
+ };
671
+ if (postViewport) {
672
+ window.addEventListener("resize", onViewportChange);
673
+ window.addEventListener("orientationchange", onViewportChange);
674
+ }
675
+ const onLoadReveal = () => {
676
+ postViewportMetrics();
677
+ if (shouldRevealOnLoad()) postReveal();
678
+ };
679
+ iframe.addEventListener("load", onLoadReveal);
680
+ return {
681
+ postReveal,
682
+ postViewportMetrics,
683
+ detach() {
684
+ if (detached) return;
685
+ detached = true;
686
+ iframe.removeEventListener("load", attachBridge);
687
+ iframe.removeEventListener("load", onLoadReveal);
688
+ window.removeEventListener("resize", onViewportChange);
689
+ window.removeEventListener("orientationchange", onViewportChange);
690
+ if (rpcHostHandle) {
691
+ rpcHostHandle.detach();
692
+ rpcHostHandle = null;
693
+ }
694
+ discoverer.destroy();
695
+ }
696
+ };
697
+ }
698
+
558
699
  // src/iframe.ts
559
700
  var STYLE_ID = "blink-deposit-styles";
560
701
  var CLOSE_DURATION_MS = 280;
@@ -596,60 +737,20 @@ function createIframe(url, containerElement, options) {
596
737
  }
597
738
  const container = document.createElement("div");
598
739
  container.setAttribute("data-blink-container", "");
599
- const iframe = document.createElement("iframe");
600
- iframe.src = url;
601
- const iframeOrigin = new URL(url).origin;
602
- iframe.allow = `publickey-credentials-get ${iframeOrigin}; publickey-credentials-create ${iframeOrigin}; clipboard-write ${iframeOrigin}`;
740
+ const { iframe, iframeOrigin } = createFrameElement(url);
603
741
  const handle = document.createElement("div");
604
742
  container.appendChild(handle);
605
743
  container.appendChild(iframe);
606
744
  overlay.appendChild(container);
607
745
  const mountTarget = containerElement ?? document.body;
608
746
  mountTarget.appendChild(overlay);
609
- const discoverer = createWalletDiscoverer();
610
- let rpcHostHandle = null;
611
- const attachBridge = () => {
612
- if (rpcHostHandle) return;
613
- if (!iframe.contentWindow) return;
614
- rpcHostHandle = attachRpcHost({
615
- iframeWindow: iframe.contentWindow,
616
- iframeOrigin,
617
- discoverer
618
- });
619
- };
620
- attachBridge();
621
- iframe.addEventListener("load", attachBridge);
622
- function postReveal() {
623
- if (closed) return;
624
- iframe.contentWindow?.postMessage(buildRevealMessage(), iframeOrigin);
625
- }
626
- function postViewportMetrics() {
627
- if (closed || !options?.fluid) return;
628
- const metrics = measureViewportMetrics();
629
- if (metrics.viewportLvh <= 0) return;
630
- iframe.contentWindow?.postMessage(
631
- buildViewportMessage(metrics.viewportLvh, metrics.safeAreaBottom),
632
- iframeOrigin
633
- );
634
- }
635
- let viewportPostScheduled = false;
636
- const onViewportChange = () => {
637
- if (viewportPostScheduled) return;
638
- viewportPostScheduled = true;
639
- requestAnimationFrame(() => {
640
- viewportPostScheduled = false;
641
- postViewportMetrics();
642
- });
643
- };
644
- if (options?.fluid) {
645
- window.addEventListener("resize", onViewportChange);
646
- window.addEventListener("orientationchange", onViewportChange);
647
- }
648
- const onLoadReveal = () => {
649
- postViewportMetrics();
650
- if (!hidden) postReveal();
651
- };
652
- iframe.addEventListener("load", onLoadReveal);
747
+ const bridges = attachFrameBridges({
748
+ iframe,
749
+ iframeOrigin,
750
+ // Only fluid needs them: the legacy fixed overlay sizes the iframe itself.
751
+ postViewport: options?.fluid === true,
752
+ shouldRevealOnLoad: () => !hidden
753
+ });
653
754
  let savedOverflow = "";
654
755
  let scrollLocked = false;
655
756
  const onBackdropClick = (event) => {
@@ -680,8 +781,6 @@ function createIframe(url, containerElement, options) {
680
781
  overlay.removeEventListener("click", onBackdropClick);
681
782
  overlay.removeEventListener("touchmove", onTouchMove);
682
783
  document.removeEventListener("keydown", onKeyDown);
683
- window.removeEventListener("resize", onViewportChange);
684
- window.removeEventListener("orientationchange", onViewportChange);
685
784
  }
686
785
  function unlockScroll() {
687
786
  if (!scrollLocked) return;
@@ -692,7 +791,7 @@ function createIframe(url, containerElement, options) {
692
791
  if (closed) return;
693
792
  closed = true;
694
793
  removeListeners();
695
- detachBridge();
794
+ bridges.detach();
696
795
  overlay.setAttribute("data-blink-closing", "");
697
796
  let removed = false;
698
797
  const removeOverlay = () => {
@@ -705,15 +804,6 @@ function createIframe(url, containerElement, options) {
705
804
  container.addEventListener("animationend", removeOverlay, { once: true });
706
805
  setTimeout(removeOverlay, CLOSE_DURATION_MS + 50);
707
806
  }
708
- function detachBridge() {
709
- iframe.removeEventListener("load", attachBridge);
710
- iframe.removeEventListener("load", onLoadReveal);
711
- if (rpcHostHandle) {
712
- rpcHostHandle.detach();
713
- rpcHostHandle = null;
714
- }
715
- discoverer.destroy();
716
- }
717
807
  return {
718
808
  get contentWindow() {
719
809
  return iframe.contentWindow;
@@ -738,15 +828,17 @@ function createIframe(url, containerElement, options) {
738
828
  overlay.style.display = "";
739
829
  lockScroll();
740
830
  attachDismissalListeners();
741
- postViewportMetrics();
742
- postReveal();
831
+ bridges.postViewportMetrics();
832
+ bridges.postReveal();
743
833
  },
744
834
  postReveal() {
745
- postReveal();
835
+ bridges.postReveal();
746
836
  },
747
837
  downgradeToFixed() {
748
838
  overlay.removeAttribute("data-blink-fluid");
749
839
  },
840
+ applyContentHeight() {
841
+ },
750
842
  onClose(callback) {
751
843
  closeCallback = callback;
752
844
  },
@@ -755,7 +847,7 @@ function createIframe(url, containerElement, options) {
755
847
  if (!closed) {
756
848
  closed = true;
757
849
  removeListeners();
758
- detachBridge();
850
+ bridges.detach();
759
851
  overlay.remove();
760
852
  unlockScroll();
761
853
  }
@@ -792,6 +884,117 @@ function shouldUseMobileSheetLayout() {
792
884
  return shortestScreenSide > 0 && shortestScreenSide <= MOBILE_SHEET_MAX_SCREEN_PX;
793
885
  }
794
886
 
887
+ // src/embeddedIframe.ts
888
+ var EMBED_STYLE_ID = "blink-deposit-embed-styles";
889
+ var EMBED_STYLES = `
890
+ [data-blink-embed]{display:block;width:100%;position:relative}
891
+ [data-blink-embed][data-blink-embed-hidden]{height:0;overflow:hidden;visibility:hidden;pointer-events:none}
892
+ [data-blink-embed] iframe{display:block;width:100%;height:0;border:none;background:transparent;color-scheme:light}
893
+ `;
894
+ function createEmbeddedIframe(url, containerElement, options) {
895
+ ensureEmbedStyles();
896
+ if (!containerElement.isConnected) {
897
+ console.warn(
898
+ "[blink] The containerElement passed to Deposit is not in the document. Embedded mode renders into it for the life of the instance, so it must be a stable node \u2014 render the slot once and hide it with `visibility: hidden` (NOT `display: none`, which removes its layout and leaves the warming flow unable to measure itself) instead of unmounting it, or construct a new Deposit against the new node."
899
+ );
900
+ }
901
+ let closed = false;
902
+ let hidden = options?.hidden === true;
903
+ let closeCallback = null;
904
+ const readEmbedMaxHeightPx = () => typeof options?.embedMaxHeightPx === "function" ? options.embedMaxHeightPx() : options?.embedMaxHeightPx;
905
+ const wrapper = document.createElement("div");
906
+ wrapper.setAttribute("data-blink-embed", "");
907
+ if (hidden) {
908
+ wrapper.setAttribute("data-blink-embed-hidden", "");
909
+ }
910
+ const { iframe, iframeOrigin } = createFrameElement(url);
911
+ iframe.setAttribute("scrolling", "no");
912
+ const initialBudget = readEmbedMaxHeightPx();
913
+ if (initialBudget !== void 0) {
914
+ iframe.style.height = `${initialBudget}px`;
915
+ }
916
+ wrapper.appendChild(iframe);
917
+ containerElement.appendChild(wrapper);
918
+ const bridges = attachFrameBridges({
919
+ iframe,
920
+ iframeOrigin,
921
+ // Embedded always needs them: the flow has no usable viewport of its own,
922
+ // so the basis it sizes against can only come from here.
923
+ postViewport: true,
924
+ getEmbedMaxHeightPx: readEmbedMaxHeightPx,
925
+ shouldRevealOnLoad: () => !hidden
926
+ });
927
+ function teardown() {
928
+ closed = true;
929
+ bridges.detach();
930
+ wrapper.remove();
931
+ }
932
+ return {
933
+ get contentWindow() {
934
+ return iframe.contentWindow;
935
+ },
936
+ close() {
937
+ if (closed) return;
938
+ teardown();
939
+ closeCallback?.();
940
+ },
941
+ isClosed() {
942
+ return closed;
943
+ },
944
+ isHidden() {
945
+ return hidden;
946
+ },
947
+ reveal() {
948
+ if (closed || !hidden) return;
949
+ hidden = false;
950
+ wrapper.removeAttribute("data-blink-embed-hidden");
951
+ warnIfContainerNotRendered(containerElement);
952
+ bridges.postViewportMetrics();
953
+ bridges.postReveal();
954
+ },
955
+ postReveal() {
956
+ bridges.postReveal();
957
+ },
958
+ downgradeToFixed() {
959
+ },
960
+ applyContentHeight(heightPx) {
961
+ if (closed) return;
962
+ const budget = readEmbedMaxHeightPx();
963
+ const capped = budget === void 0 ? heightPx : Math.min(heightPx, budget);
964
+ iframe.style.height = `${Math.max(0, Math.round(capped))}px`;
965
+ },
966
+ onClose(callback) {
967
+ closeCallback = callback;
968
+ },
969
+ destroy() {
970
+ closeCallback = null;
971
+ if (!closed) teardown();
972
+ }
973
+ };
974
+ }
975
+ function warnIfContainerNotRendered(containerElement) {
976
+ if (typeof containerElement.getBoundingClientRect !== "function") return;
977
+ const rect = containerElement.getBoundingClientRect();
978
+ if (rect.width > 0 || rect.height > 0) return;
979
+ const display = typeof getComputedStyle === "function" ? getComputedStyle(containerElement).display : "";
980
+ console.warn(
981
+ `[blink] The containerElement has no layout at the moment the flow is revealed${display === "none" ? " (display: none)" : ""}. A display:none subtree cannot be measured, so the flow may report no height and the panel stays blank at its budget height. Hide the slot with \`visibility: hidden\` (plus \`height: 0; overflow: hidden\`) instead of \`display: none\`, so it keeps its box while it warms up.`
982
+ );
983
+ }
984
+ function ensureEmbedStyles() {
985
+ const existingStyle = document.getElementById(EMBED_STYLE_ID);
986
+ if (existingStyle) {
987
+ if (existingStyle.textContent !== EMBED_STYLES) {
988
+ existingStyle.textContent = EMBED_STYLES;
989
+ }
990
+ return;
991
+ }
992
+ const style = document.createElement("style");
993
+ style.id = EMBED_STYLE_ID;
994
+ style.textContent = EMBED_STYLES;
995
+ document.head.appendChild(style);
996
+ }
997
+
795
998
  // src/signer.ts
796
999
  var DEFAULT_SIGNER_TIMEOUT_MS = 15e3;
797
1000
  async function callSigner(signer, request, timeoutMs) {
@@ -922,6 +1125,13 @@ var SANDBOX_WEBVIEW_BASE_URL = "https://pay-sandbox.blink.cash";
922
1125
  var IFRAME_READY_TIMEOUT_MS = 2e3;
923
1126
  var WARM_IFRAME_READY_TIMEOUT_MS = 6e4;
924
1127
  var LAYOUT_FLUID_CAPABILITY = "layout-fluid";
1128
+ var DEFAULT_EMBED_MAX_HEIGHT_FRACTION = 0.9;
1129
+ function dismissedError() {
1130
+ return new DepositError(
1131
+ "DEPOSIT_DISMISSED",
1132
+ "The deposit was dismissed before the transfer completed."
1133
+ );
1134
+ }
925
1135
  var IFRAME_NOT_READY = { ready: false, fluidCapable: false };
926
1136
  function resolveWebviewBaseUrl(config) {
927
1137
  if (config.webviewBaseUrl) return config.webviewBaseUrl;
@@ -942,11 +1152,20 @@ var Deposit = class {
942
1152
  warmIframe = null;
943
1153
  warmIframeReady = null;
944
1154
  warmIframeReadyCancel = null;
1155
+ /** Resolved embedded-vs-overlay decision; see {@link Deposit.isEmbedded}. */
1156
+ embeddedResolved = null;
1157
+ /**
1158
+ * Settles the in-flight `requestDeposit()` as a dismissal. Non-null exactly
1159
+ * while a flow is in progress, so {@link Deposit.close} can reject the promise
1160
+ * the caller is awaiting instead of tearing the frame down under it.
1161
+ */
1162
+ dismissActiveFlow = null;
945
1163
  listeners = {
946
1164
  complete: /* @__PURE__ */ new Set(),
947
1165
  error: /* @__PURE__ */ new Set(),
948
1166
  close: /* @__PURE__ */ new Set(),
949
- "status-change": /* @__PURE__ */ new Set()
1167
+ "status-change": /* @__PURE__ */ new Set(),
1168
+ resize: /* @__PURE__ */ new Set()
950
1169
  };
951
1170
  /** Current phase of the deposit flow. */
952
1171
  get status() {
@@ -964,10 +1183,40 @@ var Deposit = class {
964
1183
  get isActive() {
965
1184
  return this._status === "signer-loading" || this._status === "iframe-active";
966
1185
  }
1186
+ /**
1187
+ * How this instance will actually present: `'embedded'` renders inline in
1188
+ * `containerElement`, `'overlay'` covers the page.
1189
+ *
1190
+ * Not simply an echo of the config — a host that asked for `'embedded'` gets
1191
+ * `'overlay'` on mobile, where a phone-sized method panel cannot hold the
1192
+ * flow (see {@link Deposit.isEmbedded}). Read it when laying out the panel:
1193
+ * on `'overlay'` no `resize` event ever fires, so a panel sized from those
1194
+ * reports would sit empty behind the overlay — collapse or skip it.
1195
+ *
1196
+ * Live until the SDK builds its first (warm-up) frame and fixed from then on,
1197
+ * so re-read it on resize rather than caching it at mount.
1198
+ */
1199
+ get presentation() {
1200
+ return this.isEmbedded() ? "embedded" : "overlay";
1201
+ }
967
1202
  constructor(config) {
968
1203
  if (!config.signer || typeof config.signer !== "string" && typeof config.signer !== "function") {
969
1204
  throw new DepositError("INVALID_REQUEST", "DepositConfig.signer is required (URL string or SignerFunction).");
970
1205
  }
1206
+ if (config.presentation === "embedded") {
1207
+ if (!config.containerElement) {
1208
+ throw new DepositError(
1209
+ "INVALID_REQUEST",
1210
+ 'DepositConfig.containerElement is required when presentation is "embedded".'
1211
+ );
1212
+ }
1213
+ if (config.layout === "fixed") {
1214
+ throw new DepositError(
1215
+ "INVALID_REQUEST",
1216
+ 'DepositConfig.layout "fixed" is an overlay-only escape hatch and cannot be combined with presentation "embedded".'
1217
+ );
1218
+ }
1219
+ }
971
1220
  this.config = config;
972
1221
  this.log("Deposit instance created", {
973
1222
  signer: typeof config.signer === "string" ? config.signer : "<function>"
@@ -993,10 +1242,7 @@ var Deposit = class {
993
1242
  const webviewBaseUrl = resolveWebviewBaseUrl(this.config);
994
1243
  this.hostedOrigin = this.config.hostedFlowOrigin ?? new URL(webviewBaseUrl).origin;
995
1244
  const preloadUrl = this.buildPreloadUrl(webviewBaseUrl);
996
- const iframe = createIframe(preloadUrl, this.config.containerElement, {
997
- hidden: true,
998
- fluid: this.isFluidLayout()
999
- });
1245
+ const iframe = this.createPresentedIframe(preloadUrl, { hidden: true });
1000
1246
  const ready = this.waitForIframeReady(iframe, WARM_IFRAME_READY_TIMEOUT_MS);
1001
1247
  this.warmIframe = iframe;
1002
1248
  this.warmIframeReady = ready.promise;
@@ -1025,6 +1271,83 @@ var Deposit = class {
1025
1271
  window.addEventListener("load", whenIdle, { once: true });
1026
1272
  }
1027
1273
  }
1274
+ /**
1275
+ * Whether the flow renders inline in the host's element rather than as an
1276
+ * overlay.
1277
+ *
1278
+ * `presentation: 'embedded'` is a request, not a guarantee: **on mobile the
1279
+ * flow presents as the normal overlay** even for an embedded host. An
1280
+ * aggregator's method panel is a narrow, short column on a phone, and the
1281
+ * flow inside it is a payment journey with a keypad, wallet lists and a QR
1282
+ * code — it needs the screen. The overlay is exactly what a phone user gets
1283
+ * from every other Blink integration, and the host's own dialog stays behind
1284
+ * it. Desktop keeps the inline card, where the panel has room.
1285
+ *
1286
+ * **The decision lives with the frame.** Read live until a frame is built,
1287
+ * then committed for good ({@link Deposit.createPresentedIframe}) — `preload()`
1288
+ * warms a frame from these same predicates and the flow that later adopts it
1289
+ * must agree, because a frame warmed at `layout=embed` handed to the overlay
1290
+ * presenter (or the reverse) is a blank modal. Committing at frame creation
1291
+ * rather than at construction matters: a host that constructs `Deposit` before
1292
+ * layout settles — a widget mounting inside a transition, a background or
1293
+ * prerendered tab — reports a zero-width viewport, and `(max-width: 640px)`
1294
+ * matches at zero, so caching then would lock a desktop user into the overlay
1295
+ * over a viewport that never existed. The warm-up already waits for `load`
1296
+ * plus an idle callback, so by the time it commits the measurement is real.
1297
+ */
1298
+ isEmbedded() {
1299
+ if (this.config.presentation !== "embedded") return false;
1300
+ if (this.embeddedResolved !== null) return this.embeddedResolved;
1301
+ if (typeof window === "undefined") return true;
1302
+ const viewportWidth = window.visualViewport?.width ?? window.innerWidth;
1303
+ if (!(viewportWidth > 0)) return true;
1304
+ return !shouldUseMobileSheetLayout();
1305
+ }
1306
+ /**
1307
+ * The host's height budget for the inline iframe. Explicit config wins;
1308
+ * otherwise most of the host viewport, which keeps a tall flow from
1309
+ * outgrowing the page it is embedded in.
1310
+ */
1311
+ resolveEmbedMaxHeightPx() {
1312
+ if (this.config.embedMaxHeightPx !== void 0) return this.config.embedMaxHeightPx;
1313
+ if (typeof window === "undefined") return void 0;
1314
+ const derived = Math.round(window.innerHeight * DEFAULT_EMBED_MAX_HEIGHT_FRACTION);
1315
+ return derived > 0 ? derived : void 0;
1316
+ }
1317
+ /** Build the iframe for the configured presentation. */
1318
+ createPresentedIframe(url, options) {
1319
+ if (this.config.presentation === "embedded" && this.embeddedResolved === null) {
1320
+ this.embeddedResolved = this.isEmbedded();
1321
+ if (!this.embeddedResolved) {
1322
+ this.log("Mobile device: presenting the embedded flow as an overlay");
1323
+ }
1324
+ }
1325
+ if (this.isEmbedded()) {
1326
+ return createEmbeddedIframe(url, this.config.containerElement, {
1327
+ hidden: options.hidden,
1328
+ // A getter, not a value: the default is derived from the host
1329
+ // viewport, which changes on rotation and window resize.
1330
+ embedMaxHeightPx: () => this.resolveEmbedMaxHeightPx()
1331
+ });
1332
+ }
1333
+ return createIframe(url, this.overlayMountTarget(), {
1334
+ hidden: options.hidden,
1335
+ fluid: this.isFluidLayout()
1336
+ });
1337
+ }
1338
+ /**
1339
+ * Where the overlay mounts. `containerElement` means two different things by
1340
+ * presentation: an overlay mount point (`presentation: 'overlay'`) or the
1341
+ * aggregator's inline panel slot (`'embedded'`). When an embedded flow
1342
+ * presents as an overlay on mobile, that slot must NOT be the mount point —
1343
+ * `position: fixed` resolves against the nearest transformed/filtered/
1344
+ * contained ancestor, and an aggregator's animated dialog is exactly that, so
1345
+ * the "full-screen" overlay would end up positioned inside their panel.
1346
+ * Falls through to `document.body`.
1347
+ */
1348
+ overlayMountTarget() {
1349
+ return this.config.presentation === "embedded" ? void 0 : this.config.containerElement;
1350
+ }
1028
1351
  buildPreloadUrl(webviewBaseUrl) {
1029
1352
  const preloadUrl = new URL(webviewBaseUrl);
1030
1353
  preloadUrl.searchParams.set("preload", "true");
@@ -1090,6 +1413,7 @@ var Deposit = class {
1090
1413
  const settle = (fn) => {
1091
1414
  if (this.requestId !== currentRequestId || settled) return;
1092
1415
  settled = true;
1416
+ this.dismissActiveFlow = null;
1093
1417
  fn();
1094
1418
  };
1095
1419
  const onComplete = (result) => {
@@ -1115,6 +1439,12 @@ var Deposit = class {
1115
1439
  reject(error);
1116
1440
  });
1117
1441
  };
1442
+ this.dismissActiveFlow = () => {
1443
+ settle(() => {
1444
+ this.cleanup();
1445
+ reject(dismissedError());
1446
+ });
1447
+ };
1118
1448
  if (this.config.flowTimeoutMs != null && this.config.flowTimeoutMs > 0) {
1119
1449
  this.flowTimer = setTimeout(() => {
1120
1450
  onError(
@@ -1141,9 +1471,29 @@ var Deposit = class {
1141
1471
  /** No-op — retained for API compatibility with the popup-based SDK. */
1142
1472
  focus() {
1143
1473
  }
1144
- /** Close the deposit iframe without waiting for completion. */
1474
+ /**
1475
+ * Close the deposit iframe without waiting for completion — the host's own
1476
+ * back button or dialog chrome.
1477
+ *
1478
+ * This **settles a flow in progress** by rejecting its `requestDeposit()`
1479
+ * promise with `DEPOSIT_DISMISSED`, exactly as the flow's own close control
1480
+ * does. It has to: `cleanup()` destroys the frame through `destroy()`, which
1481
+ * drops the handle's close callback on purpose, so without this the caller's
1482
+ * `await` never returns. An aggregator whose back button awaited that promise
1483
+ * to restore its method list was left showing an empty panel — the flow gone,
1484
+ * its own list still not rendered, and no error to explain why.
1485
+ *
1486
+ * The rejection code is the same `DEPOSIT_DISMISSED` the in-flow control
1487
+ * produces, so a host writes one dismissal branch rather than one per
1488
+ * affordance. What it deliberately does not do is report an error: no `error`
1489
+ * event, and `status` lands on 'idle' as it always has, because the host
1490
+ * initiated this. With no flow in progress it is a plain teardown, unchanged.
1491
+ */
1145
1492
  close() {
1146
1493
  this.log("close() called");
1494
+ const dismiss = this.dismissActiveFlow;
1495
+ this.dismissActiveFlow = null;
1496
+ dismiss?.();
1147
1497
  this.cleanup();
1148
1498
  this.setStatus("idle");
1149
1499
  this.emit("close");
@@ -1196,10 +1546,80 @@ var Deposit = class {
1196
1546
  if (theme === "dark" || theme === "system") {
1197
1547
  url.searchParams.set("appearance", theme);
1198
1548
  }
1549
+ this.applyBrandParam(url);
1199
1550
  }
1200
- /** Fluid is the default; `layout: 'fixed'` opts back into the legacy container. */
1551
+ /**
1552
+ * Appends the merchant's brand colors as `brand=p-0f62fe.bg-ffffff…`.
1553
+ *
1554
+ * On the URL rather than a message because the hosted flow's loading shell
1555
+ * paints from its entry chunk, hundreds of ms before React mounts: a palette
1556
+ * that arrived by `postMessage` would show Blink's own card color first and
1557
+ * then become the merchant's. Unreserved characters only, so the param costs
1558
+ * ~50 bytes of the URL budget rather than triple that in percent-encoding.
1559
+ *
1560
+ * Validated here purely so the merchant sees the complaint in their OWN
1561
+ * console — the hosted flow re-validates everything it decodes, since the URL
1562
+ * is host-controlled input that ends up in a stylesheet. Silence would be the
1563
+ * worst outcome: a dropped color that nobody is told about looks like the SDK
1564
+ * ignoring the config.
1565
+ */
1566
+ applyBrandParam(url) {
1567
+ const variables = this.config.appearance?.variables;
1568
+ if (!variables) return;
1569
+ const accepted = {};
1570
+ for (const [key, value] of Object.entries(variables)) {
1571
+ if (value === void 0 || value === null) continue;
1572
+ if (!BRAND_COLOR_PARAM_KEYS[key]) {
1573
+ console.error(
1574
+ `[blink] appearance.variables.${key} is not a supported brand color. Supported: ${Object.keys(BRAND_COLOR_PARAM_KEYS).join(", ")}.`
1575
+ );
1576
+ continue;
1577
+ }
1578
+ const hex = normalizeBrandHex(value);
1579
+ if (!hex) {
1580
+ console.error(
1581
+ `[blink] appearance.variables.${key} must be opaque hex (#rgb or #rrggbb); received ${JSON.stringify(value)}. Ignoring it.`
1582
+ );
1583
+ continue;
1584
+ }
1585
+ accepted[key] = hex;
1586
+ }
1587
+ const encoded = Object.keys(BRAND_COLOR_PARAM_KEYS).filter((key) => accepted[key] !== void 0).map((key) => `${BRAND_COLOR_PARAM_KEYS[key]}-${accepted[key].slice(1)}`).join(".");
1588
+ if (encoded) url.searchParams.set("brand", encoded);
1589
+ }
1590
+ /**
1591
+ * Normalizes the display-only {@link DepositRequest.balance} for the wire,
1592
+ * or returns `undefined` to omit it everywhere — the `blink:signed-payload`
1593
+ * sibling field and the legacy fallback URL param alike. Additive wire
1594
+ * discipline: an unset or rejected balance leaves both channels
1595
+ * byte-for-byte what they always were, and old webviews ignore the field
1596
+ * by construction.
1597
+ *
1598
+ * Validated here purely so the merchant sees the complaint in their OWN
1599
+ * console (same rationale as {@link applyBrandParam}); the hosted flow
1600
+ * re-validates whatever arrives, since both channels are host-controlled
1601
+ * input. A rejected balance never blocks the deposit — the flow proceeds
1602
+ * without the subtitle.
1603
+ */
1604
+ normalizeRequestBalance(balance) {
1605
+ if (balance === void 0 || balance === null) return void 0;
1606
+ const normalized = normalizeDisplayBalance(balance);
1607
+ if (normalized === null) {
1608
+ const received = typeof balance === "number" ? String(balance) : JSON.stringify(balance) ?? String(balance);
1609
+ console.error(
1610
+ `[blink] balance must be a finite number >= 0 and < 1e12 (USD, display only); received ${received}. Ignoring it.`
1611
+ );
1612
+ return void 0;
1613
+ }
1614
+ return normalized;
1615
+ }
1616
+ /**
1617
+ * Fluid is the default; `layout: 'fixed'` opts back into the legacy
1618
+ * container. Embedded is a separate presentation and never fluid — the
1619
+ * constructor rejects the combination outright.
1620
+ */
1201
1621
  isFluidLayout() {
1202
- return this.config.layout !== "fixed";
1622
+ return !this.isEmbedded() && this.config.layout !== "fixed";
1203
1623
  }
1204
1624
  /**
1205
1625
  * Marks the hosted-flow URL as fluid-layout: the iframe spans the full
@@ -1209,6 +1629,10 @@ var Deposit = class {
1209
1629
  * assumed to understand fluid layout and gets the fixed container.
1210
1630
  */
1211
1631
  applyLayoutParam(url) {
1632
+ if (this.isEmbedded()) {
1633
+ url.searchParams.set("layout", "embed");
1634
+ return;
1635
+ }
1212
1636
  if (this.isFluidLayout()) {
1213
1637
  url.searchParams.set("layout", "fluid");
1214
1638
  }
@@ -1220,7 +1644,14 @@ var Deposit = class {
1220
1644
  * afterwards (rotation, window resize) — see iframe.ts.
1221
1645
  */
1222
1646
  applyViewportParams(url) {
1223
- if (!this.isFluidLayout()) return;
1647
+ const embedded = this.isEmbedded();
1648
+ if (!this.isFluidLayout() && !embedded) return;
1649
+ if (embedded) {
1650
+ const embedMaxHeightPx = this.resolveEmbedMaxHeightPx();
1651
+ if (embedMaxHeightPx !== void 0) {
1652
+ url.searchParams.set("embedMaxHeight", String(embedMaxHeightPx));
1653
+ }
1654
+ }
1224
1655
  const metrics = measureViewportMetrics();
1225
1656
  if (metrics.viewportLvh <= 0) return;
1226
1657
  url.searchParams.set("viewportLvh", String(metrics.viewportLvh));
@@ -1240,6 +1671,7 @@ var Deposit = class {
1240
1671
  signer: typeof this.config.signer === "string" ? this.config.signer : "<function>"
1241
1672
  });
1242
1673
  const signerRequest = buildSignerRequest(request, webviewBaseUrl);
1674
+ const displayBalance = this.normalizeRequestBalance(request.balance);
1243
1675
  let preloadIframe;
1244
1676
  let iframeReadyPromise;
1245
1677
  const warm = this.takeWarmIframe();
@@ -1257,21 +1689,12 @@ var Deposit = class {
1257
1689
  });
1258
1690
  this.log("Reusing warm preload iframe");
1259
1691
  } else {
1260
- preloadIframe = createIframe(
1261
- this.buildPreloadUrl(webviewBaseUrl),
1262
- this.config.containerElement,
1263
- { fluid: this.isFluidLayout() }
1264
- );
1692
+ preloadIframe = this.createPresentedIframe(this.buildPreloadUrl(webviewBaseUrl), {});
1265
1693
  iframeReadyPromise = this.waitForIframeReady(preloadIframe).promise;
1266
1694
  }
1267
1695
  this.iframe = preloadIframe;
1268
1696
  preloadIframe.onClose(() => {
1269
- onError(
1270
- new DepositError(
1271
- "DEPOSIT_DISMISSED",
1272
- "The deposit was dismissed before the transfer completed."
1273
- )
1274
- );
1697
+ onError(dismissedError());
1275
1698
  this.cleanup();
1276
1699
  this.emit("close");
1277
1700
  });
@@ -1301,7 +1724,8 @@ var Deposit = class {
1301
1724
  buildSignedPayloadMessage(
1302
1725
  signerResponse.merchantId,
1303
1726
  signerResponse.payload,
1304
- signerResponse.signature
1727
+ signerResponse.signature,
1728
+ displayBalance
1305
1729
  ),
1306
1730
  this.hostedOrigin
1307
1731
  );
@@ -1315,17 +1739,19 @@ var Deposit = class {
1315
1739
  hostedUrl.searchParams.set("merchantId", signerResponse.merchantId);
1316
1740
  hostedUrl.searchParams.set("payload", signerResponse.payload);
1317
1741
  hostedUrl.searchParams.set("signature", signerResponse.signature);
1742
+ if (displayBalance !== void 0) {
1743
+ hostedUrl.searchParams.set("balance", displayBalance);
1744
+ }
1318
1745
  this.applyFullWidgetParam(hostedUrl);
1746
+ if (this.isEmbedded()) {
1747
+ this.applyLayoutParam(hostedUrl);
1748
+ this.applyViewportParams(hostedUrl);
1749
+ }
1319
1750
  const targetUrl = hostedUrl.toString();
1320
- const iframeHandle = createIframe(targetUrl, this.config.containerElement);
1751
+ const iframeHandle = this.createPresentedIframe(targetUrl, {});
1321
1752
  this.iframe = iframeHandle;
1322
1753
  iframeHandle.onClose(() => {
1323
- onError(
1324
- new DepositError(
1325
- "DEPOSIT_DISMISSED",
1326
- "The deposit was dismissed before the transfer completed."
1327
- )
1328
- );
1754
+ onError(dismissedError());
1329
1755
  this.cleanup();
1330
1756
  this.emit("close");
1331
1757
  });
@@ -1401,6 +1827,16 @@ var Deposit = class {
1401
1827
  iframeHandle.close();
1402
1828
  return;
1403
1829
  }
1830
+ const contentHeight = parseContentHeight(event.data);
1831
+ if (contentHeight) {
1832
+ iframeHandle.applyContentHeight(contentHeight.heightPx);
1833
+ this.emit("resize", {
1834
+ heightPx: contentHeight.heightPx,
1835
+ ...contentHeight.preferredWidthPx === void 0 ? {} : { preferredWidthPx: contentHeight.preferredWidthPx },
1836
+ ...contentHeight.minWidthPx === void 0 ? {} : { minWidthPx: contentHeight.minWidthPx }
1837
+ });
1838
+ return;
1839
+ }
1404
1840
  const message = parseTransferComplete(event.data);
1405
1841
  if (!message) {
1406
1842
  return;