react-native-revnix 1.4.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 (84) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +766 -0
  3. package/dist/blocks/background.d.ts +101 -0
  4. package/dist/blocks/background.js +165 -0
  5. package/dist/blocks/color.d.ts +88 -0
  6. package/dist/blocks/color.js +329 -0
  7. package/dist/blocks/locale.d.ts +42 -0
  8. package/dist/blocks/locale.js +263 -0
  9. package/dist/blocks/selection.d.ts +49 -0
  10. package/dist/blocks/selection.js +109 -0
  11. package/dist/blocks/style.d.ts +93 -0
  12. package/dist/blocks/style.js +365 -0
  13. package/dist/blocks/tags.d.ts +12 -0
  14. package/dist/blocks/tags.js +121 -0
  15. package/dist/blocks/types.d.ts +301 -0
  16. package/dist/blocks/types.js +44 -0
  17. package/dist/client.d.ts +402 -0
  18. package/dist/client.js +1653 -0
  19. package/dist/device.d.ts +29 -0
  20. package/dist/device.js +137 -0
  21. package/dist/errors.d.ts +39 -0
  22. package/dist/errors.js +68 -0
  23. package/dist/esm/blocks/background.js +160 -0
  24. package/dist/esm/blocks/color.js +318 -0
  25. package/dist/esm/blocks/locale.js +255 -0
  26. package/dist/esm/blocks/selection.js +100 -0
  27. package/dist/esm/blocks/style.js +356 -0
  28. package/dist/esm/blocks/tags.js +117 -0
  29. package/dist/esm/blocks/types.js +40 -0
  30. package/dist/esm/client.js +1649 -0
  31. package/dist/esm/device.js +130 -0
  32. package/dist/esm/errors.js +59 -0
  33. package/dist/esm/iap.js +101 -0
  34. package/dist/esm/implicit.js +63 -0
  35. package/dist/esm/index.js +29 -0
  36. package/dist/esm/package.json +1 -0
  37. package/dist/esm/storage.js +78 -0
  38. package/dist/esm/types.js +4 -0
  39. package/dist/esm/ui/RevnixBlockPaywall.js +706 -0
  40. package/dist/esm/ui/RevnixPaywall.js +890 -0
  41. package/dist/esm/ui/index.js +6 -0
  42. package/dist/esm/version.js +4 -0
  43. package/dist/iap.d.ts +20 -0
  44. package/dist/iap.js +104 -0
  45. package/dist/implicit.d.ts +69 -0
  46. package/dist/implicit.js +67 -0
  47. package/dist/index.d.ts +18 -0
  48. package/dist/index.js +50 -0
  49. package/dist/storage.d.ts +14 -0
  50. package/dist/storage.js +85 -0
  51. package/dist/types.d.ts +463 -0
  52. package/dist/types.js +5 -0
  53. package/dist/ui/RevnixBlockPaywall.d.ts +96 -0
  54. package/dist/ui/RevnixBlockPaywall.js +742 -0
  55. package/dist/ui/RevnixPaywall.d.ts +148 -0
  56. package/dist/ui/RevnixPaywall.js +927 -0
  57. package/dist/ui/index.d.ts +7 -0
  58. package/dist/ui/index.js +25 -0
  59. package/dist/version.d.ts +1 -0
  60. package/dist/version.js +7 -0
  61. package/package.json +79 -0
  62. package/src/blocks/background.ts +232 -0
  63. package/src/blocks/color.ts +352 -0
  64. package/src/blocks/locale.ts +271 -0
  65. package/src/blocks/paywall-background-wire.json +12 -0
  66. package/src/blocks/paywall-selection-wire.json +149 -0
  67. package/src/blocks/selection.ts +161 -0
  68. package/src/blocks/style.ts +411 -0
  69. package/src/blocks/tags.ts +125 -0
  70. package/src/blocks/types.ts +352 -0
  71. package/src/client.ts +1993 -0
  72. package/src/device.ts +146 -0
  73. package/src/errors.ts +69 -0
  74. package/src/iap.ts +137 -0
  75. package/src/implicit.ts +129 -0
  76. package/src/index.ts +82 -0
  77. package/src/storage.ts +92 -0
  78. package/src/types.ts +528 -0
  79. package/src/ui/RevnixBlockPaywall.tsx +1423 -0
  80. package/src/ui/RevnixPaywall.tsx +1542 -0
  81. package/src/ui/index.ts +43 -0
  82. package/src/version.ts +4 -0
  83. package/ui.d.ts +1 -0
  84. package/ui.js +5 -0
