@revenuecat/purchases-ui-js 4.0.0 → 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.
@@ -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,
@@ -63,9 +62,7 @@
63
62
 
64
63
  {#snippet template(props: ComponentProps<typeof Paywall>)}
65
64
  <div class="paywall-story-frame">
66
- <Main paywallData={props.paywallData}>
67
- <Paywall {...props} />
68
- </Main>
65
+ <Paywall {...props} />
69
66
  </div>
70
67
  {/snippet}
71
68
 
@@ -510,10 +507,4 @@
510
507
  background-color: var(--rc-purchases-ui-bg-color, Canvas);
511
508
  color-scheme: light dark;
512
509
  }
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
510
  </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
  }
@@ -1,14 +1,10 @@
1
1
  <script lang="ts">
2
- import type { PaywallRootBackgroundModel } from "../../../utils/background-utils";
2
+ import type { PaywallRootBackgroundModel } from "../../utils/background-utils";
3
3
 
4
- // This component exists because iOS WebKit does not reliably extend body's
5
- // background-image (gradients, bitmaps) into safe-area strips only background-color
6
- // propagates reliably via the viewport canvas. A dedicated position:fixed element that
7
- // explicitly fills the full viewport (including safe areas) is the consistent paint
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
8
7
  // surface for gradients and images on iOS Safari.
9
- //
10
- // Solid colours are NOT handled here — they use background-color on body instead,
11
- // which avoids compositor surface churn on navigation.
12
8
  const { model }: { model: PaywallRootBackgroundModel } = $props();
13
9
 
