@revenuecat/purchases-ui-js 4.1.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 (30) 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 +133 -0
  6. package/dist/components/paywall/Sheet.svelte +66 -3
  7. package/dist/components/paywall/ViewportBackdrop.svelte +40 -9
  8. package/dist/components/paywall/fixtures/sheet-video-stacking-paywall.d.ts +6 -0
  9. package/dist/components/paywall/fixtures/sheet-video-stacking-paywall.js +42 -0
  10. package/dist/components/stack/Stack.stories.svelte +74 -0
  11. package/dist/components/stack/Stack.svelte +58 -3
  12. package/dist/components/tabs/Tabs.stories.svelte +27 -0
  13. package/dist/components/tabs/Tabs.svelte +69 -4
  14. package/dist/components/tabs/tabs-video-stacking-story-args.d.ts +3 -0
  15. package/dist/components/tabs/tabs-video-stacking-story-args.js +131 -0
  16. package/dist/components/video/Video.svelte +32 -56
  17. package/dist/stories/video-background-story-fixture.d.ts +3 -0
  18. package/dist/stories/video-background-story-fixture.js +36 -0
  19. package/dist/types/background.d.ts +9 -1
  20. package/dist/ui/molecules/button.svelte +2 -2
  21. package/dist/utils/background-utils.d.ts +30 -1
  22. package/dist/utils/background-utils.js +53 -5
  23. package/dist/utils/match-media-compat.d.ts +4 -0
  24. package/dist/utils/match-media-compat.js +11 -0
  25. package/dist/utils/video-background-host.d.ts +31 -0
  26. package/dist/utils/video-background-host.js +25 -0
  27. package/dist/utils/video-inline-playback.d.ts +25 -0
  28. package/dist/utils/video-inline-playback.js +78 -0
  29. package/dist/web-components/index.css +1 -1
  30. package/package.json +1 -1
@@ -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;
@@ -27,6 +27,7 @@
27
27
  import { BACKGROUND_PAYWALL } from "./fixtures/background-paywall";
28
28
  import { OVERRIDE_PAYWALL } from "./fixtures/override-paywall";
29
29
  import { SHEET_PAYWALL } from "./fixtures/sheet-paywall";
30
+ import { SHEET_PAYWALL_VIDEO_STACKING } from "./fixtures/sheet-video-stacking-paywall";
30
31
  import { STACK_PAYWALL } from "./fixtures/stack-paywall";
31
32
  import { VARIABLES } from "./fixtures/variables";
32
33
  import { CUSTOM_VARIABLES_PAYWALL } from "./fixtures/custom-variables-paywall";
@@ -93,6 +94,28 @@
93
94
  }}
94
95
  />
95
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
+
96
119
  <Story
97
120
  name="Background - Color"
98
121
  decorators={[viewportDecorator(500, 500, 0)]}
@@ -231,6 +254,116 @@
231
254
  }}
232
255
  />
233
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
+
234
367
  <Story
235
368
  name="Primary"
236
369
  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 "./BackgroundVideoSurface.svelte";
5
6
  import type { SheetProps } from "../../types/components/sheet";
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 { css, mapSize } from "../../utils/base-utils";
8
16
  import { getActiveStateProps } from "../../utils/style-utils";
9
17
  import { onMount } from "svelte";
@@ -23,6 +31,14 @@
23
31
  const getColorMode = getColorModeContext();
24
32
  const colorMode = $derived(getColorMode());
25
33
 
