@revenuecat/purchases-ui-js 4.3.0 → 4.5.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.
@@ -41,6 +41,7 @@
41
41
  keyboard_type,
42
42
  capitalize,
43
43
  field_id,
44
+ reserved_attribute,
44
45
  required,
45
46
  size,
46
47
  padding,
@@ -74,7 +75,15 @@
74
75
  }
75
76
 
76
77
  const value = input.value.trim();
77
- onInputChanged?.(field_id, value);
78
+ if (reserved_attribute) {
79
+ if (!onReservedAttributeChanged) {
80
+ console.error("onReservedAttributeChanged is not set");
81
+ } else {
82
+ onReservedAttributeChanged(reserved_attribute, value);
83
+ }
84
+ } else {
85
+ onInputChanged?.(field_id, value);
86
+ }
78
87
  }
79
88
 
80
89
  const oninput: FormEventHandler<HTMLInputElement> = (event) => {
@@ -90,7 +99,12 @@
90
99
 
91
100
  const getColorMode = getColorModeContext();
92
101
  const colorMode = $derived(getColorMode());
93
- const { uiConfig, onInputChanged, selectedPackageId } = getPaywallContext();
102
+ const {
103
+ uiConfig,
104
+ onInputChanged,
105
+ onReservedAttributeChanged,
106
+ selectedPackageId,
107
+ } = getPaywallContext();
94
108
  const packageInfo = getOptionalPackageInfoContext();
95
109
 
96
110
  const wrapperStyle = $derived(
@@ -15,6 +15,7 @@
15
15
  type VariablesStore,
16
16
  } from "../../stores/variables";
17
17
  import type { ColorMode } from "../../types";
18
+ import type { ColorScheme } from "../../types/colors";
18
19
  import type {
19
20
  Action,
20
21
  CompleteWorkflowNavigateArgs,
@@ -48,6 +49,7 @@
48
49
  setPackageInfoContext,
49
50
  } from "../../stores/packageInfo";
50
51
  import type { WalletButtonRender } from "../../types/wallet";
52
+ import type { ReservedAttribute } from "../../types/components/input-text";
51
53
 
52
54
  /**
53
55
  * Props are captured once at mount and are not reactive to subsequent changes.
@@ -89,6 +91,10 @@
89
91
  value: string,
90
92
  actionId?: string,
91
93
  ) => void;
94
+ onReservedAttributeChanged?: (
95
+ reservedAttribute: ReservedAttribute,
96
+ value: string,
97
+ ) => void;
92
98
  maxContentWidth?: string;
93
99
  initialInputSelections?: InitialInputSelections;
94
100
  /**
@@ -105,6 +111,12 @@
105
111
  * ```
106
112
  */
107
113
  customVariables?: CustomVariables;
114
+ /**
115
+ * Optional baseline safe-area colour applied when the paywall background
116
+ * can't derive one and `background.safe_area_fallback_color` is unset.
117
+ * Hosts (e.g. workflow runtimes) pass their workflow-level fallback here.
118
+ */
119
+ safeAreaFallbackColor?: ColorScheme | null;
108
120
  }
109
121
 
110
122
  const {
@@ -126,10 +138,12 @@
126
138
  uiConfig,
127
139
  walletButtonRender,
128
140
  onInputChanged,
141
+ onReservedAttributeChanged,
129
142
  hideBackButtons = false,
130
143
  maxContentWidth,
131
144
  initialInputSelections = {},
132
145
  customVariables = {},
146
+ safeAreaFallbackColor,
133
147
  }: Props = $props();
134
148
 
135
149
  const getColorMode = setColorModeContext(() => preferredColorMode);
@@ -140,7 +154,11 @@
140
154
 
141
155
  const instanceId: symbol = Symbol();