14
10
  const backdropStyle = $derived.by((): string => {
@@ -1,4 +1,4 @@
1
- import type { PaywallRootBackgroundModel } from "../../../utils/background-utils";
1
+ import type { PaywallRootBackgroundModel } from "../../utils/background-utils";
2
2
  type $$ComponentProps = {
3
3
  model: PaywallRootBackgroundModel;
4
4
  };
@@ -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>
@@ -3,7 +3,6 @@
3
3
  import type { ComponentProps } from "svelte";
4
4
 
5
5
  import Screen from "./Screen.svelte";
6
- import Main from "../layout/Main/Main.svelte";
7
6
  import { paywallData, uiConfigData } from "../../stories/fixtures";
8
7
  import type { WorkflowScreen } from "../../types/workflow";
9
8
 
@@ -33,11 +32,9 @@
33
32
 
34
33
  {#snippet template(props: ComponentProps<typeof Screen>)}
35
34
  <div class="viewport-frame">
36
- <Main paywallData={props.paywallComponents}>
37
- <div class="content-wrapper">
38
- <Screen {...props} />
39
- </div>
40
- </Main>
35
+ <div class="content-wrapper">
36
+ <Screen {...props} />
37
+ </div>
41
38
  </div>
42
39
  {/snippet}
43
40
 
package/dist/index.d.ts CHANGED
@@ -13,7 +13,6 @@ export { default as Stack } from "./components/stack/Stack.svelte";
13
13
  export { default as Text } from "./components/text/Text.svelte";
14
14
  export { default as Timeline } from "./components/timeline/Timeline.svelte";
15
15
  export { default as Screen } from "./components/workflows/Screen.svelte";
16
- export { default as Main } from "./components/layout/Main/Main.svelte";
17
16
  export { default as Video } from "./components/video/Video.svelte";
18
17
  export * from "./types";
19
18
  export { type PaywallData } from "./types/paywall";
package/dist/index.js CHANGED
@@ -14,7 +14,6 @@ export { default as Stack } from "./components/stack/Stack.svelte";
14
14
  export { default as Text } from "./components/text/Text.svelte";
15
15
  export { default as Timeline } from "./components/timeline/Timeline.svelte";
16
16
  export { default as Screen } from "./components/workflows/Screen.svelte";
17
- export { default as Main } from "./components/layout/Main/Main.svelte";
18
17
  export { default as Video } from "./components/video/Video.svelte";
19
18
  export * from "./types";
20
19
  export {} from "./types/paywall";
@@ -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": "4.0.0",
5
+ "version": "4.1.0",
6
6
  "author": {
7
7
  "name": "RevenueCat, Inc."
8
8
  },
@@ -123,4 +123,4 @@
123
123
  "eslint --fix"
124
124
  ]
125
125
  }
126
- }
126
+ }
@@ -1,263 +0,0 @@
1
- <script module lang="ts">
2
- import { defineMeta } from "@storybook/addon-svelte-csf";
3
- import type { ComponentProps } from "svelte";
4
- import Main from "./Main.svelte";
5
- import Screen from "../../workflows/Screen.svelte";
6
- import type { Background } from "../../../types/background";
7
- import type { Component } from "../../../types/component";
8
- import type { HeaderProps } from "../../../types/components/header";
9
- import type { FooterProps } from "../../../types/components/footer";
10
- import type { TextNodeProps } from "../../../types/components/text";
11
- import type { WorkflowScreen } from "../../../types/workflow";
12
- import { uiConfigData } from "../../../stories/fixtures";
13
-
14
- function textComponent(id: string, textLid: string): TextNodeProps {
15
- return {
16
- type: "text",
17
- id,
18
- name: "",
19
- text_lid: textLid,
20
- font_name: null,
21
- font_size: "body_m",
22
- font_weight: "bold",
23
- horizontal_alignment: "center",
24
- color: { light: { type: "hex", value: "#ffffffff" } },
25
- background_color: null,
26
- padding: { top: 0, bottom: 0, leading: 0, trailing: 0 },
27
- margin: { top: 0, bottom: 0, leading: 0, trailing: 0 },
28
- size: { width: { type: "fit" }, height: { type: "fit" } },
29
- };
30
- }
31
-
32
- function visibleStack(components: Component[] = []) {
33
- return {
34
- type: "stack" as const,
35
- id: "s",
36
- name: "",
37
- components,
38
- size: {
39
- width: { type: "fill" as const },
40
- height: { type: "fit" as const },
41
- },
42
- dimension: {
43
- type: "vertical" as const,
44
- alignment: "center" as const,
45
- distribution: "center" as const,
46
- },
47
- spacing: 0,
48
- margin: { top: 0, bottom: 0, leading: 0, trailing: 0 },
49
- padding: { top: 16, bottom: 16, leading: 0, trailing: 0 },
50
- background_color: { light: { type: "hex" as const, value: "#00000033" } },
51
- background: null,
52
- border: null,
53
- shape: null,
54
- shadow: null,
55
- badge: null,
56
- };
57
- }
58
-
59
- const minimalHeader: HeaderProps = {
60
- id: "header",
61
- name: "Header",
62
- type: "header",
63
- stack: visibleStack([textComponent("header-label", "header_label")]),
64
- };
65
-
66
- const minimalFooter: FooterProps = {
67
- id: "footer",
68
- name: "Footer",
69
- type: "footer",
70
- stack: visibleStack([textComponent("footer-label", "footer_label")]),
71
- };
72
-
73
- function shellData(
74
- background: Background,
75
- hasHeader: boolean,
76
- hasFooter: boolean,
77
- ): WorkflowScreen {
78
- return {
79
- id: "main-story",
80
- default_locale: "en_US",
81
- components_localizations: {
82
- en_US: { header_label: "Header", footer_label: "Footer" },
83
- },
84
- asset_base_url: "https://assets.pawwalls.com",
85
- config: {},
86
- localized_strings: {},
87
- localized_strings_by_tier: {},
88
- name: "Main story",
89
- offering_id: null,
90
- revision: 1,
91
- template_name: "stack",
92
- components_config: {
93
- base: {
94
- background,
95
- header: hasHeader ? minimalHeader : null,
96
- sticky_footer: hasFooter ? minimalFooter : null,
97
- stack: {
98
- type: "stack" as const,
99
- id: "content",
100
- name: "",
101
- components: [],
102
- size: {
103
- width: { type: "fill" as const },
104
- height: { type: "fill" as const },
105
- },
106
- dimension: {
107
- type: "vertical" as const,
108
- alignment: "center" as const,
109
- distribution: "start" as const,
110
- },
111
- spacing: 0,
112
- margin: { top: 0, bottom: 0, leading: 0, trailing: 0 },
113
- padding: { top: 0, bottom: 0, leading: 0, trailing: 0 },
114
- background_color: null,
115
- background: null,
116
- border: null,
117
- shape: null,
118
- shadow: null,
119
- badge: null,
120
- },
121
- },
122
- },
123
- };
124
- }
125
-
126
- const solidColour: Background = {
127
- type: "color",
128
- value: { light: { type: "hex", value: "#6B4FBBFF" } },
129
- };
130
-
131
- const gradient: Background = {
132
- type: "color",
133
- value: {
134
- light: {
135
- type: "linear",
136
- degrees: 180,
137
- points: [
138
- { percent: 0, color: "#3B82F6FF" },
139
- { percent: 100, color: "#9333EAFF" },
140
- ],
141
- },
142
- },
143
- };
144
-
145
- const image: Background = {
146
- type: "image",
147
- fit_mode: "fill",
148
- color_overlay: null,
149
- value: {
150
- light: {
151
- width: 1170,
152
- height: 2532,
153
- original: "https://placehold.co/1170x2532",
154
- heic: "https://placehold.co/1170x2532",
155
- heic_low_res: "https://placehold.co/1170x2532",
156
- webp: "https://placehold.co/1170x2532",
157
- webp_low_res: "https://placehold.co/1170x2532",
158
- },
159
- },
160
- };
161
-
162
- const { Story } = defineMeta({
163
- title: "Components/Layout/Main",
164
- component: Main,
165
- render: template,
166
- args: { paywallData: shellData(solidColour, false, false) },
167
- });
168
- </script>
169
-
170
- {#snippet template({
171
- paywallData,
172
- preferredColorMode,
173
- }: ComponentProps<typeof Main>)}
174
- <div class="viewport-frame">
175
- <Main {paywallData} {preferredColorMode}>
176
- <div class="content-wrapper">
177
- <Screen paywallComponents={paywallData} uiConfig={uiConfigData} />
178
- </div>
179
- </Main>
180
- </div>
181
- {/snippet}
182
-
183
- <Story
184
- name="Solid colour"
185
- args={{ paywallData: shellData(solidColour, false, false) }}
186
- />
187
- <Story
188
- name="Solid colour — header"
189
- args={{ paywallData: shellData(solidColour, true, false) }}
190
- />
191
- <Story
192
- name="Solid colour — footer"
193
- args={{ paywallData: shellData(solidColour, false, true) }}
194
- />
195
- <Story
196
- name="Solid colour — header and footer"
197
- args={{ paywallData: shellData(solidColour, true, true) }}
198
- />
199
-
200
- <Story
201
- name="Gradient"
202
- args={{ paywallData: shellData(gradient, false, false) }}
203
- />
204
- <Story
205
- name="Gradient — header"
206
- args={{ paywallData: shellData(gradient, true, false) }}
207
- />
208
- <Story
209
- name="Gradient — footer"
210
- args={{ paywallData: shellData(gradient, false, true) }}
211
- />
212
- <Story
213
- name="Gradient — header and footer"
214
- args={{ paywallData: shellData(gradient, true, true) }}
215
- />
216
-
217
- <Story name="Image" args={{ paywallData: shellData(image, false, false) }} />
218
- <Story
219
- name="Image — header"
220
- args={{ paywallData: shellData(image, true, false) }}
221
- />
222
- <Story
223
- name="Image — footer"
224
- args={{ paywallData: shellData(image, false, true) }}
225
- />
226
- <Story
227
- name="Image — header and footer"
228
- args={{ paywallData: shellData(image, true, true) }}
229
- />
230
-
231
- <style>
232
- /* A fixed-position frame that emulates rc-workflows' <body> (see app.css).
233
- Using position:fixed escapes Storybook's DOM chain (.sb-show-main, #storybook-root, PaywallWrapper) */
234
- .viewport-frame {
235
- position: fixed;
236
- inset: 0;
237
- display: flex;
238
- flex-direction: column;
239
- padding: env(safe-area-inset-top) env(safe-area-inset-right)
240
- env(safe-area-inset-bottom) env(safe-area-inset-left);
241
- background-color: var(--rc-purchases-ui-bg-color, Canvas);
242
- color-scheme: light dark;
243
- overflow: hidden;
244
- }
245
-
246
- .content-wrapper {
247
- width: 100%;
248
- display: flex;
249
- flex: 1 1 auto;
250
- flex-direction: column;
251
- min-height: 0;
252
- overflow: hidden;
253
- }
254
-
255
- /* Screen's containerId is "screen-container" by default */
256
- :global(#screen-container) {
257
- display: flex;
258
- flex-direction: column;
259
- flex: 1 1 auto;
260
- min-height: 0;
261
- overflow: hidden;
262
- }
263
- </style>
@@ -1,19 +0,0 @@
1
- import Main from "./Main.svelte";
2
- interface $$__sveltets_2_IsomorphicComponent<Props extends Record<string, any> = any, Events extends Record<string, any> = any, Slots extends Record<string, any> = any, Exports = {}, Bindings = string> {
3
- new (options: import('svelte').ComponentConstructorOptions<Props>): import('svelte').SvelteComponent<Props, Events, Slots> & {
4
- $$bindings?: Bindings;
5
- } & Exports;
6
- (internal: unknown, props: {
7
- $$events?: Events;
8
- $$slots?: Slots;
9
- }): Exports & {
10
- $set?: any;
11
- $on?: any;
12
- };
13
- z_$$bindings?: Bindings;
14
- }
15
- declare const Main: $$__sveltets_2_IsomorphicComponent<Record<string, never>, {
16
- [evt: string]: CustomEvent<any>;
17
- }, {}, {}, string>;
18
- type Main = InstanceType<typeof Main>;
19
- export default Main;
@@ -1,117 +0,0 @@
1
- <script lang="ts">
2
- import type { Snippet } from "svelte";
3
- import type { ColorMode } from "../../../types";
4
- import type { PaywallData } from "../../../types/paywall";
5
- import { paywallRootBackgroundModel } from "../../../utils/background-utils";
6
- import { MediaQuery } from "svelte/reactivity";
7
- import ViewportBackdrop from "./ViewportBackdrop.svelte";
8
-
9
- // Tracks the last Main instance that wrote --rc-purchases-ui-bg-color.
10
- // Cleanup only removes the variable if this instance is still the last writer,
11
- // so an unmounting shell won't clear a value set by a shell that overlapped it
12
- // during a page transition.
13
- let lastBgWriter: symbol | null = null;
14
-
15
- interface Props {
16
- paywallData: PaywallData | null | undefined;
17
- preferredColorMode?: ColorMode;
18
- children: Snippet;
19
- }
20
-
21
- const { paywallData, preferredColorMode, children }: Props = $props();
22
-
23
- const instanceId: symbol = Symbol();
24
-
25
- const prefersDark = new MediaQuery("prefers-color-scheme: dark", false);
26
- const colorMode: ColorMode = $derived(
27
- preferredColorMode ?? (prefersDark.current ? "dark" : "light"),
28
- );
29
-
30
- const viewportBackdropModel = $derived(
31
- paywallRootBackgroundModel(paywallData, colorMode),
32
- );
33
-
34
- // When a sticky header or footer is present, iOS promotes it to its own compositor
35
- // layer which breaks the fixed backdrop's safe-area painting for the opposite strip.
36
- // Returns the edge stop colour to paint on body to patch the uncovered strip, or null.
37
- // Only works for exactly vertical gradients (0°/180°) — any other angle produces a
38
- // horizontal colour range across the strip that no single solid colour can represent.
39
- function gradientSafeAreaFallbackColour(
40
- gradient: string,
41
- hasHeader: boolean,
42
- hasFooter: boolean,
43
- ): string | null {
44
- if (!hasHeader && !hasFooter) return null;
45
- if (hasHeader && hasFooter) return null;
46
- const angleMatch = gradient.match(/linear-gradient\((\d+)deg/);
47
- if (!angleMatch) return null;
48
- if (parseInt(angleMatch[1], 10) % 180 !== 0) return null;
49
- const stops = gradient.match(/#[0-9a-fA-F]{6,8}/g);
50
- if (!stops || stops.length < 2) return null;
51
- const candidate = hasHeader ? stops[stops.length - 1] : stops[0];
52
- if (candidate.length === 9 && candidate.slice(-2).toLowerCase() !== "ff") {
53
- return null;
54
- }
55
- return candidate;
56
- }
57
-
58
- // Solid colours use --rc-purchases-ui-bg-color on body — iOS reliably propagates
59
- // background-color to the viewport canvas (safe-area strips). Gradients and images
60
- // are handled by ViewportBackdrop (position:fixed) instead.
61
- $effect(() => {
62
- if (typeof document === "undefined") return;
63
- const model = viewportBackdropModel;
64
- const root = document.documentElement;
65
-
66
- let value: string | null = null;
67
- if (model.kind === "style" && model.style.background) {
68
- const bg = model.style.background;
69
- if (!bg.includes("gradient")) {
70
- value = bg;
71
- } else {
72
- const base = paywallData?.components_config?.base;
73
- value = gradientSafeAreaFallbackColour(
74
- bg,
75
- !!base?.header,
76
- !!base?.sticky_footer,
77
- );
78
- }
79
- }
80
-
81
- if (value !== null) {
82
- lastBgWriter = instanceId;
83
- root.style.setProperty("--rc-purchases-ui-bg-color", value);
84
- }
85
-
86
- return () => {
87
- if (lastBgWriter === instanceId) {
88
- lastBgWriter = null;
89
- root.style.removeProperty("--rc-purchases-ui-bg-color");
90
- }
91
- };
92
- });
93
- </script>
94
-
95
- <div class="main">
96
- <ViewportBackdrop model={viewportBackdropModel} />
97
- <main class="main-content">
98
- {@render children()}
99
- </main>
100
- </div>
101
-
102
- <style>
103
- .main {
104
- flex: 1 1 auto;
105
- display: flex;
106
- flex-direction: column;
107
- min-height: 0;
108
- }
109
-
110
- .main-content {
111
- position: relative;
112
- z-index: 1;
113
- display: flex;
114
- flex: 1 1 auto;
115
- min-height: 0;
116
- }
117
- </style>
@@ -1,11 +0,0 @@
1
- import type { Snippet } from "svelte";
2
- import type { ColorMode } from "../../../types";
3
- import type { PaywallData } from "../../../types/paywall";
4
- interface Props {
5
- paywallData: PaywallData | null | undefined;
6
- preferredColorMode?: ColorMode;
7
- children: Snippet;
8
- }
9
- declare const Main: import("svelte").Component<Props, {}, "">;
10
- type Main = ReturnType<typeof Main>;
11
- export default Main;