@farcaster/snap 2.0.0 → 2.0.2

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 (194) hide show
  1. package/dist/colors.d.ts +4 -4
  2. package/dist/colors.js +20 -20
  3. package/dist/constants.d.ts +17 -1
  4. package/dist/constants.js +19 -1
  5. package/dist/index.d.ts +4 -6
  6. package/dist/index.js +2 -4
  7. package/dist/react/accent-context.d.ts +3 -1
  8. package/dist/react/accent-context.js +7 -4
  9. package/dist/react/catalog-renderer.js +4 -0
  10. package/dist/react/components/action-button.d.ts +2 -1
  11. package/dist/react/components/action-button.js +35 -13
  12. package/dist/react/components/badge.js +8 -8
  13. package/dist/react/components/bar-chart.d.ts +5 -0
  14. package/dist/react/components/bar-chart.js +26 -0
  15. package/dist/react/components/cell-grid.d.ts +5 -0
  16. package/dist/react/components/cell-grid.js +87 -0
  17. package/dist/react/components/icon.js +4 -10
  18. package/dist/react/components/input.js +12 -6
  19. package/dist/react/components/item-group.js +3 -1
  20. package/dist/react/components/item.d.ts +3 -3
  21. package/dist/react/components/item.js +4 -3
  22. package/dist/react/components/progress.js +3 -3
  23. package/dist/react/components/separator.js +3 -1
  24. package/dist/react/components/slider.js +15 -10
  25. package/dist/react/components/switch.js +10 -12
  26. package/dist/react/components/text.js +6 -14
  27. package/dist/react/components/toggle-group.js +20 -6
  28. package/dist/react/hooks/use-snap-colors.d.ts +38 -0
  29. package/dist/react/hooks/use-snap-colors.js +81 -0
  30. package/dist/react/index.d.ts +13 -1
  31. package/dist/react/index.js +9 -188
  32. package/dist/react/snap-view-core.d.ts +11 -0
  33. package/dist/react/snap-view-core.js +227 -0
  34. package/dist/react/v1/snap-view.d.ts +16 -0
  35. package/dist/react/v1/snap-view.js +90 -0
  36. package/dist/react/v2/snap-view.d.ts +23 -0
  37. package/dist/react/v2/snap-view.js +91 -0
  38. package/dist/react-native/catalog-renderer.d.ts +5 -0
  39. package/dist/react-native/catalog-renderer.js +40 -0
  40. package/dist/react-native/components/snap-action-button.d.ts +2 -0
  41. package/dist/react-native/components/snap-action-button.js +69 -0
  42. package/dist/react-native/components/snap-badge.d.ts +2 -0
  43. package/dist/react-native/components/snap-badge.js +41 -0
  44. package/dist/react-native/components/snap-bar-chart.d.ts +2 -0
  45. package/dist/react-native/components/snap-bar-chart.js +39 -0
  46. package/dist/react-native/components/snap-cell-grid.d.ts +2 -0
  47. package/dist/react-native/components/snap-cell-grid.js +94 -0
  48. package/dist/react-native/components/snap-icon.d.ts +5 -0
  49. package/dist/react-native/components/snap-icon.js +56 -0
  50. package/dist/react-native/components/snap-image.d.ts +2 -0
  51. package/dist/react-native/components/snap-image.js +23 -0
  52. package/dist/react-native/components/snap-input.d.ts +2 -0
  53. package/dist/react-native/components/snap-input.js +37 -0
  54. package/dist/react-native/components/snap-item-group.d.ts +5 -0
  55. package/dist/react-native/components/snap-item-group.js +23 -0
  56. package/dist/react-native/components/snap-item.d.ts +5 -0
  57. package/dist/react-native/components/snap-item.js +42 -0
  58. package/dist/react-native/components/snap-progress.d.ts +2 -0
  59. package/dist/react-native/components/snap-progress.js +26 -0
  60. package/dist/react-native/components/snap-separator.d.ts +2 -0
  61. package/dist/react-native/components/snap-separator.js +23 -0
  62. package/dist/react-native/components/snap-slider.d.ts +2 -0
  63. package/dist/react-native/components/snap-slider.js +43 -0
  64. package/dist/react-native/components/snap-stack.d.ts +5 -0
  65. package/dist/react-native/components/snap-stack.js +49 -0
  66. package/dist/react-native/components/snap-switch.d.ts +2 -0
  67. package/dist/react-native/components/snap-switch.js +31 -0
  68. package/dist/react-native/components/snap-text.d.ts +2 -0
  69. package/dist/react-native/components/snap-text.js +35 -0
  70. package/dist/react-native/components/snap-toggle-group.d.ts +2 -0
  71. package/dist/react-native/components/snap-toggle-group.js +99 -0
  72. package/dist/react-native/confetti-overlay.d.ts +1 -0
  73. package/dist/react-native/confetti-overlay.js +106 -0
  74. package/dist/react-native/index.d.ts +28 -0
  75. package/dist/react-native/index.js +15 -0
  76. package/dist/react-native/snap-view-core.d.ts +11 -0
  77. package/dist/react-native/snap-view-core.js +156 -0
  78. package/dist/react-native/theme.d.ts +27 -0
  79. package/dist/react-native/theme.js +43 -0
  80. package/dist/react-native/types.d.ts +42 -0
  81. package/dist/react-native/types.js +1 -0
  82. package/dist/react-native/use-snap-palette.d.ts +13 -0
  83. package/dist/react-native/use-snap-palette.js +48 -0
  84. package/dist/react-native/v1/snap-view.d.ts +24 -0
  85. package/dist/react-native/v1/snap-view.js +96 -0
  86. package/dist/react-native/v2/snap-view.d.ts +33 -0
  87. package/dist/react-native/v2/snap-view.js +114 -0
  88. package/dist/schemas.d.ts +100 -13
  89. package/dist/schemas.js +28 -10
  90. package/dist/server/parseRequest.d.ts +10 -0
  91. package/dist/server/parseRequest.js +48 -7
  92. package/dist/server/verify.d.ts +1 -0
  93. package/dist/server/verify.js +1 -0
  94. package/dist/ui/badge.d.ts +7 -2
  95. package/dist/ui/badge.js +2 -0
  96. package/dist/ui/bar-chart.d.ts +30 -0
  97. package/dist/ui/bar-chart.js +30 -0
  98. package/dist/ui/button.d.ts +4 -6
  99. package/dist/ui/button.js +1 -1
  100. package/dist/ui/catalog.d.ts +90 -16
  101. package/dist/ui/catalog.js +17 -3
  102. package/dist/ui/cell-grid.d.ts +34 -0
  103. package/dist/ui/cell-grid.js +39 -0
  104. package/dist/ui/icon.d.ts +2 -2
  105. package/dist/ui/image.d.ts +1 -2
  106. package/dist/ui/image.js +1 -1
  107. package/dist/ui/index.d.ts +4 -0
  108. package/dist/ui/index.js +2 -0
  109. package/dist/ui/item.d.ts +1 -3
  110. package/dist/ui/item.js +1 -1
  111. package/dist/ui/schema.d.ts +6 -2
  112. package/dist/ui/schema.js +2 -2
  113. package/dist/ui/slider.d.ts +1 -0
  114. package/dist/ui/slider.js +2 -0
  115. package/dist/ui/text.d.ts +2 -4
  116. package/dist/ui/text.js +2 -2
  117. package/dist/validator.d.ts +3 -2
  118. package/dist/validator.js +203 -2
  119. package/llms.txt +199 -0
  120. package/package.json +9 -3
  121. package/src/colors.ts +20 -20
  122. package/src/constants.ts +23 -1
  123. package/src/index.ts +16 -13
  124. package/src/react/accent-context.tsx +13 -6
  125. package/src/react/catalog-renderer.tsx +4 -0
  126. package/src/react/components/action-button.tsx +50 -20
  127. package/src/react/components/badge.tsx +14 -18
  128. package/src/react/components/bar-chart.tsx +69 -0
  129. package/src/react/components/cell-grid.tsx +128 -0
  130. package/src/react/components/icon.tsx +5 -18
  131. package/src/react/components/input.tsx +20 -9
  132. package/src/react/components/item-group.tsx +4 -1
  133. package/src/react/components/item.tsx +13 -10
  134. package/src/react/components/progress.tsx +12 -7
  135. package/src/react/components/separator.tsx +8 -1
  136. package/src/react/components/slider.tsx +28 -15
  137. package/src/react/components/switch.tsx +12 -16
  138. package/src/react/components/text.tsx +14 -23
  139. package/src/react/components/toggle-group.tsx +26 -9
  140. package/src/react/hooks/use-snap-colors.ts +128 -0
  141. package/src/react/index.tsx +49 -265
  142. package/src/react/snap-view-core.tsx +343 -0
  143. package/src/react/v1/snap-view.tsx +176 -0
  144. package/src/react/v2/snap-view.tsx +199 -0
  145. package/src/react-native/catalog-renderer.tsx +41 -0
  146. package/src/react-native/components/snap-action-button.tsx +96 -0
  147. package/src/react-native/components/snap-badge.tsx +60 -0
  148. package/src/react-native/components/snap-bar-chart.tsx +73 -0
  149. package/src/react-native/components/snap-cell-grid.tsx +150 -0
  150. package/src/react-native/components/snap-icon.tsx +102 -0
  151. package/src/react-native/components/snap-image.tsx +37 -0
  152. package/src/react-native/components/snap-input.tsx +58 -0
  153. package/src/react-native/components/snap-item-group.tsx +43 -0
  154. package/src/react-native/components/snap-item.tsx +66 -0
  155. package/src/react-native/components/snap-progress.tsx +40 -0
  156. package/src/react-native/components/snap-separator.tsx +32 -0
  157. package/src/react-native/components/snap-slider.tsx +85 -0
  158. package/src/react-native/components/snap-stack.tsx +66 -0
  159. package/src/react-native/components/snap-switch.tsx +46 -0
  160. package/src/react-native/components/snap-text.tsx +51 -0
  161. package/src/react-native/components/snap-toggle-group.tsx +127 -0
  162. package/src/react-native/confetti-overlay.tsx +134 -0
  163. package/src/react-native/index.tsx +83 -0
  164. package/src/react-native/snap-view-core.tsx +212 -0
  165. package/src/react-native/theme.tsx +85 -0
  166. package/src/react-native/types.ts +38 -0
  167. package/src/react-native/use-snap-palette.ts +64 -0
  168. package/src/react-native/v1/snap-view.tsx +229 -0
  169. package/src/react-native/v2/snap-view.tsx +283 -0
  170. package/src/schemas.ts +68 -17
  171. package/src/server/parseRequest.ts +68 -9
  172. package/src/server/verify.ts +2 -0
  173. package/src/ui/README.md +8 -8
  174. package/src/ui/badge.ts +2 -0
  175. package/src/ui/bar-chart.ts +38 -0
  176. package/src/ui/button.ts +1 -1
  177. package/src/ui/catalog.ts +19 -3
  178. package/src/ui/cell-grid.ts +49 -0
  179. package/src/ui/image.ts +1 -1
  180. package/src/ui/index.ts +6 -0
  181. package/src/ui/item.ts +1 -1
  182. package/src/ui/schema.ts +2 -2
  183. package/src/ui/slider.ts +2 -0
  184. package/src/ui/text.ts +2 -2
  185. package/src/validator.ts +251 -2
  186. package/dist/dataStore.d.ts +0 -12
  187. package/dist/dataStore.js +0 -35
  188. package/dist/middleware.d.ts +0 -3
  189. package/dist/middleware.js +0 -3
  190. package/dist/react/hooks/use-snap-accent.d.ts +0 -13
  191. package/dist/react/hooks/use-snap-accent.js +0 -32
  192. package/src/dataStore.ts +0 -62
  193. package/src/middleware.ts +0 -7
  194. package/src/react/hooks/use-snap-accent.ts +0 -45