142
156
  $effect(() =>
143
- applyDocumentBackground(instanceId, viewportBackdropModel, paywallData),
157
+ applyDocumentBackground(instanceId, viewportBackdropModel, {
158
+ paywallData,
159
+ colorMode: getColorMode(),
160
+ hostFallbackColor: safeAreaFallbackColor,
161
+ }),
144
162
  );
145
163
 
146
164
  const { default_locale, components_config, components_localizations } =
@@ -285,6 +303,7 @@
285
303
  onNavigateToUrl: onNavigateToUrlClicked,
286
304
  onButtonAction,
287
305
  onInputChanged,
306
+ onReservedAttributeChanged,
288
307
  walletButtonRender,
289
308
  uiConfig,
290
309
  hideBackButtons,
@@ -1,11 +1,13 @@
1
1
  import { type InitialInputSelections } from "../../stores/inputValidation";
2
2
  import type { ColorMode } from "../../types";
3
+ import type { ColorScheme } from "../../types/colors";
3
4
  import type { CompleteWorkflowNavigateArgs } from "../../types/components/button";
4
5
  import type { OnComponentInteraction } from "../../types/paywall-component-interaction";
5
6
  import type { PaywallData } from "../../types/paywall";
6
7
  import type { UIConfig } from "../../types/ui-config";
7
8
  import { type CustomVariables, type PackageInfo, type VariableDictionary } from "../../types/variables";
8
9
  import type { WalletButtonRender } from "../../types/wallet";
10
+ import type { ReservedAttribute } from "../../types/components/input-text";
9
11
  /**
10
12
  * Props are captured once at mount and are not reactive to subsequent changes.
11
13
  * The paywall should be remounted to reflect new prop values.
@@ -40,6 +42,7 @@ interface Props {
40
42
  hideBackButtons?: boolean;
41
43
  walletButtonRender?: WalletButtonRender;
42
44
  onInputChanged?: (fieldId: string, value: string, actionId?: string) => void;
45
+ onReservedAttributeChanged?: (reservedAttribute: ReservedAttribute, value: string) => void;
43
46
  maxContentWidth?: string;
44
47
  initialInputSelections?: InitialInputSelections;
45
48
  /**
@@ -56,6 +59,12 @@ interface Props {
56
59
  * ```
57
60
  */
58
61
  customVariables?: CustomVariables;
62
+ /**
63
+ * Optional baseline safe-area colour applied when the paywall background
64
+ * can't derive one and `background.safe_area_fallback_color` is unset.
65
+ * Hosts (e.g. workflow runtimes) pass their workflow-level fallback here.
66
+ */
67
+ safeAreaFallbackColor?: ColorScheme | null;
59
68
  }
60
69
  declare const Paywall: import("svelte").Component<Props, {}, "">;
61
70
  type Paywall = ReturnType<typeof Paywall>;
@@ -1,12 +1,14 @@
1
1
  <script lang="ts">
2
2
  import Paywall from "../paywall/Paywall.svelte";
3
3
  import type { CompleteWorkflowNavigateArgs } from "../../types/components/button";
4
+ import type { ColorScheme } from "../../types/colors";
4
5
  import type { InitialInputSelections } from "../../stores/inputValidation";
5
6
  import type { OnComponentInteraction } from "../../types/paywall-component-interaction";
6
7
  import type { WorkflowScreen } from "../../types/workflow";
7
8
  import type { VariableDictionary } from "../../types/variables";
8
9
  import type { WalletButtonRender } from "../../types/wallet";
9
10
  import type { UIConfig } from "../../types/ui-config";
11
+ import type { ReservedAttribute } from "../../types/components/input-text";
10
12
  interface Props {
11
13
  paywallComponents: WorkflowScreen | null | undefined;
12
14
  selectedLocale?: string;
@@ -28,10 +30,15 @@
28
30
  value: string,
29
31
  actionId?: string,
30
32
  ) => void;
