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