@revenuecat/purchases-ui-js 3.12.1 → 4.1.0

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.
@@ -1,5 +1,6 @@
1
1
  <script module lang="ts">
2
2
  import { defineMeta } from "@storybook/addon-svelte-csf";
3
+ import type { ComponentProps } from "svelte";
3
4
 
4
5
  import Paywall from "./Paywall.svelte";
5
6
  import {
@@ -31,12 +32,16 @@
31
32
  import { CUSTOM_VARIABLES_PAYWALL } from "./fixtures/custom-variables-paywall";
32
33
  import { COUNTDOWN_PAYWALL } from "../countdown/fixtures/countdown-paywall";
33
34
  import { mockDateDecorator } from "storybook-mock-date-decorator";
34
- import { IndividualPackageVariables } from "./fixtures/express-purchase-button-paywall";
35
+ import {
36
+ IndividualPackageVariables,
37
+ DUELINGUE_PAYWALL,
38
+ } from "./fixtures/express-purchase-button-paywall";
35
39
  import { CustomVariableValue } from "../../types/variables";
36
40
 
37
41
  const { Story } = defineMeta({
38
42
  title: "Example/Paywall",
39
43
  component: Paywall,
44
+ render: template,
40
45
  args: {
41
46
  onPurchaseClicked: (selectedPackageId: string, actionId: string) =>
42
47
  alert(
@@ -55,9 +60,11 @@
55
60
  });
56
61
  </script>
57
62
 
58
- <script>
59
- import { DUELINGUE_PAYWALL } from "./fixtures/express-purchase-button-paywall";
60
- </script>
63
+ {#snippet template(props: ComponentProps<typeof Paywall>)}
64
+ <div class="paywall-story-frame">
65
+ <Paywall {...props} />
66
+ </div>
67
+ {/snippet}
61
68
 
62
69
  <Story
63
70
  name="Stack paywall"
@@ -491,3 +498,13 @@
491
498
  },
492
499
  }}
493
500
  />
501
+
502
+ <style>
503
+ .paywall-story-frame {
504
+ flex: 1 1 auto;
505
+ display: flex;
506
+ flex-direction: column;
507
+ background-color: var(--rc-purchases-ui-bg-color, Canvas);
508
+ color-scheme: light dark;
509
+ }
510
+ </style>
@@ -32,15 +32,17 @@
32
32
  type PackageInfo,
33
33
  type VariableDictionary,
34
34
  } from "../../types/variables";
35
- import { mapBackground } from "../../utils/background-utils";
35
+ import { paywallRootBackgroundModel } from "../../utils/background-utils";
36
36
  import { css } from "../../utils/base-utils";
37
37
  import { STICKY_OVERLAY_Z_INDEX } from "../../utils/constants";
38
+ import { applyDocumentBackground } from "../../utils/document-background";
38
39
  import { registerFonts } from "../../utils/font-utils";
39
40
  import { findSelectedPackageId } from "../../utils/style-utils";
40
41
  import { onMount } from "svelte";
41
42
  import { derived, readable, writable } from "svelte/store";
42
43
  import Stack from "../stack/Stack.svelte";
43
44
  import Sheet from "./Sheet.svelte";
45
+ import ViewportBackdrop from "./ViewportBackdrop.svelte";
44
46
  import {
45
47
  type PackageInfoStore,
46
48
  setPackageInfoContext,
@@ -131,13 +133,38 @@
131
133
  }: Props = $props();
132
134
 
133
135
  const getColorMode = setColorModeContext(() => preferredColorMode);
134
- const colorMode = $derived(getColorMode());
136
+
137
+ const viewportBackdropModel = $derived(
138
+ paywallRootBackgroundModel(paywallData, getColorMode()),
139
+ );
140
+
141
+ const instanceId: symbol = Symbol();
142
+ $effect(() =>
143
+ applyDocumentBackground(instanceId, viewportBackdropModel, paywallData),
144
+ );
135
145
 
136
146
  const { default_locale, components_config, components_localizations } =
137
147
  paywallData;
138
148
  const { base } = components_config;
139
149
  const defaultPackageId = findSelectedPackageId(base);
140
150
 