33
+ onReservedAttributeChanged?: (
34
+ reservedAttribute: ReservedAttribute,
35
+ value: string,
36
+ ) => void;
31
37
  onCompleteWorkflowNavigate?: (
32
38
  args: CompleteWorkflowNavigateArgs,
33
39
  ) => void | Promise<void>;
34
40
  walletButtonRender?: WalletButtonRender;
41
+ safeAreaFallbackColor?: ColorScheme | null;
35
42
  }
36
43
  const {
37
44
  paywallComponents,
@@ -47,8 +54,10 @@
47
54
  variablesPerPackage,
48
55
  initialInputSelections = {},
49
56
  onInputChanged,
57
+ onReservedAttributeChanged,
50
58
  onCompleteWorkflowNavigate,
51
59
  walletButtonRender,
60
+ safeAreaFallbackColor,
52
61
  }: Props = $props();
53
62
  </script>
54
63
 
@@ -75,7 +84,9 @@
75
84
  {onComponentInteraction}
76
85
  {onPurchaseClicked}
77
86
  {onInputChanged}
87
+ {onReservedAttributeChanged}
78
88
  {walletButtonRender}
89
+ {safeAreaFallbackColor}
79
90
  onError={(error) => {
80
91
  console.error("Paywall error:", error);
81
92
  }}
@@ -1,10 +1,12 @@
1
1
  import type { CompleteWorkflowNavigateArgs } from "../../types/components/button";
2
+ import type { ColorScheme } from "../../types/colors";
2
3
  import type { InitialInputSelections } from "../../stores/inputValidation";
3
4
  import type { OnComponentInteraction } from "../../types/paywall-component-interaction";
4
5
  import type { WorkflowScreen } from "../../types/workflow";
5
6
  import type { VariableDictionary } from "../../types/variables";
6
7
  import type { WalletButtonRender } from "../../types/wallet";
7
8
  import type { UIConfig } from "../../types/ui-config";