@@ -0,0 +1,283 @@
1
+ import type { ReactNode } from "react";
2
+ import { useEffect, useMemo, useState } from "react";
3
+ import { Platform, StyleSheet, Text, View } from "react-native";
4
+ import { SnapThemeProvider, useSnapTheme, type SnapNativeColors } from "../theme";
5
+ import { SnapViewCoreInner } from "../snap-view-core";
6
+ import {
7
+ validateSnapResponse,
8
+ type ValidationResult,
9
+ } from "@farcaster/snap";
10
+ import type { SnapPage, SnapActionHandlers } from "../types";
11
+
12
+ // ─── Constants ───────────────────────────────────────
13
+
14
+ const SNAP_MAX_HEIGHT = 500;
15
+ const SNAP_WARNING_HEIGHT = 700;
16
+
17
+ // ─── Validation fallback ─────────────────────────────
18
+
19
+ function SnapValidationFallback({ message }: { message?: string }) {
20
+ const { colors } = useSnapTheme();
21
+ return (
22
+ <View style={fallbackStyles.container}>
23
+ <Text style={[fallbackStyles.text, { color: colors.textSecondary }]}>
24
+ {message ? `Unable to render snap: ${message}` : "Unable to render snap"}
25
+ </Text>
26
+ </View>
27
+ );
28
+ }
29
+
30
+ const fallbackStyles = StyleSheet.create({
31
+ container: {
32
+ width: "100%",
33
+ padding: 16,
34
+ alignItems: "center",
35
+ justifyContent: "center",
36
+ },
37
+ text: {
38
+ fontSize: 14,
39
+ },
40
+ });
41
+
42
+ // ─── SnapViewV2 (with validation) ────────────────────
43
+
44
+ export function SnapViewV2Inner({
45
+ snap,
46
+ handlers,
47
+ loading = false,
48
+ onValidationError,
49
+ validationErrorFallback,
50
+ }: {
51
+ snap: SnapPage;
52
+ handlers: SnapActionHandlers;
53
+ loading?: boolean;
54
+ onValidationError?: (result: ValidationResult) => void;
55
+ validationErrorFallback?: ReactNode;
56
+ }) {
57
+ const validation = useMemo(() => validateSnapResponse(snap), [snap]);
58
+ const valid = validation.valid;
59
+ const validationMessage = validation.issues[0]?.message;
60
+
61
+ useEffect(() => {
62
+ if (!valid) {
63
+ if (onValidationError) {
64
+ onValidationError(validation);
65
+ } else {
66
+ // eslint-disable-next-line no-console
67
+ console.warn("[Snap] validation issues:", validation.issues);
68
+ }
69
+ }
70
+ }, [valid, validation, onValidationError]);
71
+
72
+ if (!valid) {
73
+ if (validationErrorFallback === null) return null;
74
+ return (
75
+ <>{validationErrorFallback ?? <SnapValidationFallback message={validationMessage} />}</>
76
+ );
77
+ }
78
+
79
+ return (
80
+ <SnapViewCoreInner snap={snap} handlers={handlers} loading={loading} />
81
+ );
82
+ }
83
+
84
+ export function SnapViewV2({
85
+ snap,
86
+ handlers,
87
+ loading = false,
88
+ appearance = "dark",
89
+ colors,
90
+ onValidationError,
91
+ validationErrorFallback,
92
+ }: {
93
+ snap: SnapPage;
94
+ handlers: SnapActionHandlers;
95
+ loading?: boolean;
96
+ appearance?: "light" | "dark";
97
+ colors?: Partial<SnapNativeColors>;
98
+ onValidationError?: (result: ValidationResult) => void;
99
+ validationErrorFallback?: ReactNode;
100
+ }) {
101
+ return (
102
+ <SnapThemeProvider appearance={appearance} colors={colors}>
103
+ <SnapViewV2Inner
104
+ snap={snap}
105
+ handlers={handlers}
106
+ loading={loading}
107
+ onValidationError={onValidationError}
108
+ validationErrorFallback={validationErrorFallback}
109
+ />
110
+ </SnapThemeProvider>
111
+ );
112
+ }
113
+
114
+ // ─── SnapCardV2 (card frame + height limits) ─────────
115
+
116
+ function SnapCardV2Inner({
117
+ snap,
118
+ handlers,
119
+ loading,
120
+ borderRadius,
121
+ showOverflowWarning,
122
+ onValidationError,
123
+ validationErrorFallback,
124
+ actionError,
125
+ appearance,
126
+ plain,
127
+ }: {
128
+ snap: SnapPage;
129
+ handlers: SnapActionHandlers;
130
+ loading?: boolean;
131
+ borderRadius: number;
132
+ showOverflowWarning: boolean;
133
+ onValidationError?: (result: ValidationResult) => void;
134
+ validationErrorFallback?: ReactNode;
135
+ actionError?: string | null;
136
+ appearance: "light" | "dark";
137
+ plain: boolean;
138
+ }) {
139
+ const { colors } = useSnapTheme();
140
+ const [contentHeight, setContentHeight] = useState(0);
141
+
142
+ const content = (
143
+ <SnapViewV2Inner
144
+ snap={snap}
145
+ handlers={handlers}
146
+ loading={loading}
147
+ onValidationError={onValidationError}
148
+ validationErrorFallback={validationErrorFallback}
149
+ />
150
+ );
151
+
152
+ if (plain) {
153
+ return content;
154
+ }
155
+
156
+ const overflowAmount = showOverflowWarning ? contentHeight - SNAP_MAX_HEIGHT : 0;
157
+
158
+ return (
159
+ <>
160
+ <View
161
+ style={{
162
+ borderRadius,
163
+ borderWidth: 1,
164
+ borderColor: colors.border,
165
+ backgroundColor: colors.surface,
166
+ maxHeight: showOverflowWarning ? undefined : SNAP_MAX_HEIGHT,
167
+ overflow: "hidden",
168
+ minHeight: 120,
169
+ }}
170
+ >
171
+ <View
172
+ collapsable={false}
173
+ onLayout={(e) => setContentHeight(Math.round(e.nativeEvent.layout.height))}
174
+ style={{ paddingHorizontal: 16, paddingVertical: 16 }}
175
+ >
176
+ {content}
177
+ </View>
178
+ {showOverflowWarning && contentHeight > SNAP_MAX_HEIGHT && (
179
+ <View style={{ position: "absolute", top: SNAP_MAX_HEIGHT, left: 0, right: 0, height: overflowAmount, zIndex: 10, pointerEvents: "none" }}>
180
+ <View style={{ height: 1, borderTopWidth: 1, borderStyle: "dashed", borderColor: "rgba(255,100,100,0.6)" }} />
181
+ <View style={{ position: "absolute", top: -10, right: 4, backgroundColor: "rgba(0,0,0,0.7)", paddingHorizontal: 4, paddingVertical: 1, borderRadius: 3 }}>
182
+ <Text style={{ fontSize: 10, color: "rgba(255,100,100,0.7)", fontFamily: Platform.select({ ios: "Menlo", default: "monospace" }) }}>{SNAP_MAX_HEIGHT}px</Text>
183
+ </View>
184
+ <View style={{ flex: 1, backgroundColor: "rgba(255,50,50,0.15)" }} />
185
+ </View>
186
+ )}
187
+ </View>
188
+ {actionError && (
189
+ <Text
190
+ style={{
191
+ paddingHorizontal: 12,
192
+ paddingVertical: 8,
193
+ fontSize: 13,
194
+ color:
195
+ appearance === "dark"
196
+ ? "rgba(255,100,100,0.9)"
197
+ : "rgba(200,0,0,0.8)",
198
+ }}
199
+ >
200
+ {actionError}
201
+ </Text>
202
+ )}
203
+ </>
204
+ );
205
+ }
206
+
207
+ export function SnapCardV2({
208
+ snap,
209
+ handlers,
210
+ loading = false,
211
+ appearance = "dark",
212
+ colors,
213
+ borderRadius = 16,
214
+ showOverflowWarning = false,
215
+ onValidationError,
216
+ validationErrorFallback,
217
+ actionError,
218
+ plain = false,
219
+ }: {
220
+ snap: SnapPage;
221
+ handlers: SnapActionHandlers;
222
+ loading?: boolean;
223
+ appearance?: "light" | "dark";
224
+ colors?: Partial<SnapNativeColors>;
225
+ borderRadius?: number;
226
+ showOverflowWarning?: boolean;
227
+ onValidationError?: (result: ValidationResult) => void;
228
+ validationErrorFallback?: ReactNode;
229
+ actionError?: string | null;
230
+ plain?: boolean;
231
+ }) {
232
+ return (
233
+ <SnapThemeProvider appearance={appearance} colors={colors}>
234
+ <SnapCardV2Inner
235
+ snap={snap}
236
+ handlers={handlers}
237
+ loading={loading}
238
+ borderRadius={borderRadius}
239
+ showOverflowWarning={showOverflowWarning}
240
+ onValidationError={onValidationError}
241
+ validationErrorFallback={validationErrorFallback}
242
+ actionError={actionError}
243
+ appearance={appearance}
244
+ plain={plain}
245
+ />
246
+ </SnapThemeProvider>
247
+ );
248
+ }
249
+
250
+ const cardStyles = StyleSheet.create({
251
+ frameRing: { alignSelf: "stretch" },
252
+ card: { borderWidth: 1, minHeight: 120, overflow: "hidden" },
253
+ body: { paddingHorizontal: 16, paddingVertical: 16 },
254
+ actionError: { paddingHorizontal: 12, paddingVertical: 8, fontSize: 13 },
255
+ warningOverlay: {
256
+ position: "absolute",
257
+ top: SNAP_MAX_HEIGHT,
258
+ left: 0,
259
+ right: 0,
260
+ bottom: 0,
261
+ zIndex: 10,
262
+ },
263
+ warningLine: {
264
+ height: 1,
265
+ borderTopWidth: 1,
266
+ borderStyle: "dashed",
267
+ borderColor: "rgba(255,100,100,0.6)",
268
+ },
269
+ warningLabel: {
270
+ position: "absolute",
271
+ top: -10,
272
+ right: 4,
273
+ backgroundColor: "rgba(0,0,0,0.7)",
274
+ paddingHorizontal: 4,
275
+ paddingVertical: 1,
276
+ borderRadius: 3,
277
+ },
278
+ warningLabelText: {
279
+ fontSize: 10,
280
+ color: "rgba(255,100,100,0.7)",
281
+ fontFamily: Platform.select({ ios: "Menlo", default: "monospace" }),
282
+ },
283
+ });
package/src/schemas.ts CHANGED
@@ -2,13 +2,10 @@ import { z } from "zod";
2
2
  import type { Spec } from "@json-render/core";
