@revenuecat/purchases-ui-js 4.0.0 → 4.2.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.
Files changed (42) hide show
  1. package/dist/components/carousel/Carousel.stories.svelte +29 -0
  2. package/dist/components/carousel/Carousel.svelte +74 -8
  3. package/dist/components/paywall/BackgroundVideoSurface.svelte +162 -0
  4. package/dist/components/paywall/BackgroundVideoSurface.svelte.d.ts +12 -0
  5. package/dist/components/paywall/Paywall.stories.svelte +134 -10
  6. package/dist/components/paywall/Paywall.svelte +36 -6
  7. package/dist/components/paywall/Sheet.svelte +66 -3
  8. package/dist/components/paywall/ViewportBackdrop.svelte +92 -0
  9. package/dist/components/{layout/Main → paywall}/ViewportBackdrop.svelte.d.ts +1 -1
  10. package/dist/components/paywall/fixtures/sheet-video-stacking-paywall.d.ts +6 -0
  11. package/dist/components/paywall/fixtures/sheet-video-stacking-paywall.js +42 -0
  12. package/dist/components/stack/Stack.stories.svelte +74 -0
  13. package/dist/components/stack/Stack.svelte +58 -3
  14. package/dist/components/tabs/Tabs.stories.svelte +27 -0
  15. package/dist/components/tabs/Tabs.svelte +69 -4
  16. package/dist/components/tabs/tabs-video-stacking-story-args.d.ts +3 -0
  17. package/dist/components/tabs/tabs-video-stacking-story-args.js +131 -0
  18. package/dist/components/video/Video.svelte +185 -20
  19. package/dist/components/workflows/Screen.stories.svelte +3 -6
  20. package/dist/index.d.ts +0 -1
  21. package/dist/index.js +0 -1
  22. package/dist/stories/video-background-story-fixture.d.ts +3 -0
  23. package/dist/stories/video-background-story-fixture.js +36 -0
  24. package/dist/types/background.d.ts +9 -1
  25. package/dist/ui/molecules/button.svelte +2 -2
  26. package/dist/utils/background-utils.d.ts +30 -1
  27. package/dist/utils/background-utils.js +53 -5
  28. package/dist/utils/document-background.d.ts +3 -0
  29. package/dist/utils/document-background.js +59 -0
  30. package/dist/utils/match-media-compat.d.ts +4 -0
  31. package/dist/utils/match-media-compat.js +11 -0
  32. package/dist/utils/video-background-host.d.ts +31 -0
  33. package/dist/utils/video-background-host.js +25 -0
  34. package/dist/utils/video-inline-playback.d.ts +25 -0
  35. package/dist/utils/video-inline-playback.js +78 -0
  36. package/dist/web-components/index.css +1 -1
  37. package/package.json +2 -2
  38. package/dist/components/layout/Main/Main.stories.svelte +0 -263
  39. package/dist/components/layout/Main/Main.stories.svelte.d.ts +0 -19
  40. package/dist/components/layout/Main/Main.svelte +0 -117
  41. package/dist/components/layout/Main/Main.svelte.d.ts +0 -11
  42. package/dist/components/layout/Main/ViewportBackdrop.svelte +0 -65
@@ -4,6 +4,7 @@
4
4
  import { viewportDecorator } from "../../stories/viewport-decorator";
5
5
  import type { CarouselProps } from "../../types/components/carousel";
6
6
  import { DEFAULT_SPACING } from "../../utils/constants";
7
+ import { STORY_BACKGROUND_VIDEO_FILL } from "../../stories/video-background-story-fixture";
7
8
  import { defineMeta } from "@storybook/addon-svelte-csf";
8
9
  import Carousel from "./Carousel.svelte";
9
10
 
@@ -442,6 +443,34 @@
442
443
  });
443
444
  </script>
444
445
 
