@farcaster/snap 1.15.4 → 1.16.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 (51) hide show
  1. package/dist/constants.d.ts +8 -0
  2. package/dist/constants.js +9 -1
  3. package/dist/index.d.ts +1 -1
  4. package/dist/index.js +1 -1
  5. package/dist/react/components/badge.js +2 -3
  6. package/dist/react/index.d.ts +9 -4
  7. package/dist/react/index.js +9 -228
  8. package/dist/react/snap-view-core.d.ts +11 -0
  9. package/dist/react/snap-view-core.js +224 -0
  10. package/dist/react/v1/snap-view.d.ts +14 -0
  11. package/dist/react/v1/snap-view.js +9 -0
  12. package/dist/react/v2/snap-view.d.ts +21 -0
  13. package/dist/react/v2/snap-view.js +76 -0
  14. package/dist/react-native/components/snap-badge.js +3 -3
  15. package/dist/react-native/index.d.ts +15 -45
  16. package/dist/react-native/index.js +10 -166
  17. package/dist/react-native/snap-view-core.d.ts +11 -0
  18. package/dist/react-native/snap-view-core.js +153 -0
  19. package/dist/react-native/types.d.ts +41 -0
  20. package/dist/react-native/types.js +1 -0
  21. package/dist/react-native/v1/snap-view.d.ts +22 -0
  22. package/dist/react-native/v1/snap-view.js +31 -0
  23. package/dist/react-native/v2/snap-view.d.ts +31 -0
  24. package/dist/react-native/v2/snap-view.js +101 -0
  25. package/dist/schemas.d.ts +15 -9
  26. package/dist/schemas.js +7 -8
  27. package/dist/server/parseRequest.d.ts +7 -0
  28. package/dist/server/parseRequest.js +27 -0
  29. package/dist/ui/schema.js +1 -1
  30. package/dist/validator.d.ts +3 -2
  31. package/dist/validator.js +193 -2
  32. package/llms.txt +9 -0
  33. package/package.json +1 -1
  34. package/src/constants.ts +11 -1
  35. package/src/index.ts +8 -0
  36. package/src/react/accent-context.tsx +1 -1
  37. package/src/react/components/badge.tsx +2 -3
  38. package/src/react/index.tsx +37 -330
  39. package/src/react/snap-view-core.tsx +340 -0
  40. package/src/react/v1/snap-view.tsx +50 -0
  41. package/src/react/v2/snap-view.tsx +168 -0
  42. package/src/react-native/components/snap-badge.tsx +3 -3
  43. package/src/react-native/index.tsx +47 -267
  44. package/src/react-native/snap-view-core.tsx +209 -0
  45. package/src/react-native/types.ts +37 -0
  46. package/src/react-native/v1/snap-view.tsx +108 -0
  47. package/src/react-native/v2/snap-view.tsx +239 -0
  48. package/src/schemas.ts +9 -10
  49. package/src/server/parseRequest.ts +39 -0
  50. package/src/ui/schema.ts +1 -1
  51. package/src/validator.ts +240 -2
@@ -1,4 +1,8 @@
1
+ export declare const SPEC_VERSION_1: "1.0";
2
+ export declare const SPEC_VERSION_2: "2.0";
1
3
  export declare const SPEC_VERSION: "1.0";
4
+ export declare const SUPPORTED_SPEC_VERSIONS: readonly ["1.0", "2.0"];
5
+ export type SpecVersion = (typeof SUPPORTED_SPEC_VERSIONS)[number];
2
6
  export declare const MEDIA_TYPE: "application/vnd.farcaster.snap+json";
3
7
  export declare const EFFECT_VALUES: readonly ["confetti"];
4
8
  export declare const POST_GRID_TAP_KEY: "grid_tap";
@@ -7,5 +11,9 @@ export declare const GRID_MAX_COLS = 32;
7
11
  export declare const GRID_MIN_ROWS = 2;
8
12
  export declare const GRID_MAX_ROWS = 16;
9
13
  export declare const GRID_GAP_VALUES: readonly ["none", "sm", "md", "lg"];
14
+ export declare const MAX_ELEMENTS = 64;
15
+ export declare const MAX_ROOT_CHILDREN = 7;
16
+ export declare const MAX_CHILDREN = 6;
17
+ export declare const MAX_DEPTH = 4;
10
18
  export declare const BAR_CHART_MAX_BARS = 6;
11
19
  export declare const BAR_CHART_LABEL_MAX_CHARS = 40;