@@ -0,0 +1,29 @@
1
+ import type { DeviceFacts } from "./types.js";
2
+ /** The header the server reads. Keep in sync with
3
+ * convex/lib/deviceAttributes.ts DEVICE_HEADER. */
4
+ export declare const DEVICE_HEADER = "X-Revnix-Device";
5
+ /** Storage key for the first-launch timestamp. */
6
+ export declare const INSTALLED_AT_KEY = "revnix.installedAt";
7
+ /** What goes on the wire: the app-supplied facts plus the three the SDK owns. */
8
+ export interface DevicePayload extends DeviceFacts {
9
+ sdkVersion: string;
10
+ installedAt?: number;
11
+ firstOpen?: boolean;
12
+ }
13
+ /** The facts this runtime can report about itself without help. Browser
14
+ * only in practice — under Node and React Native `navigator` is absent or
15
+ * says nothing useful, and those callers pass `device` in configure(). */
16
+ export declare function detectDeviceFacts(): DeviceFacts;
17
+ /** Best-effort OS version from a browser UA string; undefined when the UA
18
+ * gives nothing recognizable. Only the major desktop/mobile families —
19
+ * this is a targeting hint, not a fingerprinting library. */
20
+ export declare function osVersionFromUserAgent(ua: string): string | undefined;
21
+ /** Drop undefined/empty fields so the encoded header carries only what is
22
+ * known — the server would drop them anyway, but bytes on every resolve
23
+ * are bytes on every resolve. */
24
+ export declare function compactPayload(payload: DevicePayload): Record<string, unknown>;
25
+ /** base64url of the UTF-8 JSON payload. Hand-rolled rather than
26
+ * btoa/TextEncoder: neither is guaranteed on every React Native runtime
27
+ * this SDK supports, and a missing global here would fail every paywall
28
+ * resolve on that platform for the sake of one header. */
29
+ export declare function encodeDeviceHeader(payload: Record<string, unknown>): string;
package/dist/device.js ADDED
@@ -0,0 +1,137 @@
1
+ "use strict";
2
+ // REV-268: the device attribute contract, SDK side. Every SDK sends the same
3
+ // twelve facts about the device on each placement resolve, in the
4
+ // X-Revnix-Device header, and the server stores them as reserved `device.*`
5
+ // customer attributes — the facts a targeting rule ("US only", "app version
6
+ // at least 3", "first open") evaluates against on the very request that
7
+ // serves the paywall.
8
+ //
9
+ // This SDK runs in a browser, in React Native, and under Node, so it cannot
10
+ // read most of the facts itself the way the native SDKs can: there is no
11
+ // StoreKit storefront in a browser and no `navigator` in Node. The split is
12
+ // - what the runtime can tell us safely (detectDeviceFacts): platform,
13
+ // locale, and — in a browser — the OS version from the user agent;
14
+ // - what the app must tell us (RevnixConfig.device): app version, model,
15
+ // storefront, currency, sandbox — a React Native app has
16
+ // react-native-device-info in hand and this SDK should not depend on it;
17
+ // - what the SDK owns outright: sdkVersion, installedAt, firstOpen.
18
+ // Anything unknown is simply omitted; the server treats every field as
19
+ // optional and normalizes what it gets (lib/deviceAttributes.ts in
20
+ // revnix-app), so nothing here needs to be exact — only honest.
21
+ Object.defineProperty(exports, "__esModule", { value: true });
22
+ exports.INSTALLED_AT_KEY = exports.DEVICE_HEADER = void 0;
23
+ exports.detectDeviceFacts = detectDeviceFacts;
24
+ exports.osVersionFromUserAgent = osVersionFromUserAgent;
25
+ exports.compactPayload = compactPayload;
26
+ exports.encodeDeviceHeader = encodeDeviceHeader;
27
+ /** The header the server reads. Keep in sync with
28
+ * convex/lib/deviceAttributes.ts DEVICE_HEADER. */
29
+ exports.DEVICE_HEADER = "X-Revnix-Device";
30
+ /** Storage key for the first-launch timestamp. */
31
+ exports.INSTALLED_AT_KEY = "revnix.installedAt";
32
+ /** The facts this runtime can report about itself without help. Browser
33
+ * only in practice — under Node and React Native `navigator` is absent or
34
+ * says nothing useful, and those callers pass `device` in configure(). */
35
+ function detectDeviceFacts() {
36
+ const nav = globalThis.navigator;
37
+ const facts = {};
38
+ if (!nav)
39
+ return facts;
40
+ // A React Native runtime also exposes `navigator` (product: "ReactNative")
41
+ // but its language/userAgent are not the device's — leave those to the app.
42
+ if (nav.product === "ReactNative")
43
+ return facts;
44
+ if (typeof nav.userAgent === "string" && nav.userAgent.length > 0) {
45
+ facts.platform = "web";
46
+ const os = osVersionFromUserAgent(nav.userAgent);
47
+ if (os !== undefined)
48
+ facts.osVersion = os;
49
+ }
50
+ if (typeof nav.language === "string" && nav.language.length > 0) {
51
+ facts.locale = nav.language;
52
+ }
53
+ return facts;
54
+ }
55
+ /** Best-effort OS version from a browser UA string; undefined when the UA
56
+ * gives nothing recognizable. Only the major desktop/mobile families —
57
+ * this is a targeting hint, not a fingerprinting library. */
58
+ function osVersionFromUserAgent(ua) {
59
+ const ios = /(?:iPhone|iPad|CPU) OS (\d+(?:_\d+)*)/.exec(ua)?.[1];
60
+ if (ios !== undefined)
61
+ return ios.replace(/_/g, ".");
62
+ const android = /Android (\d+(?:\.\d+)*)/.exec(ua)?.[1];
63
+ if (android !== undefined)
64
+ return android;
65
+ const mac = /Mac OS X (\d+(?:[._]\d+)*)/.exec(ua)?.[1];
66
+ if (mac !== undefined)
67
+ return mac.replace(/_/g, ".");
68
+ const windows = /Windows NT (\d+(?:\.\d+)*)/.exec(ua)?.[1];
69
+ if (windows !== undefined)
70
+ return windows;
71
+ return undefined;
72
+ }
73
+ /** Drop undefined/empty fields so the encoded header carries only what is
74
+ * known — the server would drop them anyway, but bytes on every resolve
75
+ * are bytes on every resolve. */
76
+ function compactPayload(payload) {
77
+ const out = {};
78
+ for (const [key, value] of Object.entries(payload)) {
79
+ if (value === undefined || value === null || value === "")
80
+ continue;
81
+ out[key] = value;
82
+ }
83
+ return out;
84
+ }
85
+ /** base64url of the UTF-8 JSON payload. Hand-rolled rather than
86
+ * btoa/TextEncoder: neither is guaranteed on every React Native runtime
87
+ * this SDK supports, and a missing global here would fail every paywall
88
+ * resolve on that platform for the sake of one header. */
89
+ function encodeDeviceHeader(payload) {
90
+ return base64url(utf8Bytes(JSON.stringify(payload)));
91
+ }
92
+ function utf8Bytes(text) {
93
+ const bytes = [];
94
+ for (let i = 0; i < text.length; i++) {
95
+ let code = text.charCodeAt(i);
96
+ // Surrogate pair → one code point.
97
+ if (code >= 0xd800 && code <= 0xdbff && i + 1 < text.length) {
98
+ const low = text.charCodeAt(i + 1);
99
+ if (low >= 0xdc00 && low <= 0xdfff) {
100
+ code = 0x10000 + ((code - 0xd800) << 10) + (low - 0xdc00);
101
+ i++;
102
+ }
103
+ }
104
+ if (code < 0x80) {
105
+ bytes.push(code);
106
+ }
107
+ else if (code < 0x800) {
108
+ bytes.push(0xc0 | (code >> 6), 0x80 | (code & 0x3f));
109
+ }
110
+ else if (code < 0x10000) {
111
+ bytes.push(0xe0 | (code >> 12), 0x80 | ((code >> 6) & 0x3f), 0x80 | (code & 0x3f));
112
+ }
113
+ else {
114
+ bytes.push(0xf0 | (code >> 18), 0x80 | ((code >> 12) & 0x3f), 0x80 | ((code >> 6) & 0x3f), 0x80 | (code & 0x3f));
115
+ }
116
+ }
117
+ return bytes;
118
+ }
119
+ const B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
120
+ function base64url(bytes) {
121
+ let out = "";
122
+ for (let i = 0; i < bytes.length; i += 3) {
123
+ const a = bytes[i] ?? 0;
124
+ const b = i + 1 < bytes.length ? bytes[i + 1] : undefined;
125
+ const c = i + 2 < bytes.length ? bytes[i + 2] : undefined;
126
+ out += B64[a >> 2];
127
+ out += B64[((a & 0x03) << 4) | ((b ?? 0) >> 4)];
128
+ if (b === undefined)
129
+ break;
130
+ out += B64[((b & 0x0f) << 2) | ((c ?? 0) >> 6)];
131
+ if (c === undefined)
132
+ break;
133
+ out += B64[c & 0x3f];
134
+ }
135
+ // No padding: base64url as the server decodes it (it re-pads itself).
136
+ return out;
137
+ }
@@ -0,0 +1,39 @@
1
+ export declare class RevnixError extends Error {
2
+ readonly status: number;
3
+ constructor(status: number, message: string);
4
+ /** Transient failures worth retrying (network, timeout, rate limit, 5xx).
5
+ * 4xx other than 429 are the caller's fault and won't fix themselves. */
6
+ get isRetryable(): boolean;
7
+ }
8
+ /** POST /v1/purchases returned 409: the store token belongs to another
9
+ * customer and the app's transfer behavior is "block". Surface this to the
10
+ * user as "this subscription is linked to a different account". */
11
+ export declare class PurchaseBlockedError extends RevnixError {
12
+ constructor(message: string);
13
+ }
14
+ /** C7: the request didn't complete within the configured timeout (default
15
+ * 10s). `fetch` has no default timeout on React Native / browsers, so without
16
+ * this a slow or dead backend would hang the app until the OS TCP timeout. */
17
+ export declare class RevnixTimeoutError extends RevnixError {
18
+ constructor(message?: string);
19
+ }
20
+ /** C7: the request never reached the server (offline, DNS/TLS failure, captive
21
+ * portal). Distinct from an HTTP error so callers can show an offline state
22
+ * instead of treating it as a bug. */
23
+ export declare class RevnixNetworkError extends RevnixError {
24
+ constructor(message?: string);
25
+ }
26
+ /** L4: the API returned 401/403 — the key is missing, revoked, or the wrong
27
+ * kind (e.g. a secret key where a public key is required) — or the SDK
28
+ * refused locally before the network for the same reason (identify()/alias()
29
+ * on a publishable key). Retrying is pointless; the fix is reconfiguration,
30
+ * so surface this to the developer. */
31
+ export declare class RevnixAuthError extends RevnixError {
32
+ constructor(status: number, message: string);
33
+ }
34
+ /** C7: the API returned 429 (rate limited). `retryAfterMs` mirrors the
35
+ * server's Retry-After header when present — back off at least that long. */
36
+ export declare class RevnixRateLimitError extends RevnixError {
37
+ readonly retryAfterMs?: number;
38
+ constructor(message: string, retryAfterMs?: number);
39
+ }
package/dist/errors.js ADDED
@@ -0,0 +1,68 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.RevnixRateLimitError = exports.RevnixAuthError = exports.RevnixNetworkError = exports.RevnixTimeoutError = exports.PurchaseBlockedError = exports.RevnixError = void 0;
4
+ class RevnixError extends Error {
5
+ constructor(status, message) {
6
+ super(message);
7
+ this.name = "RevnixError";
8
+ this.status = status;
9
+ }
10
+ /** Transient failures worth retrying (network, timeout, rate limit, 5xx).
11
+ * 4xx other than 429 are the caller's fault and won't fix themselves. */
12
+ get isRetryable() {
13
+ return this.status === 0 || this.status === 429 || this.status >= 500;
14
+ }
15
+ }
16
+ exports.RevnixError = RevnixError;
17
+ /** POST /v1/purchases returned 409: the store token belongs to another
18
+ * customer and the app's transfer behavior is "block". Surface this to the
19
+ * user as "this subscription is linked to a different account". */
20
+ class PurchaseBlockedError extends RevnixError {
21
+ constructor(message) {
22
+ super(409, message);
23
+ this.name = "PurchaseBlockedError";
24
+ }
25
+ }
26
+ exports.PurchaseBlockedError = PurchaseBlockedError;
27
+ /** C7: the request didn't complete within the configured timeout (default
28
+ * 10s). `fetch` has no default timeout on React Native / browsers, so without
29
+ * this a slow or dead backend would hang the app until the OS TCP timeout. */
30
+ class RevnixTimeoutError extends RevnixError {
31
+ constructor(message = "Revnix request timed out") {
32
+ super(0, message);
33
+ this.name = "RevnixTimeoutError";
34
+ }
35
+ }
36
+ exports.RevnixTimeoutError = RevnixTimeoutError;
37
+ /** C7: the request never reached the server (offline, DNS/TLS failure, captive
38
+ * portal). Distinct from an HTTP error so callers can show an offline state
39
+ * instead of treating it as a bug. */
40
+ class RevnixNetworkError extends RevnixError {
41
+ constructor(message = "Revnix request failed to reach the server") {
42
+ super(0, message);
43
+ this.name = "RevnixNetworkError";
44
+ }
45
+ }
46
+ exports.RevnixNetworkError = RevnixNetworkError;
47
+ /** L4: the API returned 401/403 — the key is missing, revoked, or the wrong
48
+ * kind (e.g. a secret key where a public key is required) — or the SDK
49
+ * refused locally before the network for the same reason (identify()/alias()
50
+ * on a publishable key). Retrying is pointless; the fix is reconfiguration,
51
+ * so surface this to the developer. */
52
+ class RevnixAuthError extends RevnixError {
53
+ constructor(status, message) {
54
+ super(status, message);
55
+ this.name = "RevnixAuthError";
56
+ }
57
+ }
58
+ exports.RevnixAuthError = RevnixAuthError;
59
+ /** C7: the API returned 429 (rate limited). `retryAfterMs` mirrors the
60
+ * server's Retry-After header when present — back off at least that long. */
61
+ class RevnixRateLimitError extends RevnixError {
62
+ constructor(message, retryAfterMs) {
63
+ super(429, message);
64
+ this.name = "RevnixRateLimitError";
65
+ this.retryAfterMs = retryAfterMs;
66
+ }
67
+ }
68
+ exports.RevnixRateLimitError = RevnixRateLimitError;
@@ -0,0 +1,160 @@
1
+ // The paywall screen background — the SDK half of the dashboard's model.
2
+ //
3
+ // A background is either the original CSS string (color or gradient) or a
4
+ // layered spec: ground color/gradient, then a photo (fit, focal point,
5
+ // opacity, blur), then a scrim. `backgroundLayers()` resolves either form to
6
+ // one paint list, mirroring the dashboard renderer exactly — the same
7
+ // function name, the same defaults, the same clamping — so a background can
8
+ // never look different on device than in the builder for reasons of
9
+ // interpretation. (Type parity with revnix-app/src/lib/paywall-blocks/
10
+ // background.ts; keep the two in step.)
11
+ const DEFAULT_GROUND = "#000000";
12
+ /**
13
+ * The flat color a ground stands in for — what `@bg` resolves to, and what
14
+ * paints under a gradient this build cannot draw.
15
+ *
16
+ * It reads the LAST comma-separated layer, because in CSS the first-listed
17
+ * layer paints on TOP: taking the first color would answer with the
18
+ * translucent accent glow the library's Spotlight and Corner halo presets
19
+ * stack over their base, not with the base itself. Fully transparent stops are
20
+ * skipped for the same reason — a scrim that fades to `…00` says nothing about
21
+ * the ground under it.
22
+ *
23
+ * Returning the raw gradient here instead is what put this SDK and the
24
+ * dashboard out of step with the other five renderers, which have always
25
+ * collapsed it: `@bg` is fed to `color-mix()` (and, inside a gradient, to
26
+ * another gradient's stop list), and neither can take a gradient — so the raw
27
+ * form resolved to something invalid and painted NOTHING. Of the 250 shipped
28
+ * gallery presets only 7 have a gradient ground and none of those use `@bg`,
29
+ * so no design that ships today changes; what changes is a background a
30
+ * customer edits to a gradient, which used to silently drop every `@bg` tint
31
+ * on the screen.
32
+ */
33
+ export function backgroundBaseColor(bg) {
34
+ const ground = typeof bg === "string" ? bg : bg?.color;
35
+ if (ground === undefined)
36
+ return DEFAULT_GROUND;
37
+ const layers = splitLayers(ground);
38
+ const bottom = layers.length > 0 ? layers[layers.length - 1] : ground;
39
+ const colors = bottom.match(/#[0-9a-fA-F]{3,8}\b|rgba?\([^)]*\)/gi);
40
+ if (!colors)
41
+ return ground;
42
+ return colors.find((color) => !isFullyTransparent(color)) ?? colors[0];
43
+ }
44
+ /** Whether a color literal is fully transparent. Kept here rather than routed
45
+ * through `color.ts`, which imports THIS module — only the two forms the
46
+ * dashboard emits are recognized, and anything else counts as opaque, which
47
+ * is the safe answer for picking a ground. */
48
+ function isFullyTransparent(color) {
49
+ const s = color.trim();
50
+ if (s.startsWith("#")) {
51
+ const hex = s.slice(1);
52
+ if (hex.length === 8)
53
+ return parseInt(hex.slice(6), 16) === 0;
54
+ if (hex.length === 4)
55
+ return parseInt(hex.slice(3).repeat(2), 16) === 0;
56
+ return false;
57
+ }
58
+ const open = s.indexOf("(");
59
+ const close = s.indexOf(")");
60
+ if (open < 0 || close < 0)
61
+ return false;
62
+ const parts = s
63
+ .slice(open + 1, close)
64
+ .split(/[,/\s]+/)
65
+ .filter(Boolean);
66
+ return parts.length >= 4 && parseFloat(parts[3]) === 0;
67
+ }
68
+ /** Splits on top-level commas only, so the commas inside `rgba(...)` and
69
+ * inside a gradient's argument list do not tear a layer in half. */
70
+ function splitLayers(input) {
71
+ const out = [];
72
+ let depth = 0;
73
+ let current = "";
74
+ for (const ch of input) {
75
+ if (ch === "(")
76
+ depth++;
77
+ else if (ch === ")" && depth > 0)
78
+ depth--;
79
+ if (ch === "," && depth === 0) {
80
+ out.push(current.trim());
81
+ current = "";
82
+ continue;
83
+ }
84
+ current += ch;
85
+ }
86
+ const tail = current.trim();
87
+ if (tail)
88
+ out.push(tail);
89
+ return out;
90
+ }
91
+ function pct(value, fallback) {
92
+ if (value === undefined || !Number.isFinite(value))
93
+ return fallback;
94
+ return Math.min(1, Math.max(0, value / 100));
95
+ }
96
+ function coord(value) {
97
+ if (value === undefined || !Number.isFinite(value))
98
+ return 50;
99
+ return Math.min(100, Math.max(0, value));
100
+ }
101
+ /** The paint list, bottom layer first. Layers that would draw nothing (no
102
+ * url, zero opacity) are dropped, so a legacy string resolves to exactly
103
+ * one ground layer. */
104
+ export function backgroundLayers(bg,
105
+ /** Resolves palette tokens in the ground and overlay fills. */
106
+ resolve = (v) => v) {
107
+ const spec = bg === undefined ? {} : typeof bg === "string" ? { color: bg } : bg;
108
+ const layers = {};
109
+ if (spec.color)
110
+ layers.ground = resolve(spec.color);
111
+ if (spec.image?.url) {
112
+ const opacity = pct(spec.image.opacity, 1);
113
+ if (opacity > 0) {
114
+ layers.image = {
115
+ url: spec.image.url,
116
+ fit: spec.image.fit === "contain" ? "contain" : "cover",
117
+ focalX: coord(spec.image.focalX),
118
+ focalY: coord(spec.image.focalY),
119
+ opacity,
120
+ ...(spec.image.blur ? { blur: spec.image.blur } : {}),
121
+ };
122
+ }
123
+ }
124
+ if (spec.overlay?.fill) {
125
+ const opacity = pct(spec.overlay.opacity, 1);
126
+ if (opacity > 0) {
127
+ layers.overlay = { fill: resolve(spec.overlay.fill), opacity };
128
+ }
129
+ }
130
+ return layers;
131
+ }
132
+ /**
133
+ * Cover-fit geometry with a focal point — the SDK's stand-in for CSS
134
+ * `object-position`, which React Native's <Image> lacks. Given the box and
135
+ * the source dimensions it returns the drawn size and the offset that puts
136
+ * the focal point as close to the box's matching anchor as the image edges
137
+ * allow (exactly CSS's `object-position: X% Y%` rule: the X% point of the
138
+ * image aligns to the X% point of the box, clamped so no edge shows).
139
+ */
140
+ export function coverPlacement(box, source, focalX, focalY) {
141
+ if (box.width <= 0 || box.height <= 0 || source.width <= 0 || source.height <= 0) {
142
+ return { width: box.width, height: box.height, left: 0, top: 0 };
143
+ }
144
+ const scale = Math.max(box.width / source.width, box.height / source.height);
145
+ const width = source.width * scale;
146
+ const height = source.height * scale;
147
+ const place = (boxLen, drawnLen, pctPoint) => {
148
+ const offset = (boxLen - drawnLen) * (pctPoint / 100);
149
+ // Clamp: never pull an image edge inside the box. `|| 0` normalises the
150
+ // -0 that falls out of the arithmetic at the edges — harmless to RN, but
151
+ // it makes the returned geometry compare cleanly.
152
+ return Math.min(0, Math.max(boxLen - drawnLen, offset)) || 0;
153
+ };
154
+ return {
155
+ width,
156
+ height,
157
+ left: place(box.width, width, focalX),
158
+ top: place(box.height, height, focalY),
159
+ };
160
+ }