3
3
  import {
4
4
  EFFECT_VALUES,
5
- SPEC_VERSION,
5
+ SUPPORTED_SPEC_VERSIONS,
6
+ type SpecVersion,
6
7
  } from "./constants";
7
- import {
8
- DEFAULT_THEME_ACCENT,
9
- PALETTE_COLOR_VALUES,
10
- } from "./colors";
11
- import { type SnapDataStore } from "./dataStore";
8
+ import { DEFAULT_THEME_ACCENT, PALETTE_COLOR_VALUES } from "./colors";
12
9
 
13
10
  // ─── Theme ─────────────────────────────────────────────
14
11
 
@@ -28,7 +25,7 @@ const themeSchema = z
28
25
 
29
26
  export const snapResponseSchema = z
30
27
  .object({
31
- version: z.literal(SPEC_VERSION),
28
+ version: z.enum(SUPPORTED_SPEC_VERSIONS),
32
29
  theme: themeSchema.optional().default({ accent: DEFAULT_THEME_ACCENT }),
33
30
  effects: z.array(z.enum(EFFECT_VALUES)).optional(),
34
31
  ui: z.custom<Spec>(
@@ -43,7 +40,41 @@ export const snapResponseSchema = z
43
40
  .strict();
44
41
 
45
42
  export type SnapResponse = z.infer<typeof snapResponseSchema>;
46
- export type SnapHandlerResult = z.input<typeof snapResponseSchema>;
43
+
44
+ /**
45
+ * Permissive element input type for snap handler authors.
46
+ * Allows dynamic element construction without requiring exact UIElement types.
47
+ */
48
+ export type SnapElementInput = {
49
+ type: string;
50
+ props?: Record<string, unknown>;
51
+ children?: string[];
52
+ on?: Record<string, unknown>;
53
+ [key: string]: unknown;
54
+ };
55
+
56
+ /**
57
+ * Permissive input type for the `ui` field in snap handler return values.
58
+ * Accepts dynamically-built element maps (e.g. `Record<string, SnapElementInput>`)
59
+ * without requiring exact UIElement types.
60
+ */
61
+ export type SnapSpecInput = {
62
+ root: string;
63
+ elements: Record<string, SnapElementInput>;
64
+ state?: Record<string, unknown>;
65
+ };
66
+
67
+ /**
68
+ * Return type for snap handler functions.
69
+ * Uses permissive input types so handlers can build elements dynamically
70
+ * without type casts. Runtime validation via the Zod schema still catches invalid shapes.
71
+ */
72
+ export type SnapHandlerResult = {
73
+ version: SpecVersion;
74
+ theme?: { accent?: z.input<typeof themeAccentSchema> };
75
+ effects?: z.input<typeof snapResponseSchema>["effects"];
76
+ ui: SnapSpecInput;
77
+ };
47
78
 
48
79
  // ─── POST payload ──────────────────────────────────────
49
80
 
@@ -54,14 +85,37 @@ const postInputValueSchema = z.union([
54
85
  z.array(z.string()),
55
86
  ]);
56
87
 
88
+ const standaloneSurfaceSchema = z.object({
89
+ type: z.literal("standalone"),
90
+ });
91
+
92
+ const castSurfaceSchema = z.object({
93
+ type: z.literal("cast"),
94
+ cast: z.object({
95
+ hash: z.string(),
96
+ author: z.object({
97
+ fid: z.number().int().nonnegative(),
98
+ }),
99
+ }),
100
+ });
101
+
102
+ const surfaceSchema = z.discriminatedUnion("type", [
103
+ castSurfaceSchema,
104
+ standaloneSurfaceSchema,
105
+ ]);
106
+
57
107
  export const payloadSchema = z
58
108
  .object({
59
- fid: z.number().int().nonnegative(),
109
+ fid: z.number().int().nonnegative().optional(), // deprecated in favor of user.fid
60
110
  inputs: z.record(z.string(), postInputValueSchema).default({}),
61
- button_index: z.number().int().nonnegative(),
62
111
  timestamp: z.number().int(),
112
+ audience: z.string(),
113
+ user: z.object({
114
+ fid: z.number().int().nonnegative(),
115
+ }),
116
+ surface: surfaceSchema,
63
117
  })
64
- .strict();
118
+ .strip();
65
119
 
66
120
  export type SnapPayload = z.infer<typeof payloadSchema>;
67
121
 
@@ -74,11 +128,9 @@ const snapGetActionSchema = z.object({
74
128
 
75
129
  export type SnapGetAction = z.infer<typeof snapGetActionSchema>;
76
130
 
77
- const snapPostActionSchema = payloadSchema
78
- .extend({
79
- type: z.literal(ACTION_TYPE_POST),
80
- })
81
- .strict();
131
+ const snapPostActionSchema = payloadSchema.extend({
132
+ type: z.literal(ACTION_TYPE_POST),
133
+ });
82
134
 
83
135
  export type SnapPostAction = z.infer<typeof snapPostActionSchema>;
84
136
 
@@ -92,7 +144,6 @@ export type SnapAction = z.infer<typeof snapActionSchema>;
92
144
  export type SnapContext = {
93
145
  action: SnapAction;
94
146
  request: Request;
95
- data: SnapDataStore;
96
147
  };
97
148
 
98
149
  export type SnapFunction = (ctx: SnapContext) => Promise<SnapHandlerResult>;
@@ -29,6 +29,14 @@ export type ParseRequestError =
29
29
  | {
30
30
  type: "signature";
31
31
  message: string;
32
+ }
33
+ | {
34
+ type: "origin_mismatch";
35
+ message: string;
36
+ }
37
+ | {
38
+ type: "fid_mismatch";
39
+ message: string;
32
40
  };
33
41
 
34
42
  export type ParseRequestOptions = {
@@ -43,6 +51,11 @@ export type ParseRequestOptions = {
43
51
  * potential replays. Defaults to 300 (5 minutes) when not provided.
44
52
  */
45
53
  maxSkewSeconds?: number;
54
+
55
+ /**
56
+ * The origin of the request. Derived from the request when not provided.
57
+ */
58
+ requestOrigin?: string;
46
59
  };
47
60
 
48
61
  export type ParseRequestResult =
@@ -107,6 +120,18 @@ export async function parseRequest(
107
120
  };
108
121
  }
109
122
 
123
+ const payloadParsed = payloadSchema.safeParse(
124
+ decodePayload(parsed.data.payload),
125
+ );
126
+ if (!payloadParsed.success) {
127
+ return {
128
+ success: false,
129
+ error: { type: "validation", issues: payloadParsed.error.issues },
130
+ };
131
+ }
132
+
133
+ const body = payloadParsed.data;
134
+
110
135
  if (!options.skipJFSVerification) {
111
136
  const jfs = await verifyJFSRequestBody(parsed.data);
112
137
  if (!jfs.valid) {
@@ -115,28 +140,62 @@ export async function parseRequest(
115
140
  error: { type: "signature", message: jfs.error.message },
116
141
  };
117
142
  }
143
+ if (jfs.signingUserFid !== body.user.fid) {
144
+ return {
145
+ success: false,
146
+ error: {
147
+ type: "fid_mismatch",
148
+ message: `JFS header fid "${jfs.signingUserFid}" does not match user.fid "${body.user.fid}"`,
149
+ },
150
+ };
151
+ }
118
152
  }
119
153
 
120
- const payloadParsed = payloadSchema.safeParse(
121
- decodePayload(parsed.data.payload),
122
- );
123
- if (!payloadParsed.success) {
154
+ if (Math.abs(nowSec - body.timestamp) > maxSkew) {
124
155
  return {
125
156
  success: false,
126
- error: { type: "validation", issues: payloadParsed.error.issues },
157
+ error: {
158
+ type: "replay",
159
+ message: `timestamp outside allowed skew of ${maxSkew}s`,
160
+ },
127
161
  };
128
162
  }
129
163
 
130
- const body = payloadParsed.data;
131
- if (Math.abs(nowSec - body.timestamp) > maxSkew) {
164
+ // Audience validation: ensure the payload audience matches the server origin.
165
+ let expectedOrigin = options.requestOrigin;
166
+ if (expectedOrigin === undefined) {
167
+ try {
168
+ const url = new URL(request.url);
169
+ const proto =
170
+ request.headers.get("x-forwarded-proto") ??
171
+ url.protocol.replace(":", "");
172
+ const host = request.headers.get("x-forwarded-host") ?? url.host;
173
+ expectedOrigin = `${proto}://${host}`;
174
+ } catch {
175
+ // do nothing
176
+ }
177
+ }
178
+
179
+ if (expectedOrigin !== undefined && body.audience !== expectedOrigin) {
132
180
  return {
133
181
  success: false,
134
182
  error: {
135
- type: "replay",
136
- message: `timestamp outside allowed skew of ${maxSkew}s`,
183
+ type: "origin_mismatch",
184
+ message: `payload audience "${body.audience}" does not match expected origin "${expectedOrigin}"`,
185
+ },
186
+ };
187
+ }
188
+
189
+ if (body.fid !== undefined && body.fid !== body.user.fid) {
190
+ return {
191
+ success: false,
192
+ error: {
193
+ type: "fid_mismatch",
194
+ message: `fid "${body.fid}" does not match user.fid "${body.user.fid}"`,
137
195
  },
138
196
  };
139
197
  }
198
+
140
199
  return {
141
200
  success: true,
142
201
  action: {
@@ -27,6 +27,7 @@ export async function verifyJFSRequestBody<TPayload>(
27
27
  }
28
28
  | {
29
29
  valid: true;
30
+ signingUserFid: number; // the FID of the user who signed the request
30
31
  data: TPayload;
31
32
  }
32
33
  > {
@@ -108,6 +109,7 @@ export async function verifyJFSRequestBody<TPayload>(
108
109
  return {
109
110
  valid: true,
110
111
  data: payload,
112
+ signingUserFid: header.fid,
111
113
  };
112
114
  }
113
115
 
package/src/ui/README.md CHANGED
@@ -8,14 +8,14 @@ Snaps use a fixed set of named colors called the **palette**:
8
8
 
9
9
  | Name | Light hex | Dark hex |
10
10
  | -------- | --------- | --------- |
11
- | `gray` | `#8F8F8F` | `#8F8F8F` |
12
- | `blue` | `#006BFF` | `#006FFE` |
13
- | `red` | `#FC0036` | `#F13342` |
14
- | `amber` | `#FFAE00` | `#FFAE00` |
15
- | `green` | `#28A948` | `#00AC3A` |
16
- | `teal` | `#00AC96` | `#00AA96` |
17
- | `purple` | `#8B5CF6` | `#A78BFA` |
18
- | `pink` | `#F32782` | `#F12B82` |
11
+ | `gray` | `#6E6A86` | `#908CAA` |
12
+ | `blue` | `#286983` | `#9CCFD8` |
13
+ | `red` | `#B4637A` | `#EB6F92` |
14
+ | `amber` | `#EA9D34` | `#F6C177` |
15
+ | `green` | `#3E8F8F` | `#56D4A4` |
16
+ | `teal` | `#56949F` | `#3E8FB0` |
17
+ | `purple` | `#907AA9` | `#C4A7E7` |
18
+ | `pink` | `#D7827E` | `#EBBCBA` |
19
19
 
20
20
  These are exported from `@farcaster/snap` as `PALETTE_LIGHT_HEX`, `PALETTE_DARK_HEX`, and the `PaletteColor` type. Clients resolve the correct hex for their current light/dark mode.
21
21
 
package/src/ui/badge.ts CHANGED
@@ -2,10 +2,12 @@ import { z } from "zod";
2
2
  import { PROGRESS_COLOR_VALUES } from "../colors.js";
3
3
  import { ICON_NAMES } from "./icon.js";
4
4
 
5
+ export const BADGE_VARIANTS = ["default", "outline"] as const;
5
6
  export const BADGE_MAX_LABEL_CHARS = 30;
6
7
 
7
8
  export const badgeProps = z.object({
8
9
  label: z.string().min(1).max(BADGE_MAX_LABEL_CHARS),
10
+ variant: z.enum(BADGE_VARIANTS).optional(),
9
11
  color: z.enum(PROGRESS_COLOR_VALUES).optional(),
10
12
  icon: z.enum(ICON_NAMES).optional(),
11
13
  });
@@ -0,0 +1,38 @@
1
+ import { z } from "zod";
2
+ import { BAR_CHART_COLOR_VALUES, PALETTE_COLOR_VALUES } from "../colors.js";
3
+ import {
4
+ BAR_CHART_MAX_BARS,
5
+ BAR_CHART_LABEL_MAX_CHARS,
6
+ } from "../constants.js";
7
+
8
+ export const barChartProps = z
9
+ .object({
10
+ bars: z
11
+ .array(
12
+ z.object({
13
+ label: z.string().min(1).max(BAR_CHART_LABEL_MAX_CHARS),
14
+ value: z.number().nonnegative(),
15
+ color: z.enum(PALETTE_COLOR_VALUES).optional(),
16
+ }),
17
+ )
18
+ .min(1)
19
+ .max(BAR_CHART_MAX_BARS),
20
+ max: z.number().nonnegative().optional(),
21
+ color: z.enum(BAR_CHART_COLOR_VALUES).optional(),
22
+ })
23
+ .superRefine((val, ctx) => {
24
+ if (val.max !== undefined) {
25
+ for (let i = 0; i < val.bars.length; i++) {
26
+ const bar = val.bars[i]!;
27
+ if (bar.value > val.max) {
28
+ ctx.addIssue({
29
+ code: "custom",
30
+ message: `bar value (${bar.value}) exceeds chart max (${val.max})`,
31
+ path: ["bars", i, "value"],
32
+ });
33
+ }
34
+ }
35
+ }
36
+ });
37
+
38
+ export type BarChartProps = z.infer<typeof barChartProps>;
package/src/ui/button.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { ICON_NAMES } from "./icon.js";
3
3
 
4
- export const BUTTON_VARIANTS = ["default", "secondary", "outline", "ghost"] as const;
4
+ export const BUTTON_VARIANTS = ["secondary", "primary"] as const;
5
5
  export const BUTTON_MAX_LABEL_CHARS = 30;
6
6
 
7
7
  export const buttonProps = z.object({
package/src/ui/catalog.ts CHANGED
@@ -15,6 +15,8 @@ import { separatorProps } from "./separator.js";
15
15
  import { sliderProps } from "./slider.js";
16
16
  import { stackProps } from "./stack.js";
17
17
  import { textProps } from "./text.js";
18
+ import { barChartProps } from "./bar-chart.js";
19
+ import { cellGridProps } from "./cell-grid.js";
18
20
 
19
21
  const snapClientParams = z.object({
20
22
  client_action: z.record(z.string(), z.unknown()),
@@ -31,7 +33,7 @@ export const snapJsonRenderCatalog = defineCatalog(snapJsonRenderSchema, {
31
33
  badge: {
32
34
  props: badgeProps,
33
35
  description:
34
- "Inline label — variant: default | secondary | destructive | outline.",
36
+ "Inline label — variant: default (filled) or outline (bordered). Optional color and icon.",
35
37
  },
36
38
  button: {
37
39
  props: buttonProps,
@@ -95,7 +97,17 @@ export const snapJsonRenderCatalog = defineCatalog(snapJsonRenderSchema, {
95
97
  text: {
96
98
  props: textProps,
97
99
  description:
98
- "Text block — size: lg (heading), md (body, default), sm (caption). Optional weight and align.",
100
+ "Text block — size: md (body, default), sm (caption). Optional weight and align.",
101
+ },
102
+ bar_chart: {
103
+ props: barChartProps,
104
+ description:
105
+ "Horizontal bar chart — 1–6 bars with label, value, and optional per-bar color. Optional max and default color.",
106
+ },
107
+ cell_grid: {
108
+ props: cellGridProps,
109
+ description:
110
+ "Cell grid — sparse colored cells on a rows×cols grid. Optional gap and selection mode (taps write to inputs[name]).",
99
111
  },
100
112
  },
101
113
  actions: {
@@ -105,7 +117,11 @@ export const snapJsonRenderCatalog = defineCatalog(snapJsonRenderSchema, {
105
117
  params: z.object({ target: z.string() }),
106
118
  },
107
119
  open_url: {
108
- description: "Open target URL in the system browser.",
120
+ description: "Open external URL in browser.",
121
+ params: z.object({ target: z.string() }),
122
+ },
123
+ open_snap: {
124
+ description: "Open a snap URL inline. The client renders the target as a snap rather than opening a browser.",
109
125
  params: z.object({ target: z.string() }),
110
126
  },
111
127
  open_mini_app: {