package/dist/constants.js CHANGED
@@ -1,4 +1,7 @@
1
- export const SPEC_VERSION = "1.0";
1
+ export const SPEC_VERSION_1 = "1.0";
2
+ export const SPEC_VERSION_2 = "2.0";
3
+ export const SPEC_VERSION = SPEC_VERSION_1;
4
+ export const SUPPORTED_SPEC_VERSIONS = [SPEC_VERSION_1, SPEC_VERSION_2];
2
5
  export const MEDIA_TYPE = "application/vnd.farcaster.snap+json";
3
6
  export const EFFECT_VALUES = ["confetti"];
4
7
  // ─── Pixel grid ────────────────────────────────────────
@@ -8,6 +11,11 @@ export const GRID_MAX_COLS = 32;
8
11
  export const GRID_MIN_ROWS = 2;
9
12
  export const GRID_MAX_ROWS = 16;
10
13
  export const GRID_GAP_VALUES = ["none", "sm", "md", "lg"];
14
+ // ─── Snap structural limits ───────────────────────────
15
+ export const MAX_ELEMENTS = 64;
16
+ export const MAX_ROOT_CHILDREN = 7;
17
+ export const MAX_CHILDREN = 6;
18
+ export const MAX_DEPTH = 4;
11
19
  // ─── Bar chart ─────────────────────────────────────────
12
20
  export const BAR_CHART_MAX_BARS = 6;
13
21
  export const BAR_CHART_LABEL_MAX_CHARS = 40;
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export type { Spec as SnapSpec, UIElement as SnapUIElement, } from "@json-render/core";
2
- export { SPEC_VERSION, MEDIA_TYPE, EFFECT_VALUES, POST_GRID_TAP_KEY, } from "./constants.js";
2
+ export { SPEC_VERSION, SPEC_VERSION_1, SPEC_VERSION_2, SUPPORTED_SPEC_VERSIONS, type SpecVersion, MEDIA_TYPE, EFFECT_VALUES, POST_GRID_TAP_KEY, MAX_ELEMENTS, MAX_ROOT_CHILDREN, MAX_CHILDREN, MAX_DEPTH, } from "./constants.js";
3
3
  export { DEFAULT_THEME_ACCENT, PALETTE_COLOR, PALETTE_COLOR_ACCENT, PALETTE_COLOR_VALUES, PALETTE_LIGHT_HEX, PALETTE_DARK_HEX, type PaletteColor, } from "./colors.js";
4
4
  export { ACTION_TYPE_GET, ACTION_TYPE_POST, snapResponseSchema, payloadSchema, type SnapAction, type SnapContext, type SnapResponse, type SnapHandlerResult, type SnapElementInput, type SnapSpecInput, type SnapFunction, type SnapPayload, } from "./schemas.js";
5
5
  export { validateSnapResponse, type ValidationResult } from "./validator.js";
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- export { SPEC_VERSION, MEDIA_TYPE, EFFECT_VALUES, POST_GRID_TAP_KEY, } from "./constants.js";
1
+ export { SPEC_VERSION, SPEC_VERSION_1, SPEC_VERSION_2, SUPPORTED_SPEC_VERSIONS, MEDIA_TYPE, EFFECT_VALUES, POST_GRID_TAP_KEY, MAX_ELEMENTS, MAX_ROOT_CHILDREN, MAX_CHILDREN, MAX_DEPTH, } from "./constants.js";
2
2
  export { DEFAULT_THEME_ACCENT, PALETTE_COLOR, PALETTE_COLOR_ACCENT, PALETTE_COLOR_VALUES, PALETTE_LIGHT_HEX, PALETTE_DARK_HEX, } from "./colors.js";
3
3
  export { ACTION_TYPE_GET, ACTION_TYPE_POST, snapResponseSchema, payloadSchema, } from "./schemas.js";
4
4
  export { validateSnapResponse } from "./validator.js";
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { Badge } from "@neynar/ui/badge";
4
- import { useSnapColors, pickForegroundForBg } from "../hooks/use-snap-colors.js";
4
+ import { useSnapColors } from "../hooks/use-snap-colors.js";
5
5
  import { ICON_MAP } from "./icon.js";
