@farcaster/snap 1.15.4 → 1.16.1

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 +22 -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 +34 -0
  50. package/src/ui/schema.ts +1 -1
  51. package/src/validator.ts +240 -2
@@ -0,0 +1,340 @@
1
+ "use client";
2
+
3
+ import type { Spec } from "@json-render/core";
4
+ import { snapJsonRenderCatalog } from "../ui/index.js";
5
+ import { SnapCatalogView } from "./catalog-renderer";
6
+ import { SnapPreviewAccentProvider } from "./accent-context";
7
+ import { resolveSnapPaletteHex } from "./lib/resolve-palette-hex";
8
+ import { snapPreviewPrimaryCssProperties } from "./lib/preview-primary-css";
9
+ import {
10
+ type CSSProperties,
11
+ useCallback,
12
+ useEffect,
13
+ useMemo,
14
+ useRef,
15
+ useState,
16
+ } from "react";
17
+ import type { JsonValue, SnapActionHandlers, SnapPage } from "./index";
18
+
19
+ // ─── Internal helpers ──────────────────────────────────
20
+
21
+ export function applyStatePaths(
22
+ model: Record<string, unknown>,
23
+ changes: { path: string; value: unknown }[] | Record<string, unknown>,
24
+ ): void {
25
+ const entries = Array.isArray(changes)
26
+ ? changes.map((c) => [c.path, c.value] as const)
27
+ : Object.entries(changes);
28
+ for (const [path, value] of entries) {
29
+ const trimmed = path.startsWith("/") ? path : `/${path}`;
30
+ const parts = trimmed.split("/").filter(Boolean);
31
+ if (parts.length < 2) continue;
32
+ const [top, ...rest] = parts;
33
+ if (top === "inputs") {
34
+ if (typeof model.inputs !== "object" || model.inputs === null) {
35
+ model.inputs = {};
36
+ }
37
+ const inputs = model.inputs as Record<string, unknown>;
38
+ if (rest.length === 1) {
39
+ inputs[rest[0]!] = value;
40
+ }
41
+ continue;
42
+ }
43
+ if (top === "theme") {
44
+ if (typeof model.theme !== "object" || model.theme === null) {
45
+ model.theme = {};
46
+ }
47
+ const theme = model.theme as Record<string, unknown>;
48
+ if (rest.length === 1) {
49
+ theme[rest[0]!] = value;
50
+ }
51
+ }
52
+ }
53
+ }
54
+
55
+ const CONFETTI_COLORS = [
56
+ "#8B5CF6",
57
+ "#EC4899",
58
+ "#3B82F6",
59
+ "#10B981",
60
+ "#F59E0B",
61
+ "#EF4444",
62
+ "#06B6D4",
63
+ ];
64
+
65
+ function ConfettiOverlay() {
66
+ const pieces = useMemo(
67
+ () =>
68
+ Array.from({ length: 80 }, (_, i) => ({
69
+ id: i,
70
+ left: Math.random() * 100,
71
+ delay: Math.random() * 1.2,
72
+ duration: 2.5 + Math.random() * 2,
73
+ color:
74
+ CONFETTI_COLORS[Math.floor(Math.random() * CONFETTI_COLORS.length)],
75
+ size: 6 + Math.random() * 8,
76
+ rotation: Math.random() * 360,
77
+ })),
78
+ [],
79
+ );
80
+
81
+ return (
82
+ <div
83
+ style={{
84
+ position: "absolute",
85
+ inset: 0,
86
+ overflow: "hidden",
87
+ pointerEvents: "none",
88
+ zIndex: 20,
89
+ }}
90
+ >
91
+ {pieces.map(({ id, left, delay, duration, color, size, rotation }) => (
92
+ <div
93
+ key={id}
94
+ style={{
95
+ position: "absolute",
96
+ left: `${left}%`,
97
+ top: -20,
98
+ width: size,
99
+ height: size * 0.6,
100
+ backgroundColor: color,
101
+ borderRadius: 2,
102
+ transform: `rotate(${rotation}deg)`,
103
+ animation: `confettiFall ${duration}s ease-in ${delay}s forwards`,
104
+ }}
105
+ />
106
+ ))}
107
+ <style>{`@keyframes confettiFall{0%{top:-20px;opacity:1;transform:rotate(0deg) translateX(0)}50%{opacity:1}100%{top:110%;opacity:0;transform:rotate(720deg) translateX(${
108
+ Math.random() > 0.5 ? "" : "-"
109
+ }40px)}}`}</style>
110
+ </div>
111
+ );
112
+ }
113
+
114
+ function SnapLoadingOverlay({
115
+ appearance,
116
+ accentHex,
117
+ active,
118
+ }: {
119
+ appearance: "light" | "dark";
120
+ accentHex: string;
121
+ active: boolean;
122
+ }) {
123
+ const isDark = appearance === "dark";
124
+ const tint = isDark ? "rgba(0, 0, 0, 0.1)" : "rgba(255, 255, 255, 0.2)";
125
+ const trackColor = isDark
126
+ ? "rgba(255, 255, 255, 0.12)"
127
+ : "rgba(15, 23, 42, 0.1)";
128
+
129
+ return (
130
+ <div
131
+ style={{
132
+ position: "absolute",
133
+ inset: 0,
134
+ display: "flex",
135
+ alignItems: "center",
136
+ justifyContent: "center",
137
+ zIndex: 10,
138
+ background: tint,
139
+ backdropFilter: active ? "blur(10px) saturate(1.05)" : "none",
140
+ WebkitBackdropFilter: active
141
+ ? "blur(10px) saturate(1.05)"
142
+ : "none",
143
+ opacity: active ? 1 : 0,
144
+ pointerEvents: active ? "auto" : "none",
145
+ transition: "opacity 0.28s ease, backdrop-filter 0.28s ease",
146
+ }}
147
+ aria-hidden={!active}
148
+ aria-busy={active ? true : undefined}
149
+ aria-live={active ? "polite" : undefined}
150
+ aria-label={active ? "Loading" : undefined}
151
+ >
152
+ <div
153
+ data-snap-loading-spinner
154
+ style={{
155
+ width: 30,
156
+ height: 30,
157
+ borderRadius: "50%",
158
+ border: `2.5px solid ${trackColor}`,
159
+ borderTopColor: accentHex,
160
+ opacity: 0.88,
161
+ animation: "snapViewSpin 0.75s linear infinite",
162
+ flexShrink: 0,
163
+ }}
164
+ />
165
+ <style>{`
166
+ @keyframes snapViewSpin {
167
+ to { transform: rotate(360deg); }
168
+ }
169
+ @media (prefers-reduced-motion: reduce) {
170
+ [data-snap-loading-spinner] {
171
+ animation: none;
172
+ border-top-color: ${accentHex};
173
+ opacity: 0.75;
174
+ }
175
+ }
176
+ `}</style>
177
+ </div>
178
+ );
179
+ }
180
+
181
+ const PALETTE = [
182
+ "gray",
183
+ "blue",
184
+ "red",
185
+ "amber",
186
+ "green",
187
+ "teal",
188
+ "purple",
189
+ "pink",
190
+ ] as const;
191
+
192
+ // ─── SnapViewCore ────────────────────────────────────
193
+ // Shared rendering logic used by both v1 and v2.
194
+
195
+ export function SnapViewCore({
196
+ snap,
197
+ handlers,
198
+ loading = false,
199
+ appearance = "dark",
200
+ }: {
201
+ snap: SnapPage;
202
+ handlers: SnapActionHandlers;
203
+ loading?: boolean;
204
+ appearance?: "light" | "dark";
205
+ }) {
206
+ const spec = snap.ui;
207
+ const initialState = useMemo(() => spec.state ?? { inputs: {} }, [spec]);
208
+
209
+ const stateRef = useRef<Record<string, unknown>>(initialState);
210
+
211
+ useEffect(() => {
212
+ stateRef.current = {
213
+ inputs: {
214
+ ...((initialState.inputs ?? {}) as Record<string, unknown>),
215
+ },
216
+ theme: {
217
+ ...((initialState.theme ?? {}) as Record<string, unknown>),
218
+ },
219
+ };
220
+ }, [initialState]);
221
+
222
+ useEffect(() => {
223
+ const catalogResult = snapJsonRenderCatalog.validate(spec);
224
+ if (!catalogResult.success) {
225
+ // eslint-disable-next-line no-console
226
+ console.warn("[Snap] catalog validation issues:", catalogResult.error);
227
+ }
228
+ }, [spec]);
229
+
230
+ const [pageKey, setPageKey] = useState(0);
231
+ useEffect(() => {
232
+ setPageKey((k) => k + 1);
233
+ }, [spec]);
234
+
235
+ const showConfetti = snap.effects?.includes("confetti");
236
+
237
+ const accentName = snap.theme?.accent ?? "purple";
238
+
239
+ const accentHex = useMemo(
240
+ () => resolveSnapPaletteHex(accentName, appearance),
241
+ [accentName, appearance],
242
+ );
243
+
244
+ const previewSurfaceStyle = useMemo(() => {
245
+ const vars: Record<string, string> = {};
246
+ for (const c of PALETTE)
247
+ vars[`--snap-color-${c}`] = resolveSnapPaletteHex(c, appearance);
248
+ return {
249
+ ...snapPreviewPrimaryCssProperties(accentName, appearance),
250
+ ...vars,
251
+ } as CSSProperties;
252
+ }, [accentName, appearance]);
253
+
254
+ const handleAction = useCallback(
255
+ (name: unknown, params: unknown) => {
256
+ const inputs = (stateRef.current.inputs ?? {}) as Record<
257
+ string,
258
+ JsonValue
259
+ >;
260
+ const p = (params ?? {}) as Record<string, unknown>;
261
+ switch (name) {
262
+ case "submit":
263
+ handlers.submit(String(p.target ?? ""), inputs);
264
+ break;
265
+ case "open_url":
266
+ handlers.open_url(String(p.target ?? ""));
267
+ break;
268
+ case "open_mini_app":
269
+ handlers.open_mini_app(String(p.target ?? ""));
270
+ break;
271
+ case "view_cast":
272
+ handlers.view_cast({ hash: String(p.hash ?? "") });
273
+ break;
274
+ case "view_profile":
275
+ handlers.view_profile({ fid: Number(p.fid ?? 0) });
276
+ break;
277
+ case "compose_cast":
278
+ handlers.compose_cast({
279
+ text: p.text ? String(p.text) : undefined,
280
+ channelKey: p.channelKey ? String(p.channelKey) : undefined,
281
+ embeds: Array.isArray(p.embeds)
282
+ ? (p.embeds as string[])
283
+ : undefined,
284
+ });
285
+ break;
286
+ case "view_token":
287
+ handlers.view_token({ token: String(p.token ?? "") });
288
+ break;
289
+ case "send_token":
290
+ handlers.send_token({
291
+ token: String(p.token ?? ""),
292
+ amount: p.amount ? String(p.amount) : undefined,
293
+ recipientFid: p.recipientFid ? Number(p.recipientFid) : undefined,
294
+ recipientAddress: p.recipientAddress
295
+ ? String(p.recipientAddress)
296
+ : undefined,
297
+ });
298
+ break;
299
+ case "swap_token":
300
+ handlers.swap_token({
301
+ sellToken: p.sellToken ? String(p.sellToken) : undefined,
302
+ buyToken: p.buyToken ? String(p.buyToken) : undefined,
303
+ });
304
+ break;
305
+ default:
306
+ break;
307
+ }
308
+ },
309
+ [handlers],
310
+ );
311
+
312
+ return (
313
+ <div style={{ position: "relative", width: "100%" }}>
314
+ {showConfetti && <ConfettiOverlay />}
315
+ <SnapLoadingOverlay
316
+ appearance={appearance}
317
+ accentHex={accentHex}
318
+ active={loading}
319
+ />
320
+
321
+ <div style={previewSurfaceStyle}>
322
+ <SnapPreviewAccentProvider
323
+ pageAccent={snap.theme?.accent}
324
+ appearance={appearance}
325
+ >
326
+ <SnapCatalogView
327
+ key={pageKey}
328
+ spec={spec}
329
+ state={initialState}
330
+ loading={false}
331
+ onStateChange={(changes) => {
332
+ applyStatePaths(stateRef.current, changes);
333
+ }}
334
+ onAction={handleAction}
335
+ />
336
+ </SnapPreviewAccentProvider>
337
+ </div>
338
+ </div>
339
+ );
340
+ }
@@ -0,0 +1,50 @@
1
+ "use client";
2
+
3
+ import { SnapViewCore } from "../snap-view-core";
4
+ import type { SnapPage, SnapActionHandlers } from "../index";
5
+
6
+ export function SnapViewV1({
7
+ snap,
8
+ handlers,
9
+ loading = false,
10
+ appearance = "dark",
11
+ }: {
12
+ snap: SnapPage;
13
+ handlers: SnapActionHandlers;
14
+ loading?: boolean;
15
+ appearance?: "light" | "dark";
16
+ }) {
17
+ return (
18
+ <SnapViewCore
19
+ snap={snap}
20
+ handlers={handlers}
21
+ loading={loading}
22
+ appearance={appearance}
23
+ />
24
+ );
25
+ }
26
+
27
+ export function SnapCardV1({
28
+ snap,
29
+ handlers,
30
+ loading = false,
31
+ appearance = "dark",
32
+ maxWidth = 480,
33
+ }: {
34
+ snap: SnapPage;
35
+ handlers: SnapActionHandlers;
36
+ loading?: boolean;
37
+ appearance?: "light" | "dark";
38
+ maxWidth?: number;
39
+ }) {
40
+ return (
41
+ <div style={{ position: "relative", width: "100%", maxWidth }}>
42
+ <SnapViewV1
43
+ snap={snap}
44
+ handlers={handlers}
45
+ loading={loading}
46
+ appearance={appearance}
47
+ />
48
+ </div>
49
+ );
50
+ }
@@ -0,0 +1,168 @@
1
+ "use client";
2
+
3
+ import { type ReactNode, useEffect, useMemo } from "react";
4
+ import { validateSnapResponse } from "../../validator.js";
5
+ import type { ValidationResult } from "../../validator.js";
6
+ import { SnapViewCore } from "../snap-view-core";
7
+ import type { SnapPage, SnapActionHandlers } from "../index";
8
+
9
+ const SNAP_MAX_HEIGHT = 500;
10
+ const SNAP_WARNING_HEIGHT = 700;
11
+
12
+ // ─── Default validation error fallback ────────────────
13
+
14
+ function SnapValidationFallback({
15
+ appearance,
16
+ message,
17
+ }: {
18
+ appearance: "light" | "dark";
19
+ message?: string;
20
+ }) {
21
+ const isDark = appearance === "dark";
22
+ return (
23
+ <div
24
+ style={{
25
+ width: "100%",
26
+ padding: 16,
27
+ display: "flex",
28
+ alignItems: "center",
29
+ justifyContent: "center",
30
+ color: isDark ? "rgba(255,255,255,0.5)" : "rgba(0,0,0,0.4)",
31
+ fontSize: 14,
32
+ }}
33
+ >
34
+ <span>{message ? `Unable to render snap: ${message}` : "Unable to render snap"}</span>
35
+ </div>
36
+ );
37
+ }
38
+
39
+ // ─── SnapViewV2 ──────────────────────────────────────
40
+
41
+ export function SnapViewV2({
42
+ snap,
43
+ handlers,
44
+ loading = false,
45
+ appearance = "dark",
46
+ onValidationError,
47
+ validationErrorFallback,
48
+ }: {
49
+ snap: SnapPage;
50
+ handlers: SnapActionHandlers;
51
+ loading?: boolean;
52
+ appearance?: "light" | "dark";
53
+ onValidationError?: (result: ValidationResult) => void;
54
+ validationErrorFallback?: ReactNode;
55
+ }) {
56
+ const validation = useMemo(() => validateSnapResponse(snap), [snap]);
57
+ const valid = validation.valid;
58
+ const validationMessage = validation.issues[0]?.message;
59
+
60
+ useEffect(() => {
61
+ if (!valid) {
62
+ if (onValidationError) {
63
+ onValidationError(validation);
64
+ } else {
65
+ // eslint-disable-next-line no-console
66
+ console.warn("[Snap] validation issues:", validation.issues);
67
+ }
68
+ }
69
+ }, [valid, validation, onValidationError]);
70
+
71
+ if (!valid) {
72
+ if (validationErrorFallback === null) return null;
73
+ return <>{validationErrorFallback ?? <SnapValidationFallback appearance={appearance} message={validationMessage} />}</>;
74
+ }
75
+
76
+ return (
77
+ <SnapViewCore
78
+ snap={snap}
79
+ handlers={handlers}
80
+ loading={loading}
81
+ appearance={appearance}
82
+ />
83
+ );
84
+ }
85
+
86
+ // ─── SnapCardV2 ──────────────────────────────────────
87
+
88
+ export function SnapCardV2({
89
+ snap,
90
+ handlers,
91
+ loading = false,
92
+ appearance = "dark",
93
+ maxWidth = 480,
94
+ showOverflowWarning = false,
95
+ onValidationError,
96
+ validationErrorFallback,
97
+ }: {
98
+ snap: SnapPage;
99
+ handlers: SnapActionHandlers;
100
+ loading?: boolean;
101
+ appearance?: "light" | "dark";
102
+ maxWidth?: number;
103
+ showOverflowWarning?: boolean;
104
+ onValidationError?: (result: ValidationResult) => void;
105
+ validationErrorFallback?: ReactNode;
106
+ }) {
107
+ const maxHeight = showOverflowWarning ? SNAP_WARNING_HEIGHT : SNAP_MAX_HEIGHT;
108
+ const bg = appearance === "dark" ? "rgba(0,0,0,0.85)" : "rgba(255,255,255,0.9)";
109
+
110
+ return (
111
+ <div
112
+ style={{
113
+ position: "relative",
114
+ width: "100%",
115
+ maxWidth,
116
+ maxHeight,
117
+ overflow: "hidden",
118
+ }}
119
+ >
120
+ <SnapViewV2
121
+ snap={snap}
122
+ handlers={handlers}
123
+ loading={loading}
124
+ appearance={appearance}
125
+ onValidationError={onValidationError}
126
+ validationErrorFallback={validationErrorFallback}
127
+ />
128
+ {showOverflowWarning && (
129
+ <div
130
+ style={{
131
+ position: "absolute",
132
+ top: SNAP_MAX_HEIGHT,
133
+ left: 0,
134
+ right: 0,
135
+ bottom: 0,
136
+ pointerEvents: "none",
137
+ zIndex: 10,
138
+ }}
139
+ >
140
+ <div style={{ borderTop: "1px dashed rgba(255,100,100,0.6)", position: "relative" }}>
141
+ <span
142
+ style={{
143
+ position: "absolute",
144
+ top: -10,
145
+ right: 0,
146
+ fontSize: 10,
147
+ fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
148
+ color: "rgba(255,100,100,0.7)",
149
+ background: bg,
150
+ padding: "1px 4px",
151
+ borderRadius: 3,
152
+ }}
153
+ >
154
+ {SNAP_MAX_HEIGHT}px
155
+ </span>
156
+ </div>
157
+ <div
158
+ style={{
159
+ height: "100%",
160
+ background:
161
+ "repeating-linear-gradient(-45deg, transparent, transparent 8px, rgba(255,100,100,0.06) 8px, rgba(255,100,100,0.06) 16px)",
162
+ }}
163
+ />
164
+ </div>
165
+ )}
166
+ </div>
167
+ );
168
+ }
@@ -22,17 +22,17 @@ export function SnapBadge({
22
22
  style={[
23
23
  styles.badge,
24
24
  isFilled
25
- ? { backgroundColor: resolvedColor, borderColor: resolvedColor }
25
+ ? { backgroundColor: resolvedColor + "20", borderColor: "transparent" }
26
26
  : { borderColor: resolvedColor },
27
27
  ]}
28
28
  >
29
29
  {Icon && (
30
- <Icon size={12} color={isFilled ? "#fff" : resolvedColor} />
30
+ <Icon size={12} color={resolvedColor} />
31
31
  )}
32
32
  <Text
33
33
  style={[
34
34
  styles.label,
35
- { color: isFilled ? "#fff" : resolvedColor },
35
+ { color: resolvedColor },
36
36
  ]}
37
37
  >
38
38
  {label}