446
+ <Story
447
+ name="Surface — video background (stacking / z-order)"
448
+ parameters={{
449
+ chromatic: { disableSnapshot: true },
450
+ docs: {
451
+ description: {
452
+ story:
453
+ "**Z-order:** video layer → tinted `::before` → carousel clip (slides + controls). Rounded corners should clip the backdrop; Chromatic skips motion snapshots.",
454
+ },
455
+ },
456
+ }}
457
+ args={{
458
+ background: STORY_BACKGROUND_VIDEO_FILL,
459
+ shape: {
460
+ type: "rectangle",
461
+ corners: {
462
+ bottom_leading: 20,
463
+ bottom_trailing: 20,
464
+ top_leading: 20,
465
+ top_trailing: 20,
466
+ },
467
+ },
468
+ auto_advance: null,
469
+ loop: false,
470
+ initial_page_index: 0,
471
+ }}
472
+ />
473
+
445
474
  <Story
446
475
  name="No loop - first page"
447
476
  args={{
@@ -2,8 +2,16 @@
2
2
  import { getColorModeContext } from "../../stores/color-mode";
3
3
  import { getPaywallContext } from "../../stores/paywall";
4
4
  import { getSelectedStateContext } from "../../stores/selected";
5
+ import BackgroundVideoSurface from "../paywall/BackgroundVideoSurface.svelte";
5
6
  import type { CarouselProps } from "../../types/components/carousel";
6
- import { mapBackground } from "../../utils/background-utils";
7
+ import {
8
+ mapBackground,
9
+ resolveBackgroundVideoForSurface,
10
+ } from "../../utils/background-utils";
11
+ import {
12
+ rcVideoBackgroundHostClass,
13
+ videoBackgroundHostStyles,
14
+ } from "../../utils/video-background-host";
7
15
  import {
8
16
  css,
9
17
  mapBorder,
@@ -33,7 +41,7 @@
33
41
  page_peek,
34
42
  page_control,
35
43
  initial_page_index,
36
- loop,
44
+ loop: carouselPagingLoop,
37
45
  auto_advance,
38
46
  } = $derived.by(() => {
39
47
  return {
@@ -46,13 +54,26 @@
46
54
  const colorMode = $derived(getColorMode());
47
55
  const { emitComponentInteraction } = getPaywallContext();
48
56
 
57
+ const hasVideoBg = $derived(background?.type === "video");
58
+ const videoSurface = $derived.by(() => {
59
+ if (!hasVideoBg) return null;
60
+ const bg = background;
61
+ if (!bg || bg.type !== "video") return null;
62
+ return resolveBackgroundVideoForSurface(colorMode, bg);
63
+ });
64
+ const borderRadiusCssForBg = $derived(mapBorderRadius(shape));
65
+
49
66
  const carouselStyle = $derived(
50
67
  css({
51
68
  margin: mapSpacing(margin),
52
69
  padding: mapSpacing(padding),
70
+ ...videoBackgroundHostStyles({
71
+ hasVideoBg,
72
+ positionRelative: true,
73
+ }),
53
74
  ...mapBackground(colorMode, null, background),
54
75
  ...mapBorder(colorMode, border),
55
- "border-radius": mapBorderRadius(shape),
76
+ "border-radius": borderRadiusCssForBg,
56
77
  "box-shadow": mapShadow(colorMode, shadow),
57
78
  "transition-duration": `${auto_advance?.ms_transition_time ?? 0}ms`,
58
79
  }),
@@ -67,7 +88,7 @@
67
88
  );
68
89
 
69
90
  const prevPages = $derived.by(() => {
70
- if (!loop) {
91
+ if (!carouselPagingLoop) {
71
92
  return [];
72
93
  }
73
94
 
@@ -85,7 +106,7 @@
85
106
  });
86
107
 
87
108
  const nextPages = $derived.by(() => {
88
- if (!loop) {
109
+ if (!carouselPagingLoop) {
89
110
  return [];
90
111
  }
91
112
 
@@ -138,7 +159,7 @@
138
159
  }
139
160
  }
140
161
 
141
- const loopOffset = $derived(loop ? 2 : 0);
162
+ const loopOffset = $derived(carouselPagingLoop ? 2 : 0);
142
163
 
143
164
  function setSliderTranslate(translate: number) {
144
165
  if (slider !== null) {
@@ -238,7 +259,7 @@
238
259
  return;
239
260
  }
240
261
 
241
- const [min, max] = loop
262
+ const [min, max] = carouselPagingLoop
242
263
  ? [startTranslation - carouselWidth, startTranslation + carouselWidth]
243
264
  : [-(pages.length - 1) * pageWidth, 0];
244
265
  const translation = getTranslation(slider);
@@ -252,7 +273,29 @@
252
273
  }
253
274
  </script>
254
275
 
255
- <div class="carousel rc-gradient-border" style={carouselStyle}>
276
+ <div
277
+ class={[
278
+ "carousel",
279
+ "rc-gradient-border",
280
+ hasVideoBg ? rcVideoBackgroundHostClass("carousel") : "",
281
+ ]
282
+ .filter(Boolean)
283
+ .join(" ")}
284
+ style={carouselStyle}
285
+ >
286
+ {#if videoSurface}
287
+ <div class="carousel-bg-video-mount">
288
+ <BackgroundVideoSurface
289
+ url={videoSurface.url}
290
+ urlLowRes={videoSurface.url_low_res}
291
+ objectFit={videoSurface.fit}
292
+ objectPosition={videoSurface.position}
293
+ poster={videoSurface.posterWebp}
294
+ mute={videoSurface.mute}
295
+ loop={videoSurface.loop}
296
+ />
297
+ </div>
298
+ {/if}
256
299
  <div class="carousel-clip">
257
300
  <div
258
301
  bind:this={slider}
@@ -324,6 +367,29 @@
324
367
  border-radius: inherit;
325
368
  }
326
369
 
370
+ .carousel-bg-video-mount {
371
+ position: absolute;
372
+ inset: 0;
373
+ z-index: 1;
374
+ border-radius: inherit;
375
+ overflow: hidden;
376
+ pointer-events: none;
377
+ }
378
+
379
+ .carousel.rc-carousel-video-bg::before {
380
+ content: "";
381
+ position: absolute;
382
+ inset: 0;
383
+ background: var(--overlay);
384
+ pointer-events: none;
385
+ z-index: 2;
386
+ }
387
+
388
+ .carousel.rc-carousel-video-bg .carousel-clip {
389
+ position: relative;
390
+ z-index: 3;
391
+ }
392
+
327
393
  .slider {
328
394
  display: flex;
329
395
  flex-direction: row;
@@ -0,0 +1,162 @@
1
+ <script lang="ts">
2
+ import { onMount, untrack } from "svelte";
3
+ import { subscribeCompatMediaChange } from "../../utils/match-media-compat";
4
+ import {
5
+ subscribeDecorativeVideoAutoplayAttempts,
6
+ tryDecorativeAutoplayWithMuteFallback,
7
+ } from "../../utils/video-inline-playback";
8
+
9
+ interface Props {
10
+ url: string;
11
+ urlLowRes?: string | null;
12
+ objectFit: string;
13
+ objectPosition: string;
14
+ poster?: string | null;
15
+ mute: boolean;
16
+ loop: boolean;
17
+ }
18
+
19
+ let {
20
+ url,
21
+ urlLowRes = null,
22
+ objectFit,
23
+ objectPosition,
24
+ poster = null,
25
+ mute,
26
+ loop,
27
+ }: Props = $props();
28
+
29
+ let videoElement = $state<HTMLVideoElement | null>(null);
30
+ let hasError = $state(false);
31
+ let reduceMotion = $state(false);
32
+ /** When unmuted autoplay fails, retry muted (Safari autoplay policy). */
33
+ let playbackMutedOverride = $state(false);
34
+
35
+ onMount(() => {
36
+ if (typeof window === "undefined") return;
37
+ const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
38
+ reduceMotion = mq.matches;
39
+ const onChange = () => {
40
+ reduceMotion = mq.matches;
41
+ };
42
+ return subscribeCompatMediaChange(mq, onChange);
43
+ });
44
+
45
+ $effect(() => {
46
+ url;
47
+ urlLowRes;
48
+ untrack(() => {
49
+ hasError = false;
50
+ playbackMutedOverride = false;
51
+ });
52
+ queueMicrotask(() => {
53
+ videoElement?.load();
54
+ });
55
+ });
56
+
57
+ const showPosterOnly = $derived(reduceMotion || hasError);
58
+
59
+ const mediaStyle = $derived(
60
+ `object-fit:${objectFit};object-position:${objectPosition}`,
61
+ );
62
+
63
+ $effect(() => {
64
+ if (showPosterOnly || !videoElement) {
65
+ untrack(() => {
66
+ playbackMutedOverride = false;
67
+ });
68
+ return;
69
+ }
70
+
71
+ const el = videoElement;
72
+ let cancelled = false;
73
+ const isCancelled = () => cancelled;
74
+
75
+ const tryPlay = () => {
76
+ tryDecorativeAutoplayWithMuteFallback(el, {
77
+ cancelled: isCancelled,
78
+ preferMuted: mute,
79
+ getMutedOverride: () => playbackMutedOverride,
80
+ setMutedOverride: (v) =>
81
+ untrack(() => {
82
+ playbackMutedOverride = v;
83
+ }),
84
+ });
85
+ };
86
+
87
+ const removeMediaListeners = subscribeDecorativeVideoAutoplayAttempts(
88
+ el,
89
+ tryPlay,
90
+ isCancelled,
91
+ );
92
+
93
+ return () => {
94
+ cancelled = true;
95
+ removeMediaListeners();
96
+ };
97
+ });
98
+
99
+ function onVideoError() {
100
+ hasError = true;
101
+ }
102
+ </script>
103
+
104
+ {#if showPosterOnly && poster}
105
+ <div class="rc-bg-video" aria-hidden="true">
106
+ <img src={poster} alt="" class="fill" style={mediaStyle} />
107
+ </div>
108
+ {:else if showPosterOnly}
109
+ <!-- No poster/fallback asset for reduced motion or failed load -->
110
+ {:else}
111
+ <!-- svelte-ignore a11y_media_has_caption decorative background -->
112
+ <div class="rc-bg-video" aria-hidden="true">
113
+ <video
114
+ bind:this={videoElement}
115
+ class="fill"
116
+ style={mediaStyle}
117
+ {loop}
118
+ muted={mute || playbackMutedOverride}
119
+ playsinline
120
+ autoplay
121
+ preload="auto"
122
+ poster={poster ?? undefined}
123
+ onerror={onVideoError}
124
+ >
125
+ {#if urlLowRes}
126
+ <!-- Narrow viewports use low-res asset first; larger screens use primary URL. -->
127
+ <source src={urlLowRes} media="(max-width: 600px)" />
128
+ {/if}
129
+ <source src={url} />
130
+ </video>
131
+ </div>
132
+ {/if}
133
+
134
+ <style>
135
+ .rc-bg-video {
136
+ position: absolute;
137
+ inset: 0;
138
+ overflow: hidden;
139
+ z-index: 1;
140
+ pointer-events: none;
141
+ }
142
+
143
+ /**
144
+ * Fill the layer with absolute edges (percent heights are unreliable under nested
145
+ * flex/absolute hosts). Beat Paywall's global `.paywall video { max-width: 100% }`
146
+ * so `object-fit: cover|contain` applies to the full backdrop box (e.g. carousel).
147
+ */
148
+ .rc-bg-video :is(video, img).fill {
149
+ position: absolute;
150
+ inset: 0;
151
+ width: 100%;
152
+ height: 100%;
153
+ max-width: none;
154
+ max-height: none;
155
+ display: block;
156
+ }
157
+
158
+ .rc-bg-video video.fill {
159
+ transform: translateZ(0);
160
+ -webkit-transform: translateZ(0);
161
+ }
162
+ </style>
@@ -0,0 +1,12 @@
1
+ interface Props {
2
+ url: string;
3
+ urlLowRes?: string | null;
4
+ objectFit: string;
5
+ objectPosition: string;
6
+ poster?: string | null;
7
+ mute: boolean;
8
+ loop: boolean;
9
+ }
10
+ declare const BackgroundVideoSurface: import("svelte").Component<Props, {}, "">;
11
+ type BackgroundVideoSurface = ReturnType<typeof BackgroundVideoSurface>;
12
+ export default BackgroundVideoSurface;
@@ -3,7 +3,6 @@
3
3
  import type { ComponentProps } from "svelte";
4
4
 
5
5
  import Paywall from "./Paywall.svelte";
6
- import Main from "../layout/Main/Main.svelte";
7
6
  import {
8
7
  alignmentPaywallData,
9
8
  calmPaywallData,
@@ -28,6 +27,7 @@
28
27
  import { BACKGROUND_PAYWALL } from "./fixtures/background-paywall";
29
28
  import { OVERRIDE_PAYWALL } from "./fixtures/override-paywall";
30
29
  import { SHEET_PAYWALL } from "./fixtures/sheet-paywall";
30
+ import { SHEET_PAYWALL_VIDEO_STACKING } from "./fixtures/sheet-video-stacking-paywall";
31
31
  import { STACK_PAYWALL } from "./fixtures/stack-paywall";
32
32
  import { VARIABLES } from "./fixtures/variables";
33
33
  import { CUSTOM_VARIABLES_PAYWALL } from "./fixtures/custom-variables-paywall";
@@ -63,9 +63,7 @@
63
63
 
64
64
  {#snippet template(props: ComponentProps<typeof Paywall>)}
65
65
  <div class="paywall-story-frame">
66
- <Main paywallData={props.paywallData}>
67
- <Paywall {...props} />
68
- </Main>
66
+ <Paywall {...props} />
69
67
  </div>
70
68
  {/snippet}
71
69
 
@@ -96,6 +94,28 @@
96
94
  }}
97
95
  />
98
96
 
97
+ <Story
98
+ name="Sheet — video background (stacking / z-order)"
99
+ parameters={{
100
+ chromatic: { disableSnapshot: true },
101
+ docs: {
102
+ description: {
103
+ story:
104
+ "**Z-order:** `BackgroundVideoSurface` under the sheet panel (`z-index: 1`), CSS `::before` tint (`z-index: 2`), inner `Stack` (`z-index: 3`). Backdrop dim is outside the sheet chrome. Motion is disabled in Chromatic.",
105
+ },
106
+ },
107
+ }}
108
+ decorators={[viewportDecorator(500, 500, 0)]}
109
+ play={async ({ canvasElement }) => {
110
+ const button = canvasElement.querySelector("button");
111
+ button?.click();
112
+ await waitForAnimations();
113
+ }}
114
+ args={{
115
+ paywallData: SHEET_PAYWALL_VIDEO_STACKING,
116
+ }}
117
+ />
118
+
99
119
  <Story
100
120
  name="Background - Color"
101
121
  decorators={[viewportDecorator(500, 500, 0)]}
@@ -234,6 +254,116 @@
234
254
  }}
235
255
  />
236
256
 
257
+ <Story
258
+ name="Background - Video fill"
259
+ decorators={[viewportDecorator(500, 500, 0)]}
260
+ parameters={{ chromatic: { disableSnapshot: true } }}
261
+ args={{
262
+ paywallData: BACKGROUND_PAYWALL({
263
+ type: "video",
264
+ fit_mode: "fill",
265
+ mute_audio: true,
266
+ loop: true,
267
+ color_overlay: null,
268
+ fallback_image: {
269
+ light: {
270
+ width: 640,
271
+ height: 360,
272
+ original:
273
+ "https://placehold.co/640x360/1a1a2e/ffffff.webp?text=Poster",
274
+ heic: "https://placehold.co/640x360/1a1a2e/ffffff.webp?text=Poster",
275
+ heic_low_res:
276
+ "https://placehold.co/640x360/1a1a2e/ffffff.webp?text=Poster",
277
+ webp: "https://placehold.co/640x360/1a1a2e/ffffff.webp?text=Poster",
278
+ webp_low_res:
279
+ "https://placehold.co/640x360/1a1a2e/ffffff.webp?text=Poster",
280
+ },
281
+ },
282
+ value: {
283
+ light: {
284
+ width: 1920,
285
+ height: 1080,
286
+ url: "https://videos.pexels.com/video-files/10288594/10288594-hd_1920_1080_25fps.mp4",
287
+ url_low_res:
288
+ "https://videos.pexels.com/video-files/10288594/10288594-hd_1920_1080_25fps.mp4",
289
+ },
290
+ },
291
+ }),
292
+ }}
293
+ />
294
+
295
+ <Story
296
+ name="Background - Video fit"
297
+ decorators={[viewportDecorator(500, 500, 0)]}
298
+ parameters={{ chromatic: { disableSnapshot: true } }}
299
+ args={{
300
+ paywallData: BACKGROUND_PAYWALL({
301
+ type: "video",
302
+ fit_mode: "fit",
303
+ mute_audio: true,
304
+ loop: true,
305
+ color_overlay: null,
306
+ fallback_image: null,
307
+ value: {
308
+ light: {
309
+ width: 1920,
310
+ height: 1080,
311
+ url: "https://videos.pexels.com/video-files/10288594/10288594-hd_1920_1080_25fps.mp4",
312
+ url_low_res:
313
+ "https://videos.pexels.com/video-files/10288594/10288594-hd_1920_1080_25fps.mp4",
314
+ },
315
+ },
316
+ }),
317
+ }}
318
+ />
319
+
320
+ <Story
321
+ name="Background - Video with tint overlay"
322
+ decorators={[viewportDecorator(500, 500, 0)]}
323
+ parameters={{ chromatic: { disableSnapshot: true } }}
324
+ args={{
325
+ paywallData: BACKGROUND_PAYWALL({
326
+ type: "video",
327
+ fit_mode: "fill",
328
+ mute_audio: true,
329
+ loop: true,
330
+ color_overlay: {
331
+ light: {
332
+ type: "linear",
333
+ degrees: 180,
334
+ points: [
335
+ { percent: 0, color: "#00000066" },
336
+ { percent: 100, color: "#1a0a3080" },
337
+ ],
338
+ },
339
+ },
340
+ fallback_image: {
341
+ light: {
342
+ width: 640,
343
+ height: 360,
344
+ original:
345
+ "https://placehold.co/640x360/2d1b4e/ffffff.webp?text=Poster",
346
+ heic: "https://placehold.co/640x360/2d1b4e/ffffff.webp?text=Poster",
347
+ heic_low_res:
348
+ "https://placehold.co/640x360/2d1b4e/ffffff.webp?text=Poster",
349
+ webp: "https://placehold.co/640x360/2d1b4e/ffffff.webp?text=Poster",
350
+ webp_low_res:
351
+ "https://placehold.co/640x360/2d1b4e/ffffff.webp?text=Poster",
352
+ },
353
+ },
354
+ value: {
355
+ light: {
356
+ width: 1920,
357
+ height: 1080,
358
+ url: "https://videos.pexels.com/video-files/10288594/10288594-hd_1920_1080_25fps.mp4",
359
+ url_low_res:
360
+ "https://videos.pexels.com/video-files/10288594/10288594-hd_1920_1080_25fps.mp4",
361
+ },
362
+ },
363
+ }),
364
+ }}
365
+ />
366
+
237
367
  <Story
238
368
  name="Primary"
239
369
  args={{
@@ -510,10 +640,4 @@
510
640
  background-color: var(--rc-purchases-ui-bg-color, Canvas);
511
641
  color-scheme: light dark;
512
642
  }
513
-
514
- /* Allow chromatic to capture the full content snapshot */
515
- .paywall-story-frame :global(.paywall-content-scroll) {
516
- flex: 1 1 auto;
517
- overflow-y: visible;
518
- }
519
643
  </style>
@@ -32,13 +32,17 @@
32
32
  type PackageInfo,
33
33
  type VariableDictionary,
34
34
  } from "../../types/variables";
35
+ import { paywallRootBackgroundModel } from "../../utils/background-utils";
36
+ import { css } from "../../utils/base-utils";
35
37
  import { STICKY_OVERLAY_Z_INDEX } from "../../utils/constants";
38
+ import { applyDocumentBackground } from "../../utils/document-background";
36
39
  import { registerFonts } from "../../utils/font-utils";
37
40
  import { findSelectedPackageId } from "../../utils/style-utils";
38
41
  import { onMount } from "svelte";
39
42
  import { derived, readable, writable } from "svelte/store";
40
43
  import Stack from "../stack/Stack.svelte";
41
44
  import Sheet from "./Sheet.svelte";
45
+ import ViewportBackdrop from "./ViewportBackdrop.svelte";
42
46
  import {
43
47
  type PackageInfoStore,
44
48
  setPackageInfoContext,
@@ -128,13 +132,39 @@
128
132
  customVariables = {},
129
133
  }: Props = $props();
130
134
 
131
- setColorModeContext(() => preferredColorMode);
135
+ const getColorMode = setColorModeContext(() => preferredColorMode);
136
+
137
+ const viewportBackdropModel = $derived(
138
+ paywallRootBackgroundModel(paywallData, getColorMode()),
139
+ );
140
+
141
+ const instanceId: symbol = Symbol();
142
+ $effect(() =>
143
+ applyDocumentBackground(instanceId, viewportBackdropModel, paywallData),
144
+ );
132
145
 
133
146
  const { default_locale, components_config, components_localizations } =
134
147
  paywallData;
135
148
  const { base } = components_config;
136
149
  const defaultPackageId = findSelectedPackageId(base);
137
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
+
138
168
  const { getLocalizedString } = setLocalizationContext(() => ({
139
169
  defaultLocale: default_locale,
140
170
  selectedLocale,
@@ -300,7 +330,8 @@
300
330
  </script>
301
331
 
302
332
  <svelte:boundary onerror={onError}>
303
- <div class={paywallClass}>
333
+ <div class={paywallClass} style={paywallStyle}>
334
+ <ViewportBackdrop model={viewportBackdropModel} />
304
335
  {#if header}
305
336
  <div
306
337
  class="header-wrapper"
@@ -345,10 +376,9 @@
345
376
  .paywall {
346
377
  position: relative;
347
378
  display: flex;
348
- flex: 1 1 auto;
349
379
  flex-direction: column;
350
380
  align-items: stretch;
351
- min-height: 0;
381
+ height: 100%;
352
382
 
353
383
  transition-property: filter, transform;
354
384
  transition-duration: 0.1s;
@@ -370,13 +400,13 @@
370
400
  .paywall-content {
371
401
  z-index: 2;
372
402
  width: 100%;
373
- flex: 1;
403
+ flex: 1 1 auto;
374
404
  display: flex;
375
405
  flex-direction: column;
376
406
  min-height: 0;
377
407
 
378
408
  & > :global(.paywall-content-scroll) {
379
- flex: 1 1 0;
409
+ flex: 1 1 auto;
380
410
  min-height: 0;
381
411
  overflow-y: auto;
382
412
  }