34
+ const hasVideoBg = $derived(background?.type === "video");
35
+ const videoSurface = $derived.by(() => {
36
+ if (!hasVideoBg) return null;
37
+ const bg = background;
38
+ if (!bg || bg.type !== "video") return null;
39
+ return resolveBackgroundVideoForSurface(colorMode, bg);
40
+ });
41
+
26
42
  const backdropStyle = $derived(
27
43
  css({
28
44
  background: background_blur ? "#0000003f" : "",
@@ -32,6 +48,10 @@
32
48
  const sheetStyle = $derived(
33
49
  css({
34
50
  height: mapSize(size.height),
51
+ ...videoBackgroundHostStyles({
52
+ hasVideoBg,
53
+ clipOverflow: true,
54
+ }),
35
55
  ...mapBackground(colorMode, null, background),
36
56
  }),
37
57
  );
@@ -75,7 +95,14 @@
75
95
  }
76
96
  };
77
97
 
78
- let sheetClass = $derived(`sheet ${visible ? "visible" : ""}`);
98
+ let sheetClass = $derived(
99
+ [
100
+ `sheet ${visible ? "visible" : ""}`,
101
+ hasVideoBg ? rcVideoBackgroundHostClass("sheet") : "",
102
+ ]
103
+ .filter(Boolean)
104
+ .join(" "),
105
+ );
79
106
  </script>
80
107
 
81
108
  <div
@@ -95,7 +122,21 @@
95
122
  aria-modal="true"
96
123
  {ontransitionend}
97
124
  >
98
- <Stack {...stack} />
125
+ {#if videoSurface}
126
+ <BackgroundVideoSurface
127
+ url={videoSurface.url}
128
+ urlLowRes={videoSurface.url_low_res}
129
+ objectFit={videoSurface.fit}
130
+ objectPosition={videoSurface.position}
131
+ poster={videoSurface.posterWebp}
132
+ mute={videoSurface.mute}
133
+ loop={videoSurface.loop}
134
+ />
135
+ {/if}
136
+ <Stack
137
+ {...stack}
138
+ class={hasVideoBg ? "rc-sheet-stack-above-video" : undefined}
139
+ />
99
140
  </div>
100
141
  </div>
101
142
 
@@ -119,8 +160,30 @@
119
160
  transition-timing-function: cubic-bezier(0, 0, 0.2, 1);
120
161
  transform: translateY(100%);
121
162
 
163
+ &::before {
164
+ content: "";
165
+ position: absolute;
166
+ inset: 0;
167
+ background: var(--overlay);
168
+ pointer-events: none;
169
+ }
170
+
122
171
  &.visible {
123
172
  transform: translateY(0);
124
173
  }
125
174
  }
175
+
176
+ .sheet.rc-sheet-video-bg::before {
177
+ z-index: 2;
178
+ }
179
+
180
+ /* Match stack video stacking: tint above video (::before z-index). */
181
+ .sheet.rc-sheet-video-bg > :global(.rc-bg-video) {
182
+ z-index: 1;
183
+ }
184
+
185
+ .sheet.rc-sheet-video-bg > :global(.rc-sheet-stack-above-video) {
186
+ position: relative;
187
+ z-index: 3;
188
+ }
126
189
  </style>
@@ -1,5 +1,6 @@
1
1
  <script lang="ts">
2
2
  import type { PaywallRootBackgroundModel } from "../../utils/background-utils";
3
+ import BackgroundVideoSurface from "./BackgroundVideoSurface.svelte";
3
4
 
4
5
  // iOS WebKit only reliably propagates background-color (not background-image)
5
6
  // through to the safe-area canvas. A position:fixed element that explicitly
@@ -27,19 +28,43 @@
27
28
  return "";
28
29
  });
29
30
 
30
- const overlayStyle = $derived(
31
- model.kind === "image" && model.overlay && model.overlay !== "none"
32
- ? `background:${model.overlay}`
33
- : null,
34
- );
31
+ const overlayStyle = $derived.by((): string | null => {
32
+ if (model.kind !== "image" && model.kind !== "video") {
33
+ return null;
34
+ }
35
+ if (!model.overlay || model.overlay === "none") {
36
+ return null;
37
+ }
38
+ return `background:${model.overlay}`;
39
+ });
35
40
 
36
- const shouldRender = $derived(backdropStyle !== "");
41
+ const shouldRenderBackdrop = $derived(
42
+ backdropStyle !== "" || model.kind === "video",
43
+ );
37
44
  </script>
38
45
 
39
- {#if shouldRender}
40
- <div class="viewport-backdrop" style={backdropStyle}>
46
+ {#if shouldRenderBackdrop}
47
+ <div
48
+ class="viewport-backdrop"
49
+ style={backdropStyle !== "" ? backdropStyle : undefined}
50
+ >
51
+ {#if model.kind === "video"}
52
+ <BackgroundVideoSurface
53
+ url={model.url}
54
+ urlLowRes={model.url_low_res}
55
+ objectFit={model.fit}
56
+ objectPosition={model.position}
57
+ poster={model.posterWebp}
58
+ mute={model.mute}
59
+ loop={model.loop}
60
+ />
61
+ {/if}
41
62
  {#if overlayStyle}
42
- <div class="viewport-backdrop-overlay" style={overlayStyle}></div>
63
+ <div
64
+ class="viewport-backdrop-overlay"
65
+ class:z-over-video-bg={model.kind === "video"}
66
+ style={overlayStyle}
67
+ ></div>
43
68
  {/if}
44
69
  </div>
45
70
  {/if}
@@ -51,11 +76,17 @@
51
76
  z-index: 0;
52
77
  pointer-events: none;
53
78
  overflow: hidden;
79
+ isolation: isolate;
54
80
  }
55
81
 
56
82
  .viewport-backdrop-overlay {
57
83
  position: absolute;
58
84
  inset: 0;
59
85
  pointer-events: none;
86
+ z-index: 1;
87
+ }
88
+
89
+ .viewport-backdrop-overlay.z-over-video-bg {
90
+ z-index: 2;
60
91
  }
61
92
  </style>