9
+ import type { ReservedAttribute } from "../../types/components/input-text";
8
10
  interface Props {
9
11
  paywallComponents: WorkflowScreen | null | undefined;
10
12
  selectedLocale?: string;
@@ -19,8 +21,10 @@ interface Props {
19
21
  variablesPerPackage?: Record<string, VariableDictionary>;
20
22
  initialInputSelections?: InitialInputSelections;
21
23
  onInputChanged?: (fieldId: string, value: string, actionId?: string) => void;
24
+ onReservedAttributeChanged?: (reservedAttribute: ReservedAttribute, value: string) => void;
22
25
  onCompleteWorkflowNavigate?: (args: CompleteWorkflowNavigateArgs) => void | Promise<void>;
23
26
  walletButtonRender?: WalletButtonRender;
27
+ safeAreaFallbackColor?: ColorScheme | null;
24
28
  }
25
29
  declare const Screen: import("svelte").Component<Props, {}, "">;
26
30
  type Screen = ReturnType<typeof Screen>;
@@ -1,4 +1,5 @@
1
1
  import type { Action } from "../types/components/button";
2
+ import type { ReservedAttribute } from "../types/components/input-text";
2
3
  import type { ComponentInteractionData } from "../types/paywall-component-interaction";
3
4
  import type { UIConfig } from "../types/ui-config";
4
5
  import type { PackageInfo, VariableDictionary } from "../types/variables";
@@ -21,6 +22,7 @@ type PaywallContext = Readonly<{
21
22
  emitComponentInteraction: (data: ComponentInteractionData) => void;
22
23
  onNavigateToUrl?: (url: string) => void;
23
24
  onInputChanged?: (fieldId: string, value: string, actionId?: string) => void;
25
+ onReservedAttributeChanged?: (reservedAttribute: ReservedAttribute, value: string) => void;
24
26
  walletButtonRender?: WalletButtonRender;
25
27
  onWalletButtonReady?: (walletButtonAvailable?: boolean) => void;
26
28
  onButtonAction: (action: Action, actionId?: string) => void;
@@ -1,4 +1,4 @@
1
- type GradientPoint = {
1
+ export type GradientPoint = {
2
2
  percent: number;
3
3
  color: string;
4
4
  };
@@ -4,6 +4,7 @@ import type { ColorGradientScheme } from "../colors";
4
4
  import type { Overrides } from "../overrides";
5
5
  export type InputTextCapitalizeType = "none" | "sentences" | "words" | "characters";
6
6
  export type InputTextKeyboardType = "decimal" | "email" | "numeric" | "tel" | "text" | "url";
7
+ export type ReservedAttribute = "$email" | "$displayName" | "$phoneNumber";
7
8
  export interface InputTextProps extends BaseComponent {
8
9
  type: "input_text";
9
10
  visible?: boolean | null;
@@ -11,6 +12,7 @@ export interface InputTextProps extends BaseComponent {
11
12
  keyboard_type: InputTextKeyboardType;
12
13
  capitalize: InputTextCapitalizeType;
13
14
  field_id: string;
15
+ reserved_attribute?: ReservedAttribute | null;
14
16
  required: boolean;
15
17
  size: SizeType;
16
18
  padding: Spacing;
@@ -1,4 +1,5 @@
1
1
  import type { Background } from "./background";
2
+ import type { ColorScheme } from "./colors";
2
3
  import type { FooterProps } from "./components/footer";
3
4
  import type { HeaderProps } from "./components/header";
4
5
  import type { StackProps } from "./components/stack";
@@ -8,6 +9,7 @@ export interface RootPaywall {
8
9
  stack: StackProps;
9
10
  sticky_footer?: FooterProps | null;
10
11
  header?: HeaderProps | null;
12
+ safe_area_fallback_color?: ColorScheme | null;
11
13
  }
12
14
  export interface ComponentConfig {
13
15
  colors?: Record<string, string>;
package/dist/types.d.ts CHANGED
@@ -120,4 +120,6 @@ export declare enum StackDistribution {
120
120
  end = "flex-end"
121
121
  }
122
122
  export type { WorkflowScreen } from "./types/workflow";
123
+ export type { ColorScheme } from "./types/colors";
123
124
  export type { ComponentInteractionData, ComponentInteractionType, OnComponentInteraction, } from "./types/paywall-component-interaction";
125
+ export type { ReservedAttribute } from "./types/components/input-text";
@@ -1,3 +1,9 @@
1
1
  import type { PaywallData } from "../types/paywall";
2
2
  import type { PaywallRootBackgroundModel } from "./background-utils";
3
- export declare function applyDocumentBackground(instanceId: symbol, model: PaywallRootBackgroundModel, paywallData: PaywallData | null | undefined): () => void;
3
+ import type { ColorMode } from "../types";
4
+ import type { ColorScheme } from "../types/colors";
5
+ export declare function applyDocumentBackground(instanceId: symbol, model: PaywallRootBackgroundModel, options: {
6
+ paywallData: PaywallData | null | undefined;
7
+ colorMode: ColorMode;
8
+ hostFallbackColor?: ColorScheme | null;
9
+ }): () => void;
@@ -1,40 +1,14 @@
1
+ import { getBackgroundSafeAreaColors, resolveSafeAreaFallbackCss, SAFE_AREA_FALLBACK_COLOR_CSS_VAR, } from "./safe-area-background";
1
2
  // 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.
3
+ // unmounting paywall would clear a variable set by another paywall that
4
+ // overlapped it during a transition.
4
5
  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) {
6
+ export function applyDocumentBackground(instanceId, model, options) {
35
7
  if (typeof document === "undefined")
36
8
  return () => { };
9
+ const { paywallData, colorMode, hostFallbackColor } = options;
37
10
  const root = document.documentElement;
11
+ const base = paywallData?.components_config?.base;
38
12
  let value = null;
39
13
  if (model.kind === "style" && model.style.background) {
40
14
  const bg = model.style.background;
@@ -42,18 +16,26 @@ export function applyDocumentBackground(instanceId, model, paywallData) {
42
16
  value = bg;
43
17
  }
44
18
  else {
45
- const base = paywallData?.components_config?.base;
46
- value = gradientSafeAreaFallbackColour(bg, !!base?.header, !!base?.sticky_footer);
19
+ const edges = getBackgroundSafeAreaColors(base?.background, colorMode, { width: window.innerWidth, height: window.innerHeight }, {
20
+ stickyComponents: {
21
+ hasHeader: base?.header != null,
22
+ hasFooter: base?.sticky_footer != null,
23
+ },
24
+ });
25
+ value = edges.top ?? edges.bottom;
47
26
  }
48
27
  }
28
+ if (value === null) {
29
+ value = resolveSafeAreaFallbackCss(base?.safe_area_fallback_color, hostFallbackColor, colorMode);
30
+ }
49
31
  if (value !== null) {
50
32
  lastBgWriter = instanceId;
51
- root.style.setProperty("--rc-purchases-ui-bg-color", value);
33
+ root.style.setProperty(SAFE_AREA_FALLBACK_COLOR_CSS_VAR, value);
52
34
  }
53
35
  return () => {
54
36
  if (lastBgWriter === instanceId) {
55
37
  lastBgWriter = null;
56
- root.style.removeProperty("--rc-purchases-ui-bg-color");
38
+ root.style.removeProperty(SAFE_AREA_FALLBACK_COLOR_CSS_VAR);
57
39
  }
58
40
  };
59
41
  }
@@ -0,0 +1,21 @@
1
+ import type { ColorMode } from "../types";
2
+ import type { Background } from "../types/background";
3
+ import type { ColorScheme } from "../types/colors";
4
+ import type { PaywallData } from "../types/paywall";
5
+ type Viewport = {
6
+ width: number;
7
+ height: number;
8
+ };
9
+ type SafeAreaEdge = "top" | "bottom";
10
+ export declare const SAFE_AREA_FALLBACK_COLOR_CSS_VAR = "--rc-purchases-ui-bg-color";
11
+ export declare function resolveSafeAreaFallbackCss(paywallOverride: ColorScheme | null | undefined, hostFallback: ColorScheme | null | undefined, colorMode: ColorMode): string | null;
12
+ export declare function getSafeAreaFallbackColorHeadStyles(paywallData: PaywallData | null | undefined, hostFallbackColor?: ColorScheme | null): string;
13
+ export declare function getBackgroundSafeAreaColors(background: Background | null | undefined, colorMode: ColorMode, viewport: Viewport, options?: {
14
+ stickyComponents?: {
15
+ hasHeader?: boolean;
16
+ hasFooter?: boolean;
17
+ };
18
+ paywallFallbackColor?: ColorScheme | null;
19
+ hostFallbackColor?: ColorScheme | null;
20
+ }): Record<SafeAreaEdge, string | null>;
21
+ export {};
@@ -0,0 +1,211 @@
1
+ import { mapColorInfo, mapColorMode } from "./base-utils";
2
+ export const SAFE_AREA_FALLBACK_COLOR_CSS_VAR = "--rc-purchases-ui-bg-color";
3
+ // Light-only schemes must not bleed into the dark media query — a dark rule
4
+ // reading from `light` would override the light value.
5
+ function getSafeAreaFallbackColorInfoForMode(paywallOverride, hostFallback, colorMode) {
6
+ const colorAtMode = (scheme) => colorMode === "dark" ? scheme?.dark : scheme?.light;
7
+ return colorAtMode(paywallOverride) ?? colorAtMode(hostFallback) ?? null;
8
+ }
9
+ const HEX_COLOR_REGEX = /^#[0-9a-fA-F]{6}([0-9a-fA-F]{2})?$/;
10
+ function isHexColor(value) {
11
+ return HEX_COLOR_REGEX.test(value);
12
+ }
13
+ function isOpaqueHexColor(value) {
14
+ const match = HEX_COLOR_REGEX.exec(value);
15
+ return match != null && (match[1] == null || match[1].toLowerCase() === "ff");
16
+ }
17
+ function normalizeHexColor(color) {
18
+ return color.length === 7 ? `${color.toLowerCase()}ff` : color.toLowerCase();
19
+ }
20
+ function getCssHexColor(value) {
21
+ return value?.type === "hex" && isHexColor(value.value) ? value.value : null;
22
+ }
23
+ function isAxisAlignedVerticalGradient(degrees) {
24
+ return Math.abs(((degrees % 180) + 180) % 180) < 1e-10;
25
+ }
26
+ // Alias-aware counterpart for the SDK runtime; mapColorInfo resolves aliases
27
+ // that the hex-only path can't.
28
+ export function resolveSafeAreaFallbackCss(paywallOverride, hostFallback, colorMode) {
29
+ const info = getSafeAreaFallbackColorInfoForMode(paywallOverride, hostFallback, colorMode);
30
+ return info ? mapColorInfo(info) : null;
31
+ }
32
+ // SSR has no real viewport (no off-axis/radial sampling). Hex and alias
33
+ // backgrounds short-circuit immediately; axis-aligned vertical gradients go
34
+ // through the sticky-component disambiguation since edges may differ. Alias
35
+ // values are emitted as var() references — valid CSS that resolves once the
36
+ // SDK stylesheet is present. Invalid hex is skipped rather than emitted.
37
+ function getSsrSafeAreaCssForMode(paywallData, hostFallback, colorMode) {
38
+ const base = paywallData?.components_config?.base;
39
+ const background = base?.background;
40
+ if (background?.type === "color") {
41
+ const color = mapColorMode(colorMode, background.value);
42
+ if (color.type === "hex") {
43
+ const hex = getCssHexColor(color);
44
+ if (hex)
45
+ return hex;
46
+ }
47
+ else if (color.type === "alias") {
48
+ return mapColorInfo(color);
49
+ }
50
+ else if (color.type === "linear" &&
51
+ isAxisAlignedVerticalGradient(color.degrees)) {
52
+ const edges = getBackgroundSafeAreaColors(background, colorMode, { width: 1, height: 1 }, {
53
+ stickyComponents: {
54
+ hasHeader: base?.header != null,
55
+ hasFooter: base?.sticky_footer != null,
56
+ },
57
+ });
58
+ const sampled = edges.top ?? edges.bottom;
59
+ if (sampled && isHexColor(sampled))
60
+ return sampled;
61
+ }
62
+ }
63
+ const fallbackInfo = getSafeAreaFallbackColorInfoForMode(base?.safe_area_fallback_color, hostFallback, colorMode);
64
+ if (fallbackInfo == null)
65
+ return null;
66
+ return fallbackInfo.type === "alias"
67
+ ? mapColorInfo(fallbackInfo)
68
+ : getCssHexColor(fallbackInfo);
69
+ }
70
+ export function getSafeAreaFallbackColorHeadStyles(paywallData, hostFallbackColor) {
71
+ return ["light", "dark"]
72
+ .map((mode) => {
73
+ const value = getSsrSafeAreaCssForMode(paywallData, hostFallbackColor, mode);
74
+ return value
75
+ ? `@media (prefers-color-scheme: ${mode}) { html, body { ${SAFE_AREA_FALLBACK_COLOR_CSS_VAR}: ${value}; } }`
76
+ : null;
77
+ })
78
+ .filter(Boolean)
79
+ .join("\n");
80
+ }
81
+ function colorAtPercent(sortedPoints, percent) {
82
+ const firstPoint = sortedPoints[0];
83
+ const lastPoint = sortedPoints.at(-1);
84
+ if (firstPoint == null || lastPoint == null) {
85
+ return null;
86
+ }
87
+ if (percent <= firstPoint.percent) {
88
+ return normalizeHexColor(firstPoint.color);
89
+ }
90
+ if (percent >= lastPoint.percent) {
91
+ return normalizeHexColor(lastPoint.color);
92
+ }
93
+ const nextPointIndex = sortedPoints.findIndex((point) => point.percent >= percent);
94
+ const previousPoint = sortedPoints[nextPointIndex - 1];
95
+ const nextPoint = sortedPoints[nextPointIndex];
96
+ if (previousPoint == null || nextPoint == null) {
97
+ return null;
98
+ }
99
+ if (previousPoint.color.toLowerCase() === nextPoint.color.toLowerCase()) {
100
+ return normalizeHexColor(previousPoint.color);
101
+ }
102
+ if (previousPoint.percent === nextPoint.percent) {
103
+ return normalizeHexColor(nextPoint.color);
104
+ }
105
+ return null;
106
+ }
107
+ function getSortedGradientPoints(points) {
108
+ return points
109
+ .filter((point) => Number.isFinite(point.percent) && isHexColor(point.color))
110
+ .slice()
111
+ .sort((a, b) => a.percent - b.percent);
112
+ }
113
+ function solidColorForRange(sortedPoints, [rangeStart, rangeEnd]) {
114
+ const expectedColor = colorAtPercent(sortedPoints, rangeStart);
115
+ if (expectedColor == null || !isOpaqueHexColor(expectedColor)) {
116
+ return null;
117
+ }
118
+ const breakpoints = [
119
+ rangeStart,
120
+ ...sortedPoints
121
+ .map((point) => point.percent)
122
+ .filter((percent) => percent > rangeStart && percent < rangeEnd),
123
+ rangeEnd,
124
+ ];
125
+ const isSolid = breakpoints.every((breakpoint, index) => {
126
+ if (colorAtPercent(sortedPoints, breakpoint) !== expectedColor) {
127
+ return false;
128
+ }
129
+ const nextBreakpoint = breakpoints[index + 1];
130
+ if (nextBreakpoint == null || nextBreakpoint === breakpoint) {
131
+ return true;
132
+ }
133
+ return (colorAtPercent(sortedPoints, (breakpoint + nextBreakpoint) / 2) ===
134
+ expectedColor);
135
+ });
136
+ return isSolid ? expectedColor : null;
137
+ }
138
+ function snapToZero(value) {
139
+ return Math.abs(value) < 1e-10 ? 0 : value;
140
+ }
141
+ function linearGradientPositionPercent(degrees, x, y, viewport) {
142
+ const radians = (degrees * Math.PI) / 180;
143
+ // Math.sin(Math.PI) is ~1.22e-16, not 0. Snap so a 180° gradient's edge
144
+ // sits exactly on a stop instead of just barely missing it.
145
+ const dx = snapToZero(Math.sin(radians));
146
+ const dy = snapToZero(-Math.cos(radians));
147
+ const gradientLength = Math.abs(dx) * viewport.width + Math.abs(dy) * viewport.height;
148
+ const offset = (x - viewport.width / 2) * dx + (y - viewport.height / 2) * dy;
149
+ return ((offset + gradientLength / 2) / gradientLength) * 100;
150
+ }
151
+ function linearEdgeRangePercent(degrees, edge, viewport) {
152
+ const y = edge === "top" ? 0 : viewport.height;
153
+ const start = linearGradientPositionPercent(degrees, 0, y, viewport);
154
+ const end = linearGradientPositionPercent(degrees, viewport.width, y, viewport);
155
+ return [Math.min(start, end), Math.max(start, end)];
156
+ }
157
+ function radialGradientPositionPercent(x, y, viewport) {
158
+ const radius = Math.hypot(viewport.width / 2, viewport.height / 2);
159
+ const distance = Math.hypot(x - viewport.width / 2, y - viewport.height / 2);
160
+ return (distance / radius) * 100;
161
+ }
162
+ function radialEdgeRangePercent(edge, viewport) {
163
+ const y = edge === "top" ? 0 : viewport.height;
164
+ const center = radialGradientPositionPercent(viewport.width / 2, y, viewport);
165
+ const corner = radialGradientPositionPercent(0, y, viewport);
166
+ return [Math.min(center, corner), Math.max(center, corner)];
167
+ }
168
+ function getSolidEdgeColor(color, edge, viewport, sortedPoints) {
169
+ switch (color.type) {
170
+ case "hex":
171
+ case "alias":
172
+ return mapColorInfo(color);
173
+ case "linear":
174
+ return solidColorForRange(sortedPoints ?? [], linearEdgeRangePercent(color.degrees, edge, viewport));
175
+ case "radial":
176
+ return solidColorForRange(sortedPoints ?? [], radialEdgeRangePercent(edge, viewport));
177
+ }
178
+ }
179
+ // `position: sticky` promotes a header/footer to its own compositor layer
180
+ // regardless of fill, so even transparent sticky elements break the opposite
181
+ // safe-area strip. Pass `stickyComponents` when painting into a single CSS
182
+ // variable (SDK runtime + SSR head) so the covered edge is nulled and the
183
+ // caller can fall through to the override/host chain; editor previews leave
184
+ // it absent.
185
+ export function getBackgroundSafeAreaColors(background, colorMode, viewport, options) {
186
+ let top = null;
187
+ let bottom = null;
188
+ if (background?.type === "color") {
189
+ const color = mapColorMode(colorMode, background.value);
190
+ const sortedPoints = color.type === "linear" || color.type === "radial"
191
+ ? getSortedGradientPoints(color.points)
192
+ : null;
193
+ top = getSolidEdgeColor(color, "top", viewport, sortedPoints);
194
+ bottom = getSolidEdgeColor(color, "bottom", viewport, sortedPoints);
195
+ }
196
+ if (options?.stickyComponents) {
197
+ const hasHeader = options.stickyComponents.hasHeader === true;
198
+ const hasFooter = options.stickyComponents.hasFooter === true;
199
+ // A single CSS variable can only paint one strip; emit only when exactly
200
+ // one edge is exposed (the opposite edge is occluded by a sticky element).
201
+ top = hasFooter && !hasHeader ? top : null;
202
+ bottom = hasHeader && !hasFooter ? bottom : null;
203
+ }
204
+ if (options?.paywallFallbackColor != null ||
205
+ options?.hostFallbackColor != null) {
206
+ const fallback = resolveSafeAreaFallbackCss(options.paywallFallbackColor, options.hostFallbackColor, colorMode);
207
+ top = top ?? fallback;
208
+ bottom = bottom ?? fallback;
209
+ }
210
+ return { top, bottom };
211
+ }
@@ -0,0 +1 @@
1
+ export { getBackgroundSafeAreaColors, getSafeAreaFallbackColorHeadStyles, } from "./safe-area-background";
@@ -0,0 +1,4 @@
1
+ // Public surface for `@revenuecat/purchases-ui-js/safe-area`. The CSS var
2
+ // name and helpers used internally by `applyDocumentBackground` are
3
+ // deliberately not re-exported.
4
+ export { getBackgroundSafeAreaColors, getSafeAreaFallbackColorHeadStyles, } from "./safe-area-background";
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.3.0",
5
+ "version": "4.5.0",
6
6
  "author": {
7
7
  "name": "RevenueCat, Inc."
8
8
  },
@@ -65,6 +65,10 @@
65
65
  },
66
66
  "./web-components": {
67
67
  "default": "./dist/web-components/index.js"
68
+ },
69
+ "./safe-area": {
70
+ "types": "./dist/utils/safe-area.d.ts",
71
+ "default": "./dist/utils/safe-area.js"
68
72
  }
69
73
  },
70
74
  "engines": {