6
6
  export function SnapBadge({ element: { props }, }) {
7
7
  const content = String(props.label ?? "");
@@ -10,10 +10,9 @@ export function SnapBadge({ element: { props }, }) {
10
10
  const iconName = props.icon ? String(props.icon) : undefined;
11
11
  const colors = useSnapColors();
12
12
  const badgeColor = colors.colorHex(color);
13
- const badgeFg = variant === "default" ? pickForegroundForBg(badgeColor) : badgeColor;
14
13
  const Icon = iconName ? ICON_MAP[iconName] : undefined;
15
14
  const style = variant === "outline"
16
15
  ? { borderColor: badgeColor, color: badgeColor, backgroundColor: "transparent" }
17
- : { backgroundColor: badgeColor, color: badgeFg, borderColor: "transparent" };
16
+ : { backgroundColor: `${badgeColor}20`, color: badgeColor, borderColor: "transparent" };
18
17
  return (_jsxs(Badge, { variant: variant, className: "gap-1", style: style, children: [Icon && _jsx(Icon, { size: 12 }), content] }));
19
18
  }
@@ -1,4 +1,6 @@
1
1
  import type { Spec } from "@json-render/core";
2
+ import type { ReactNode } from "react";
3
+ import type { ValidationResult } from "../validator.js";
2
4
  export type JsonValue = string | number | boolean | null | JsonValue[] | {
3
5
  [key: string]: JsonValue;
4
6
  };
@@ -12,9 +14,7 @@ export type SnapPage = {
12
14
  };
13
15
  export type SnapActionHandlers = {
14
16
  submit: (target: string, inputs: Record<string, JsonValue>) => void;
15
- open_url: (target: string, options?: {
16
- isSnap?: boolean;
17
- }) => void;
17
+ open_url: (target: string) => void;
18
18
  open_mini_app: (target: string) => void;
19
19
  view_cast: (params: {
20
20
  hash: string;
@@ -41,9 +41,14 @@ export type SnapActionHandlers = {
41
41
  buyToken?: string;
42
42
  }) => void;
43
43
  };
44
- export declare function SnapView({ snap, handlers, loading, appearance, }: {
44
+ export declare function SnapCard({ snap, handlers, loading, appearance, maxWidth, showOverflowWarning, onValidationError, validationErrorFallback, }: {
45
45
  snap: SnapPage;
46
46
  handlers: SnapActionHandlers;
47
47
  loading?: boolean;
48
48
  appearance?: "light" | "dark";
49
+ maxWidth?: number;
50
+ /** When true, extends to 700px and shows a warning overlay below 500px. When false, clips at 500px. Only applies to v2 snaps. */
51
+ showOverflowWarning?: boolean;
52
+ onValidationError?: (result: ValidationResult) => void;
53
+ validationErrorFallback?: ReactNode;
49
54
  }): import("react/jsx-runtime").JSX.Element;
@@ -1,231 +1,12 @@
1
1
  "use client";
2
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { snapJsonRenderCatalog } from "../ui/index.js";
4
- import { SnapCatalogView } from "./catalog-renderer.js";
5
- import { SnapPreviewAccentProvider } from "./accent-context.js";
6
- import { resolveSnapPaletteHex } from "./lib/resolve-palette-hex.js";
7
- import { snapPreviewPrimaryCssProperties } from "./lib/preview-primary-css.js";
8
- import { useCallback, useEffect, useMemo, useRef, useState, } from "react";
9
- // ─── Internal helpers ──────────────────────────────────
10
- function applyStatePaths(model, changes) {
11
- const entries = Array.isArray(changes)
12
- ? changes.map((c) => [c.path, c.value])
13
- : Object.entries(changes);
14
- for (const [path, value] of entries) {
15
- const trimmed = path.startsWith("/") ? path : `/${path}`;
16
- const parts = trimmed.split("/").filter(Boolean);
17
- if (parts.length < 2)
18
- continue;
19
- const [top, ...rest] = parts;
20
- if (top === "inputs") {
21
- if (typeof model.inputs !== "object" || model.inputs === null) {
22
- model.inputs = {};
23
- }
24
- const inputs = model.inputs;
25
- if (rest.length === 1) {
26
- inputs[rest[0]] = value;
27
- }
28
- continue;
29
- }
30
- if (top === "theme") {
31
- if (typeof model.theme !== "object" || model.theme === null) {
32
- model.theme = {};
33
- }
34
- const theme = model.theme;
35
- if (rest.length === 1) {
36
- theme[rest[0]] = value;
37
- }
38
- }
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { SPEC_VERSION_2 } from "../constants.js";
4
+ import { SnapCardV1 } from "./v1/snap-view.js";
5
+ import { SnapCardV2 } from "./v2/snap-view.js";
6
+ // ─── SnapCard ────────────────────────────────────────
7
+ export function SnapCard({ snap, handlers, loading = false, appearance = "dark", maxWidth = 480, showOverflowWarning = false, onValidationError, validationErrorFallback, }) {
8
+ if (snap.version === SPEC_VERSION_2) {
9
+ return (_jsx(SnapCardV2, { snap: snap, handlers: handlers, loading: loading, appearance: appearance, maxWidth: maxWidth, showOverflowWarning: showOverflowWarning, onValidationError: onValidationError, validationErrorFallback: validationErrorFallback }));
39
10
  }
40
- }
41
- const CONFETTI_COLORS = [
42
- "#8B5CF6",
43
- "#EC4899",
44
- "#3B82F6",
45
- "#10B981",
46
- "#F59E0B",
47
- "#EF4444",
48
- "#06B6D4",
49
- ];
50
- function ConfettiOverlay() {
51
- const pieces = useMemo(() => Array.from({ length: 80 }, (_, i) => ({
52
- id: i,
53
- left: Math.random() * 100,
54
- delay: Math.random() * 1.2,
55
- duration: 2.5 + Math.random() * 2,
56
- color: CONFETTI_COLORS[Math.floor(Math.random() * CONFETTI_COLORS.length)],
57
- size: 6 + Math.random() * 8,
58
- rotation: Math.random() * 360,
59
- })), []);
60
- return (_jsxs("div", { style: {
61
- position: "absolute",
62
- inset: 0,
63
- overflow: "hidden",
64
- pointerEvents: "none",
65
- zIndex: 20,
66
- }, children: [pieces.map(({ id, left, delay, duration, color, size, rotation }) => (_jsx("div", { style: {
67
- position: "absolute",
68
- left: `${left}%`,
69
- top: -20,
70
- width: size,
71
- height: size * 0.6,
72
- backgroundColor: color,
73
- borderRadius: 2,
74
- transform: `rotate(${rotation}deg)`,
75
- animation: `confettiFall ${duration}s ease-in ${delay}s forwards`,
76
- } }, id))), _jsx("style", { children: `@keyframes confettiFall{0%{top:-20px;opacity:1;transform:rotate(0deg) translateX(0)}50%{opacity:1}100%{top:110%;opacity:0;transform:rotate(720deg) translateX(${Math.random() > 0.5 ? "" : "-"}40px)}}` })] }));
77
- }
78
- function SnapLoadingOverlay({ appearance, accentHex, active, }) {
79
- const isDark = appearance === "dark";
80
- const tint = isDark ? "rgba(0, 0, 0, 0.1)" : "rgba(255, 255, 255, 0.2)";
81
- const trackColor = isDark
82
- ? "rgba(255, 255, 255, 0.12)"
83
- : "rgba(15, 23, 42, 0.1)";
84
- return (_jsxs("div", { style: {
85
- position: "absolute",
86
- inset: 0,
87
- display: "flex",
88
- alignItems: "center",
89
- justifyContent: "center",
90
- zIndex: 10,
91
- background: tint,
92
- backdropFilter: active ? "blur(10px) saturate(1.05)" : "none",
93
- WebkitBackdropFilter: active ? "blur(10px) saturate(1.05)" : "none",
94
- opacity: active ? 1 : 0,
95
- pointerEvents: active ? "auto" : "none",
96
- transition: "opacity 0.28s ease, backdrop-filter 0.28s ease",
97
- }, "aria-hidden": !active, "aria-busy": active ? true : undefined, "aria-live": active ? "polite" : undefined, "aria-label": active ? "Loading" : undefined, children: [_jsx("div", { "data-snap-loading-spinner": true, style: {
98
- width: 30,
99
- height: 30,
100
- borderRadius: "50%",
101
- border: `2.5px solid ${trackColor}`,
102
- borderTopColor: accentHex,
103
- opacity: 0.88,
104
- animation: "snapViewSpin 0.75s linear infinite",
105
- flexShrink: 0,
106
- } }), _jsx("style", { children: `
107
- @keyframes snapViewSpin {
108
- to { transform: rotate(360deg); }
109
- }
110
- @media (prefers-reduced-motion: reduce) {
111
- [data-snap-loading-spinner] {
112
- animation: none;
113
- border-top-color: ${accentHex};
114
- opacity: 0.75;
115
- }
116
- }
117
- ` })] }));
118
- }
119
- const PALETTE = [
120
- "gray",
121
- "blue",
122
- "red",
123
- "amber",
124
- "green",
125
- "teal",
126
- "purple",
127
- "pink",
128
- ];
129
- // ─── SnapView ──────────────────────────────────────────
130
- export function SnapView({ snap, handlers, loading = false, appearance = "dark", }) {
131
- const spec = snap.ui;
132
- const initialState = useMemo(() => spec.state ?? { inputs: {} }, [spec]);
133
- const stateRef = useRef(initialState);
134
- useEffect(() => {
135
- stateRef.current = {
136
- inputs: {
137
- ...(initialState.inputs ?? {}),
138
- },
139
- theme: {
140
- ...(initialState.theme ?? {}),
141
- },
142
- };
143
- }, [initialState]);
144
- useEffect(() => {
145
- const result = snapJsonRenderCatalog.validate(spec);
146
- if (!result.success) {
147
- // eslint-disable-next-line no-console
148
- console.warn("[SnapView] catalog validation issues:", result.error);
149
- }
150
- }, [spec]);
151
- const [pageKey, setPageKey] = useState(0);
152
- useEffect(() => {
153
- setPageKey((k) => k + 1);
154
- }, [spec]);
155
- const showConfetti = snap.effects?.includes("confetti");
156
- // Increment key each time a new snap with confetti arrives so the overlay
157
- // unmounts/remounts and restarts its animation on every trigger.
158
- const confettiEpochRef = useRef(0);
159
- const lastConfettiSnapRef = useRef(null);
160
- if (showConfetti && snap !== lastConfettiSnapRef.current) {
161
- confettiEpochRef.current++;
162
- lastConfettiSnapRef.current = snap;
163
- }
164
- const accentName = snap.theme?.accent ?? "purple";
165
- const accentHex = useMemo(() => resolveSnapPaletteHex(accentName, appearance), [accentName, appearance]);
166
- const previewSurfaceStyle = useMemo(() => {
167
- const vars = {};
168
- for (const c of PALETTE)
169
- vars[`--snap-color-${c}`] = resolveSnapPaletteHex(c, appearance);
170
- return {
171
- ...snapPreviewPrimaryCssProperties(accentName, appearance),
172
- ...vars,
173
- };
174
- }, [accentName, appearance]);
175
- const handleAction = useCallback((name, params) => {
176
- const inputs = (stateRef.current.inputs ?? {});
177
- const p = (params ?? {});
178
- switch (name) {
179
- case "submit":
180
- handlers.submit(String(p.target ?? ""), inputs);
181
- break;
182
- case "open_url":
183
- handlers.open_url(String(p.target ?? ""), {
184
- isSnap: p.isSnap === true,
185
- });
186
- break;
187
- case "open_mini_app":
188
- handlers.open_mini_app(String(p.target ?? ""));
189
- break;
190
- case "view_cast":
191
- handlers.view_cast({ hash: String(p.hash ?? "") });
192
- break;
193
- case "view_profile":
194
- handlers.view_profile({ fid: Number(p.fid ?? 0) });
195
- break;
196
- case "compose_cast":
197
- handlers.compose_cast({
198
- text: p.text ? String(p.text) : undefined,
199
- channelKey: p.channelKey ? String(p.channelKey) : undefined,
200
- embeds: Array.isArray(p.embeds)
201
- ? p.embeds
202
- : undefined,
203
- });
204
- break;
205
- case "view_token":
206
- handlers.view_token({ token: String(p.token ?? "") });
207
- break;
208
- case "send_token":
209
- handlers.send_token({
210
- token: String(p.token ?? ""),
211
- amount: p.amount ? String(p.amount) : undefined,
212
- recipientFid: p.recipientFid ? Number(p.recipientFid) : undefined,
213
- recipientAddress: p.recipientAddress
214
- ? String(p.recipientAddress)
215
- : undefined,
216
- });
217
- break;
218
- case "swap_token":
219
- handlers.swap_token({
220
- sellToken: p.sellToken ? String(p.sellToken) : undefined,
221
- buyToken: p.buyToken ? String(p.buyToken) : undefined,
222
- });
223
- break;
224
- default:
225
- break;
226
- }
227
- }, [handlers]);
228
- return (_jsxs("div", { style: { position: "relative", width: "100%" }, children: [showConfetti && (_jsx(ConfettiOverlay, {}, `confetti-${confettiEpochRef.current}`)), _jsx(SnapLoadingOverlay, { appearance: appearance, accentHex: accentHex, active: loading }), _jsx("div", { style: previewSurfaceStyle, children: _jsx(SnapPreviewAccentProvider, { pageAccent: snap.theme?.accent, appearance: appearance, children: _jsx(SnapCatalogView, { spec: spec, state: initialState, loading: false, onStateChange: (changes) => {
229
- applyStatePaths(stateRef.current, changes);
230
- }, onAction: handleAction }, pageKey) }) })] }));
11
+ return (_jsx(SnapCardV1, { snap: snap, handlers: handlers, loading: loading, appearance: appearance, maxWidth: maxWidth }));
231
12
  }
@@ -0,0 +1,11 @@
1
+ import type { SnapActionHandlers, SnapPage } from "./index.js";
2
+ export declare function applyStatePaths(model: Record<string, unknown>, changes: {
3
+ path: string;
4
+ value: unknown;
5
+ }[] | Record<string, unknown>): void;
6
+ export declare function SnapViewCore({ snap, handlers, loading, appearance, }: {
7
+ snap: SnapPage;
8
+ handlers: SnapActionHandlers;
9
+ loading?: boolean;
10
+ appearance?: "light" | "dark";
11
+ }): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,224 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { snapJsonRenderCatalog } from "../ui/index.js";
4
+ import { SnapCatalogView } from "./catalog-renderer.js";
5
+ import { SnapPreviewAccentProvider } from "./accent-context.js";
6
+ import { resolveSnapPaletteHex } from "./lib/resolve-palette-hex.js";
7
+ import { snapPreviewPrimaryCssProperties } from "./lib/preview-primary-css.js";
8
+ import { useCallback, useEffect, useMemo, useRef, useState, } from "react";
9
+ // ─── Internal helpers ──────────────────────────────────
10
+ export function applyStatePaths(model, changes) {
11
+ const entries = Array.isArray(changes)
12
+ ? changes.map((c) => [c.path, c.value])
13
+ : Object.entries(changes);
14
+ for (const [path, value] of entries) {
15
+ const trimmed = path.startsWith("/") ? path : `/${path}`;
16
+ const parts = trimmed.split("/").filter(Boolean);
17
+ if (parts.length < 2)
18
+ continue;
19
+ const [top, ...rest] = parts;
20
+ if (top === "inputs") {
21
+ if (typeof model.inputs !== "object" || model.inputs === null) {
22
+ model.inputs = {};
23
+ }
24
+ const inputs = model.inputs;
25
+ if (rest.length === 1) {
26
+ inputs[rest[0]] = value;
27
+ }
28
+ continue;
29
+ }
30
+ if (top === "theme") {
31
+ if (typeof model.theme !== "object" || model.theme === null) {
32
+ model.theme = {};
33
+ }
34
+ const theme = model.theme;
35
+ if (rest.length === 1) {
36
+ theme[rest[0]] = value;
37
+ }
38
+ }
39
+ }
40
+ }
41
+ const CONFETTI_COLORS = [
42
+ "#8B5CF6",
43
+ "#EC4899",
44
+ "#3B82F6",
45
+ "#10B981",
46
+ "#F59E0B",
47
+ "#EF4444",
48
+ "#06B6D4",
49
+ ];
50
+ function ConfettiOverlay() {
51
+ const pieces = useMemo(() => Array.from({ length: 80 }, (_, i) => ({
52
+ id: i,
53
+ left: Math.random() * 100,
54
+ delay: Math.random() * 1.2,
55
+ duration: 2.5 + Math.random() * 2,
56
+ color: CONFETTI_COLORS[Math.floor(Math.random() * CONFETTI_COLORS.length)],
57
+ size: 6 + Math.random() * 8,
58
+ rotation: Math.random() * 360,
59
+ })), []);
60
+ return (_jsxs("div", { style: {
61
+ position: "absolute",
62
+ inset: 0,
63
+ overflow: "hidden",
64
+ pointerEvents: "none",
65
+ zIndex: 20,
66
+ }, children: [pieces.map(({ id, left, delay, duration, color, size, rotation }) => (_jsx("div", { style: {
67
+ position: "absolute",
68
+ left: `${left}%`,
69
+ top: -20,
70
+ width: size,
71
+ height: size * 0.6,
72
+ backgroundColor: color,
73
+ borderRadius: 2,
74
+ transform: `rotate(${rotation}deg)`,
75
+ animation: `confettiFall ${duration}s ease-in ${delay}s forwards`,
76
+ } }, id))), _jsx("style", { children: `@keyframes confettiFall{0%{top:-20px;opacity:1;transform:rotate(0deg) translateX(0)}50%{opacity:1}100%{top:110%;opacity:0;transform:rotate(720deg) translateX(${Math.random() > 0.5 ? "" : "-"}40px)}}` })] }));
77
+ }
78
+ function SnapLoadingOverlay({ appearance, accentHex, active, }) {
79
+ const isDark = appearance === "dark";
80
+ const tint = isDark ? "rgba(0, 0, 0, 0.1)" : "rgba(255, 255, 255, 0.2)";
81
+ const trackColor = isDark
82
+ ? "rgba(255, 255, 255, 0.12)"
83
+ : "rgba(15, 23, 42, 0.1)";
84
+ return (_jsxs("div", { style: {
85
+ position: "absolute",
86
+ inset: 0,
87
+ display: "flex",
88
+ alignItems: "center",
89
+ justifyContent: "center",
90
+ zIndex: 10,
91
+ background: tint,
92
+ backdropFilter: active ? "blur(10px) saturate(1.05)" : "none",
93
+ WebkitBackdropFilter: active
94
+ ? "blur(10px) saturate(1.05)"
95
+ : "none",
96
+ opacity: active ? 1 : 0,
97
+ pointerEvents: active ? "auto" : "none",
98
+ transition: "opacity 0.28s ease, backdrop-filter 0.28s ease",
99
+ }, "aria-hidden": !active, "aria-busy": active ? true : undefined, "aria-live": active ? "polite" : undefined, "aria-label": active ? "Loading" : undefined, children: [_jsx("div", { "data-snap-loading-spinner": true, style: {
100
+ width: 30,
101
+ height: 30,
102
+ borderRadius: "50%",
103
+ border: `2.5px solid ${trackColor}`,
104
+ borderTopColor: accentHex,
105
+ opacity: 0.88,
106
+ animation: "snapViewSpin 0.75s linear infinite",
107
+ flexShrink: 0,
108
+ } }), _jsx("style", { children: `
109
+ @keyframes snapViewSpin {
110
+ to { transform: rotate(360deg); }
111
+ }
112
+ @media (prefers-reduced-motion: reduce) {
113
+ [data-snap-loading-spinner] {
114
+ animation: none;
115
+ border-top-color: ${accentHex};
116
+ opacity: 0.75;
117
+ }
118
+ }
119
+ ` })] }));
120
+ }
121
+ const PALETTE = [
122
+ "gray",
123
+ "blue",
124
+ "red",
125
+ "amber",
126
+ "green",
127
+ "teal",
128
+ "purple",
129
+ "pink",
130
+ ];
131
+ // ─── SnapViewCore ────────────────────────────────────
132
+ // Shared rendering logic used by both v1 and v2.
133
+ export function SnapViewCore({ snap, handlers, loading = false, appearance = "dark", }) {
134
+ const spec = snap.ui;
135
+ const initialState = useMemo(() => spec.state ?? { inputs: {} }, [spec]);
136
+ const stateRef = useRef(initialState);
137
+ useEffect(() => {
138
+ stateRef.current = {
139
+ inputs: {
140
+ ...(initialState.inputs ?? {}),
141
+ },
142
+ theme: {
143
+ ...(initialState.theme ?? {}),
144
+ },
145
+ };
146
+ }, [initialState]);
147
+ useEffect(() => {
148
+ const catalogResult = snapJsonRenderCatalog.validate(spec);
149
+ if (!catalogResult.success) {
150
+ // eslint-disable-next-line no-console
151
+ console.warn("[Snap] catalog validation issues:", catalogResult.error);
152
+ }
153
+ }, [spec]);
154
+ const [pageKey, setPageKey] = useState(0);
155
+ useEffect(() => {
156
+ setPageKey((k) => k + 1);
157
+ }, [spec]);
158
+ const showConfetti = snap.effects?.includes("confetti");
159
+ const accentName = snap.theme?.accent ?? "purple";
160
+ const accentHex = useMemo(() => resolveSnapPaletteHex(accentName, appearance), [accentName, appearance]);
161
+ const previewSurfaceStyle = useMemo(() => {
162
+ const vars = {};
163
+ for (const c of PALETTE)
164
+ vars[`--snap-color-${c}`] = resolveSnapPaletteHex(c, appearance);
165
+ return {
166
+ ...snapPreviewPrimaryCssProperties(accentName, appearance),
167
+ ...vars,
168
+ };
169
+ }, [accentName, appearance]);
170
+ const handleAction = useCallback((name, params) => {
171
+ const inputs = (stateRef.current.inputs ?? {});
172
+ const p = (params ?? {});
173
+ switch (name) {
174
+ case "submit":
175
+ handlers.submit(String(p.target ?? ""), inputs);
176
+ break;
177
+ case "open_url":
178
+ handlers.open_url(String(p.target ?? ""));
179
+ break;
180
+ case "open_mini_app":
181
+ handlers.open_mini_app(String(p.target ?? ""));
182
+ break;
183
+ case "view_cast":
184
+ handlers.view_cast({ hash: String(p.hash ?? "") });
185
+ break;
186
+ case "view_profile":
187
+ handlers.view_profile({ fid: Number(p.fid ?? 0) });
188
+ break;
189
+ case "compose_cast":
190
+ handlers.compose_cast({
191
+ text: p.text ? String(p.text) : undefined,
192
+ channelKey: p.channelKey ? String(p.channelKey) : undefined,
193
+ embeds: Array.isArray(p.embeds)
194
+ ? p.embeds
195
+ : undefined,
196
+ });
197
+ break;
198
+ case "view_token":
199
+ handlers.view_token({ token: String(p.token ?? "") });
200
+ break;
201
+ case "send_token":
202
+ handlers.send_token({
203
+ token: String(p.token ?? ""),
204
+ amount: p.amount ? String(p.amount) : undefined,
205
+ recipientFid: p.recipientFid ? Number(p.recipientFid) : undefined,
206
+ recipientAddress: p.recipientAddress
207
+ ? String(p.recipientAddress)
208
+ : undefined,
209
+ });
210
+ break;
211
+ case "swap_token":
212
+ handlers.swap_token({
213
+ sellToken: p.sellToken ? String(p.sellToken) : undefined,
214
+ buyToken: p.buyToken ? String(p.buyToken) : undefined,
215
+ });
216
+ break;
217
+ default:
218
+ break;
219
+ }
220
+ }, [handlers]);
221
+ return (_jsxs("div", { style: { position: "relative", width: "100%" }, children: [showConfetti && _jsx(ConfettiOverlay, {}), _jsx(SnapLoadingOverlay, { appearance: appearance, accentHex: accentHex, active: loading }), _jsx("div", { style: previewSurfaceStyle, children: _jsx(SnapPreviewAccentProvider, { pageAccent: snap.theme?.accent, appearance: appearance, children: _jsx(SnapCatalogView, { spec: spec, state: initialState, loading: false, onStateChange: (changes) => {
222
+ applyStatePaths(stateRef.current, changes);
223
+ }, onAction: handleAction }, pageKey) }) })] }));
224
+ }
@@ -0,0 +1,14 @@
1
+ import type { SnapPage, SnapActionHandlers } from "../index.js";
2
+ export declare function SnapViewV1({ snap, handlers, loading, appearance, }: {
3
+ snap: SnapPage;
4
+ handlers: SnapActionHandlers;
5
+ loading?: boolean;
6
+ appearance?: "light" | "dark";
7
+ }): import("react/jsx-runtime").JSX.Element;
8
+ export declare function SnapCardV1({ snap, handlers, loading, appearance, maxWidth, }: {
9
+ snap: SnapPage;
10
+ handlers: SnapActionHandlers;
11
+ loading?: boolean;
12
+ appearance?: "light" | "dark";
13
+ maxWidth?: number;
14
+ }): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,9 @@
1
+ "use client";
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { SnapViewCore } from "../snap-view-core.js";
4
+ export function SnapViewV1({ snap, handlers, loading = false, appearance = "dark", }) {
5
+ return (_jsx(SnapViewCore, { snap: snap, handlers: handlers, loading: loading, appearance: appearance }));
6
+ }
7
+ export function SnapCardV1({ snap, handlers, loading = false, appearance = "dark", maxWidth = 480, }) {
8
+ return (_jsx("div", { style: { position: "relative", width: "100%", maxWidth }, children: _jsx(SnapViewV1, { snap: snap, handlers: handlers, loading: loading, appearance: appearance }) }));
9
+ }
@@ -0,0 +1,21 @@
1
+ import { type ReactNode } from "react";
2
+ import type { ValidationResult } from "../../validator.js";
3
+ import type { SnapPage, SnapActionHandlers } from "../index.js";
4
+ export declare function SnapViewV2({ snap, handlers, loading, appearance, onValidationError, validationErrorFallback, }: {
5
+ snap: SnapPage;
6
+ handlers: SnapActionHandlers;
7
+ loading?: boolean;
8
+ appearance?: "light" | "dark";
9
+ onValidationError?: (result: ValidationResult) => void;
10
+ validationErrorFallback?: ReactNode;
11
+ }): import("react/jsx-runtime").JSX.Element | null;
12
+ export declare function SnapCardV2({ snap, handlers, loading, appearance, maxWidth, showOverflowWarning, onValidationError, validationErrorFallback, }: {
13
+ snap: SnapPage;
14
+ handlers: SnapActionHandlers;
15
+ loading?: boolean;
16
+ appearance?: "light" | "dark";
17
+ maxWidth?: number;
18
+ showOverflowWarning?: boolean;
19
+ onValidationError?: (result: ValidationResult) => void;
20
+ validationErrorFallback?: ReactNode;
21
+ }): import("react/jsx-runtime").JSX.Element;