151
+ // Solid colours paint here so the paywall has its own bg without requiring
152
+ // consumer CSS. Gradients and images are deliberately skipped — ViewportBackdrop
153
+ // paints those at viewport scale, and rendering them on both surfaces would
154
+ // anchor the gradient stops to two different boxes and produce a visible seam
155
+ // at the paywall edge.
156
+ const paywallStyle = $derived.by((): string => {
157
+ const m = viewportBackdropModel;
158
+ if (
159
+ m.kind === "style" &&
160
+ m.style.background &&
161
+ !m.style.background.includes("gradient")
162
+ ) {
163
+ return css(m.style);
164
+ }
165
+ return "";
166
+ });
167
+
141
168
  const { getLocalizedString } = setLocalizationContext(() => ({
142
169
  defaultLocale: default_locale,
143
170
  selectedLocale,
@@ -285,8 +312,6 @@
285
312
 
286
313
  setPackageInfoContext(packageInfo);
287
314
 
288
- const style = $derived(css(mapBackground(colorMode, null, base.background)));
289
-
290
315
  onMount(() => {
291
316
  registerFonts(uiConfig);
292
317
  });
@@ -305,7 +330,8 @@
305
330
  </script>
306
331
 
307
332
  <svelte:boundary onerror={onError}>
308
- <div class={paywallClass} {style}>
333
+ <div class={paywallClass} style={paywallStyle}>
334
+ <ViewportBackdrop model={viewportBackdropModel} />
309
335
  {#if header}
310
336
  <div
311
337
  class="header-wrapper"
@@ -321,12 +347,20 @@
321
347
  maxContentWidth
322
348
  ? `max-width: ${maxContentWidth}; margin-inline: auto;`
323
349
  : "",
324
- pullContentUnderHeader ? `margin-top: -${headerHeight}px;` : "",
325
350
  ]
326
351
  .filter(Boolean)
327
352
  .join(" ")}
328
353
  >
329
- <Stack {...stack} class="paywall-content-scroll" />
354
+ <Stack
355
+ {...stack}
356
+ class="paywall-content-scroll"
357
+ style={{
358
+ height: "auto",
359
+ ...(pullContentUnderHeader
360
+ ? { "margin-top": `-${headerHeight}px` }
361
+ : {}),
362
+ }}
363
+ />
330
364
  {#if sticky_footer}
331
365
  <Footer {...sticky_footer} />
332
366
  {/if}
@@ -351,18 +385,6 @@
351
385
  transition-timing-function: ease-in-out;
352
386
  transform-origin: center;
353
387
 
354
- &::before {
355
- content: "";
356
- position: absolute;
357
- top: 0;
358
- left: 0;
359
- width: 100%;
360
- height: 100%;
361
- background: var(--overlay);
362
- pointer-events: none;
363
- z-index: 1;
364
- }
365
-
366
388
  &:global(.blur) {
367
389
  filter: blur(10px) brightness(0.8);
368
390
  transform: scale(1.045);
@@ -378,13 +400,13 @@
378
400
  .paywall-content {
379
401
  z-index: 2;
380
402
  width: 100%;
381
- flex: 1;
403
+ flex: 1 1 auto;
382
404
  display: flex;
383
405
  flex-direction: column;
384
406
  min-height: 0;
385
407
 
386
408
  & > :global(.paywall-content-scroll) {
387
- flex-grow: 1;
409
+ flex: 1 1 auto;
388
410
  min-height: 0;
389
411
  overflow-y: auto;
390
412
  }
@@ -0,0 +1,61 @@
1
+ <script lang="ts">
2
+ import type { PaywallRootBackgroundModel } from "../../utils/background-utils";
3
+
4
+ // iOS WebKit only reliably propagates background-color (not background-image)
5
+ // through to the safe-area canvas. A position:fixed element that explicitly
6
+ // fills the viewport — including safe areas — is the only consistent paint
7
+ // surface for gradients and images on iOS Safari.
8
+ const { model }: { model: PaywallRootBackgroundModel } = $props();
9
+
10
+ const backdropStyle = $derived.by((): string => {
11
+ if (
12
+ model.kind === "style" &&
13
+ model.style.background?.includes("gradient")
14
+ ) {
15
+ return Object.entries(model.style)
16
+ .map(([k, v]) => `${k}:${v}`)
17
+ .join(";");
18
+ }
19
+ if (model.kind === "image") {
20
+ return [
21
+ `background-image:url("${model.src}")`,
22
+ `background-size:${model.fit}`,
23
+ `background-position:${model.position}`,
24
+ `background-repeat:no-repeat`,
25
+ ].join(";");
26
+ }
27
+ return "";
28
+ });
29
+
30
+ const overlayStyle = $derived(
31
+ model.kind === "image" && model.overlay && model.overlay !== "none"
32
+ ? `background:${model.overlay}`
33
+ : null,
34
+ );
35
+
36
+ const shouldRender = $derived(backdropStyle !== "");
37
+ </script>
38
+
39
+ {#if shouldRender}
40
+ <div class="viewport-backdrop" style={backdropStyle}>
41
+ {#if overlayStyle}
42
+ <div class="viewport-backdrop-overlay" style={overlayStyle}></div>
43
+ {/if}
44
+ </div>
45
+ {/if}
46
+
47
+ <style>
48
+ .viewport-backdrop {
49
+ position: fixed;
50
+ inset: 0;
51
+ z-index: 0;
52
+ pointer-events: none;
53
+ overflow: hidden;
54
+ }
55
+
56
+ .viewport-backdrop-overlay {
57
+ position: absolute;
58
+ inset: 0;
59
+ pointer-events: none;
60
+ }
61
+ </style>
@@ -0,0 +1,7 @@
1
+ import type { PaywallRootBackgroundModel } from "../../utils/background-utils";
2
+ type $$ComponentProps = {
3
+ model: PaywallRootBackgroundModel;
4
+ };
5
+ declare const ViewportBackdrop: import("svelte").Component<$$ComponentProps, {}, "">;
6
+ type ViewportBackdrop = ReturnType<typeof ViewportBackdrop>;
7
+ export default ViewportBackdrop;
@@ -70,7 +70,13 @@
70
70
  });
71
71
  let videoElement = $state<HTMLVideoElement | null>(null);
72
72
  let hasVideoError = $state(false);
73
- let showControlsFallback = $state(false);
73
+ /**
74
+ * When `mute_audio` is false, unmuted `play()` often fails after a full reload (autoplay policy).
75
+ * Retrying with muted playback requires keeping the `<video muted>` binding in sync with the element.
76
+ */
77
+ let playbackMutedOverride = $state(false);
78
+ /** Prevents repeated `load()` / `play()` nudge when the autoplay $effect re-runs (was resetting iOS to readyState 0). */
79
+ let lastAutoplayBootstrapUrl = $state<string | null>(null);
74
80
 
75
81
  // Load video or fallback image metadata to get dimensions
76
82
  $effect(() => {
@@ -138,7 +144,13 @@
138
144
  if (size.height.type === "fixed") {
139
145
  return size.height.value;
140
146
  }
141
- return Math.round(wrapperWidth * (videoSize.height / videoSize.width));
147
+ const w = videoSize.width;
148
+ const h = videoSize.height;
149
+ if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0) {
150
+ // Avoid 0-height box and NaN before metadata (`NaN <= 0` is false); 16:9 placeholder until dimensions resolve.
151
+ return wrapperWidth > 0 ? Math.round(wrapperWidth * (9 / 16)) : 200;
152
+ }
153
+ return Math.round(wrapperWidth * (h / w));
142
154
  });
143
155
 
144
156
  const style = $derived(
@@ -195,42 +207,211 @@
195
207
 
196
208
  const shouldShowFallback = $derived(hasVideoError || !video);
197
209
 
198
- // Programmatic autoplay (single path; HTML autoplay omitted to avoid iOS quirks).
210
+ // Autoplay: native `autoplay` + programmatic `play()`. Unmuted autoplay is often blocked; we retry
211
+ // once with muted playback (`playbackMutedOverride`) then try to restore sound. Controls follow
212
+ // `show_controls` only. Bootstrap once per URL; `play()` when `readyState >= HAVE_CURRENT_DATA`.
213
+ // Microtask + rAF retries cover decode/policy ordering; refresh leaves `document` already
214
+ // `complete` (`load` won't fire)—extra microtask retries `tryPlay` for muted→unmuted paths.
215
+ // Pair with `translateZ(0)` + rc-workflows `screen-slide-fade`.
199
216
  $effect(() => {
200
217
  if (!auto_play) {
201
218
  untrack(() => {
202
- showControlsFallback = false;
219
+ playbackMutedOverride = false;
203
220
  });
204
221
  return;
205
222
  }
206
223
 
207
224
  if (shouldShowFallback || !video) {
208
225
  untrack(() => {
209
- showControlsFallback = false;
226
+ playbackMutedOverride = false;
210
227
  });
211
228
  return;
212
229
  }
213
230
 
214
231
  const el = videoElement;
215
- if (!el) {
216
- return;
232
+ if (!el) return;
233
+
234
+ const vurl = video.url;
235
+
236
+ const needsBootstrap = lastAutoplayBootstrapUrl !== vurl;
237
+ if (needsBootstrap) {
238
+ lastAutoplayBootstrapUrl = vurl;
239
+ untrack(() => {
240
+ playbackMutedOverride = false;
241
+ });
217
242
  }
218
243
 
219
- void video.url;
220
- void mute_audio;
244
+ let cancelled = false;
245
+
246
+ /** Keep `muted` / `playsInline` aligned with `mute_audio`, `playbackMutedOverride`, and the template. */
247
+ const applyVideoPlaybackFlags = () => {
248
+ el.muted = mute_audio || playbackMutedOverride;
249
+ el.playsInline = true;
250
+ };
251
+
252
+ let playingUnmuteListener: (() => void) | null = null;
253
+
254
+ const clearPlayingUnmuteListener = () => {
255
+ if (playingUnmuteListener !== null) {
256
+ el.removeEventListener("playing", playingUnmuteListener);
257
+ playingUnmuteListener = null;
258
+ }
259
+ };
260
+
261
+ const tryPlay = () => {
262
+ if (cancelled) return;
263
+ if (!el.paused) return;
221
264
 
222
- untrack(() => {
223
- showControlsFallback = false;
224
- });
265
+ applyVideoPlaybackFlags();
266
+
267
+ if (el.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) {
268
+ return;
269
+ }
225
270
 
226
- const playPromise = el.play();
227
- if (playPromise !== undefined) {
228
- playPromise.catch(() => {
229
- untrack(() => {
230
- showControlsFallback = true;
271
+ const playPromise = el.play();
272
+ if (playPromise !== undefined) {
273
+ playPromise.catch(() => {
274
+ if (!mute_audio && !playbackMutedOverride) {
275
+ untrack(() => {
276
+ playbackMutedOverride = true;
277
+ });
278
+ applyVideoPlaybackFlags();
279
+
280
+ /** Unmuted `play()` after a forced-muted start sometimes only works tied to playback (WebKit). */
281
+ let unmuteAfterMutedStartHandled = false;
282
+ const tryRestoreAudioAfterMutedAutoplay = () => {
283
+ if (unmuteAfterMutedStartHandled || mute_audio || cancelled)
284
+ return;
285
+ unmuteAfterMutedStartHandled = true;
286
+ queueMicrotask(() => {
287
+ if (cancelled) return;
288
+ untrack(() => {
289
+ playbackMutedOverride = false;
290
+ });
291
+ applyVideoPlaybackFlags();
292
+ el.volume = 1;
293
+ const p3 = el.play();
294
+ if (p3 === undefined) return;
295
+ p3.catch(() => {
296
+ untrack(() => {
297
+ playbackMutedOverride = true;
298
+ });
299
+ applyVideoPlaybackFlags();
300
+ void el.play().catch(() => {});
301
+ });
302
+ });
303
+ };
304
+
305
+ const onPlayingAfterMutedBootstrap = () => {
306
+ playingUnmuteListener = null;
307
+ tryRestoreAudioAfterMutedAutoplay();
308
+ };
309
+ playingUnmuteListener = onPlayingAfterMutedBootstrap;
310
+ el.addEventListener("playing", onPlayingAfterMutedBootstrap, {
311
+ once: true,
312
+ });
313
+
314
+ const p2 = el.play();
315
+ if (p2 !== undefined) {
316
+ p2.then(() => {
317
+ // `playing` may have fired before our listener ran; retry unmute once playback exists.
318
+ queueMicrotask(() => {
319
+ if (
320
+ cancelled ||
321
+ mute_audio ||
322
+ unmuteAfterMutedStartHandled ||
323
+ el.paused
324
+ ) {
325
+ return;
326
+ }
327
+ if (!el.muted) {
328
+ clearPlayingUnmuteListener();
329
+ return;
330
+ }
331
+ tryRestoreAudioAfterMutedAutoplay();
332
+ });
333
+ }).catch(() => {
334
+ clearPlayingUnmuteListener();
335
+ });
336
+ }
337
+ }
231
338
  });
339
+ }
340
+ };
341
+
342
+ const onMediaReady = () => {
343
+ tryPlay();
344
+ };
345
+
346
+ if (el.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) {
347
+ el.addEventListener("loadeddata", onMediaReady);
348
+ el.addEventListener("canplay", onMediaReady);
349
+ } else {
350
+ tryPlay();
351
+ }
352
+
353
+ /**
354
+ * Once per `video.url`: muted `play()` nudge helps iOS decode before `HAVE_CURRENT_DATA`.
355
+ * When `mute_audio` is false, `load()` reloads the media so poster/metadata can attach; unmuted
356
+ * autoplay may still require a user gesture (handled in `tryPlay`).
357
+ */
358
+ const nudgePlayback = () => {
359
+ applyVideoPlaybackFlags();
360
+
361
+ if (mute_audio) {
362
+ void el.play().catch(() => {});
363
+ } else {
364
+ try {
365
+ el.load();
366
+ } catch {
367
+ /* ignore */
368
+ }
369
+ }
370
+ };
371
+ if (needsBootstrap && el.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) {
372
+ nudgePlayback();
373
+ }
374
+
375
+ /** Extra `tryPlay` ticks: next microtask vs next frame vs cold `load`; WebKit reordering varies. */
376
+ const deferTryPlayWithReadyGate = () => {
377
+ queueMicrotask(() => {
378
+ if (cancelled) return;
379
+ if (el.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) {
380
+ tryPlay();
381
+ }
382
+ });
383
+ requestAnimationFrame(() => {
384
+ if (cancelled) return;
385
+ if (el.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) {
386
+ tryPlay();
387
+ }
232
388
  });
389
+ };
390
+
391
+ deferTryPlayWithReadyGate();
392
+
393
+ const onWindowLoad = () => {
394
+ tryPlay();
395
+ };
396
+ if (typeof document !== "undefined" && typeof window !== "undefined") {
397
+ if (document.readyState === "complete") {
398
+ queueMicrotask(() => {
399
+ if (!cancelled) tryPlay();
400
+ });
401
+ } else {
402
+ window.addEventListener("load", onWindowLoad);
403
+ }
233
404
  }
405
+
406
+ return () => {
407
+ cancelled = true;
408
+ clearPlayingUnmuteListener();
409
+ el.removeEventListener("loadeddata", onMediaReady);
410
+ el.removeEventListener("canplay", onMediaReady);
411
+ if (typeof window !== "undefined") {
412
+ window.removeEventListener("load", onWindowLoad);
413
+ }
414
+ };
234
415
  });
235
416
 
236
417
  const isVisible = $derived(
@@ -262,9 +443,11 @@
262
443
  src={video.url}
263
444
  style="object-fit:{mapFitMode(fit_mode)}"
264
445
  {loop}
265
- muted={mute_audio}
266
- controls={show_controls || showControlsFallback}
446
+ muted={mute_audio || playbackMutedOverride}
447
+ controls={show_controls}
267
448
  playsinline
449
+ autoplay={auto_play}
450
+ preload={auto_play ? "auto" : "metadata"}
268
451
  >
269
452
  <track kind="captions" />
270
453
  </video>
@@ -352,4 +535,10 @@
352
535
  object-fit: contain;
353
536
  object-position: center;
354
537
  }
538
+
539
+ /* Own compositing layer — mitigates WebKit freezing video frames under animated/transform ancestors */
540
+ video {
541
+ transform: translateZ(0);
542
+ -webkit-transform: translateZ(0);
543
+ }
355
544
  </style>
@@ -1,5 +1,6 @@
1
1
  <script module lang="ts">
2
2
  import { defineMeta } from "@storybook/addon-svelte-csf";
3
+ import type { ComponentProps } from "svelte";
3
4
 
4
5
  import Screen from "./Screen.svelte";
5
6
  import { paywallData, uiConfigData } from "../../stories/fixtures";
@@ -20,6 +21,7 @@
20
21
  const { Story } = defineMeta({
21
22
  title: "Components/Screen",
22
23
  component: Screen,
24
+ render: template,
23
25
  args: {
24
26
  paywallComponents: defaultScreen,
25
27
  selectedLocale: defaultScreen.default_locale,
@@ -28,4 +30,41 @@
28
30
  });
29
31
  </script>
30
32
 
33
+ {#snippet template(props: ComponentProps<typeof Screen>)}
34
+ <div class="viewport-frame">
35
+ <div class="content-wrapper">
36
+ <Screen {...props} />
37
+ </div>
38
+ </div>
39
+ {/snippet}
40
+
31
41
  <Story name="Default" />
42
+
43
+ <style>
44
+ .viewport-frame {
45
+ position: fixed;
46
+ inset: 0;
47
+ display: flex;
48
+ flex-direction: column;
49
+ background-color: var(--rc-purchases-ui-bg-color, Canvas);
50
+ color-scheme: light dark;
51
+ overflow: hidden;
52
+ }
53
+
54
+ .content-wrapper {
55
+ width: 100%;
56
+ display: flex;
57
+ flex: 1 1 auto;
58
+ flex-direction: column;
59
+ min-height: 0;
60
+ overflow: hidden;
61
+ }
62
+
63
+ :global(#screen-container) {
64
+ display: flex;
65
+ flex-direction: column;
66
+ flex: 1 1 auto;
67
+ min-height: 0;
68
+ overflow: hidden;
69
+ }
70
+ </style>
@@ -1,4 +1,19 @@
1
1
  import type { ColorMode } from "../types";
2
2
  import type { Background } from "../types/background";
3
3
  import type { ColorGradientScheme } from "../types/colors";
4
+ import type { PaywallData } from "../types/paywall";
5
+ export type PaywallRootBackgroundModel = {
6
+ kind: "none";
7
+ } | {
8
+ kind: "style";
9
+ style: Record<string, string>;
10
+ overlay: string;
11
+ } | {
12
+ kind: "image";
13
+ src: string;
14
+ fit: string;
15
+ position: string;
16
+ overlay: string;
17
+ };
18
+ export declare function paywallRootBackgroundModel(paywallData: PaywallData | null | undefined, colorMode: ColorMode): PaywallRootBackgroundModel;
4
19
  export declare function mapBackground(colorMode: ColorMode, background_color: ColorGradientScheme | null | undefined, background: Background | null | undefined): Record<string, string>;
@@ -23,6 +23,27 @@ function mapBackgroundValue(colorMode, background_color, background) {
23
23
  }
24
24
  return mapBackgroundVideo(colorMode, background.value);
25
25
  }
26
+ export function paywallRootBackgroundModel(paywallData, colorMode) {
27
+ const background = paywallData?.components_config?.base?.background;
28
+ if (background == null) {
29
+ return { kind: "none" };
30
+ }
31
+ if (background.type !== "image") {
32
+ const styles = mapBackground(colorMode, null, background);
33
+ const overlay = styles["--overlay"] ?? "none";
34
+ const style = { ...styles };
35
+ delete style["--overlay"];
36
+ return { kind: "style", style, overlay };
37
+ }
38
+ const files = mapColorMode(colorMode, background.value);
39
+ return {
40
+ kind: "image",
41
+ src: files.webp,
42
+ fit: mapFitMode(background.fit_mode),
43
+ position: background.fit_mode === "fill" ? "center" : "top center",
44
+ overlay: mapOverlay(colorMode, background.color_overlay),
45
+ };
46
+ }
26
47
  export function mapBackground(colorMode, background_color, background) {
27
48
  const value = mapBackgroundValue(colorMode, background_color, background);
28
49
  if (background?.type !== "image") {
@@ -0,0 +1,3 @@
1
+ import type { PaywallData } from "../types/paywall";
2
+ import type { PaywallRootBackgroundModel } from "./background-utils";
3
+ export declare function applyDocumentBackground(instanceId: symbol, model: PaywallRootBackgroundModel, paywallData: PaywallData | null | undefined): () => void;
@@ -0,0 +1,59 @@
1
+ // Last writer wins, but cleanup only fires for the last writer — otherwise an
2
+ // unmounting paywall would clear a variable set by another paywall that overlapped
3
+ // it during a transition.
4
+ let lastBgWriter = null;
5
+ // iOS promotes sticky header/footer to its own compositor layer, which breaks
6
+ // safe-area painting for the opposite strip. A vertical gradient's edge stop
7
+ // can stand in as a solid fallback for that strip; any other angle would smear
8
+ // a horizontal colour range across the strip that no single colour represents.
9
+ function gradientSafeAreaFallbackColour(gradient, hasHeader, hasFooter) {
10
+ if (!hasHeader && !hasFooter)
11
+ return null;
12
+ if (hasHeader && hasFooter)
13
+ return null;
14
+ const angleMatch = gradient.match(/linear-gradient\((\d+)deg/);
15
+ if (!angleMatch)
16
+ return null;
17
+ if (parseInt(angleMatch[1], 10) % 180 !== 0)
18
+ return null;
19
+ const stops = gradient.match(/#[0-9a-fA-F]{6,8}/g);
20
+ if (!stops || stops.length < 2)
21
+ return null;
22
+ const candidate = hasHeader ? stops[stops.length - 1] : stops[0];
23
+ if (candidate.length === 9 && candidate.slice(-2).toLowerCase() !== "ff") {
24
+ return null;
25
+ }
26
+ return candidate;
27
+ }
28
+ // Published as a CSS variable so consumer surfaces can opt in to safe-area
29
+ // painting (`background: var(--rc-purchases-ui-bg-color, Canvas)` on any
30
+ // element that extends into the safe-area canvas — typically html, body, or
31
+ // a position:fixed root). Writing to documentElement.backgroundColor directly
32
+ // would bleed the paywall colour onto host page chrome that shouldn't take
33
+ // it on, so we expose the value rather than apply it.
34
+ export function applyDocumentBackground(instanceId, model, paywallData) {
35
+ if (typeof document === "undefined")
36
+ return () => { };
37
+ const root = document.documentElement;
38
+ let value = null;
39
+ if (model.kind === "style" && model.style.background) {
40
+ const bg = model.style.background;
41
+ if (!bg.includes("gradient")) {
42
+ value = bg;
43
+ }
44
+ else {
45
+ const base = paywallData?.components_config?.base;
46
+ value = gradientSafeAreaFallbackColour(bg, !!base?.header, !!base?.sticky_footer);
47
+ }
48
+ }
49
+ if (value !== null) {
50
+ lastBgWriter = instanceId;
51
+ root.style.setProperty("--rc-purchases-ui-bg-color", value);
52
+ }
53
+ return () => {
54
+ if (lastBgWriter === instanceId) {
55
+ lastBgWriter = null;
56
+ root.style.removeProperty("--rc-purchases-ui-bg-color");
57
+ }
58
+ };
59
+ }
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@revenuecat/purchases-ui-js",
3
3
  "description": "Web components for Paywalls. Powered by RevenueCat",
4
4
  "private": false,
5
- "version": "3.12.1",
5
+ "version": "4.1.0",
6
6
  "author": {
7
7
  "name": "RevenueCat, Inc."
8
8
  },
@@ -93,6 +93,7 @@
93
93
  "@testing-library/svelte": "^5.3.1",
94
94
  "@types/node": "24.9.2",
95
95
  "@types/qrcode": "^1.5.6",
96
+ "@types/react": "^19.2.14",
96
97
  "@typescript-eslint/parser": "8.57.2",
97
98
  "chromatic": "13.3.2",
98
99
  "eslint": "9.38.0",