@farcaster/snap 1.5.2 → 1.6.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 (153) hide show
  1. package/dist/constants.d.ts +0 -107
  2. package/dist/constants.js +0 -148
  3. package/dist/dataStore.d.ts +12 -0
  4. package/dist/dataStore.js +35 -0
  5. package/dist/index.d.ts +5 -3
  6. package/dist/index.js +4 -3
  7. package/dist/react/accent-context.d.ts +6 -0
  8. package/dist/react/accent-context.js +10 -0
  9. package/dist/react/catalog-renderer.d.ts +5 -0
  10. package/dist/react/catalog-renderer.js +37 -0
  11. package/dist/react/components/action-button.d.ts +6 -0
  12. package/dist/react/components/action-button.js +22 -0
  13. package/dist/react/components/badge.d.ts +5 -0
  14. package/dist/react/components/badge.js +18 -0
  15. package/dist/react/components/icon.d.ts +7 -0
  16. package/dist/react/components/icon.js +60 -0
  17. package/dist/react/components/image.d.ts +5 -0
  18. package/dist/react/components/image.js +15 -0
  19. package/dist/react/components/input.d.ts +5 -0
  20. package/dist/react/components/input.js +18 -0
  21. package/dist/react/components/item-group.d.ts +7 -0
  22. package/dist/react/components/item-group.js +17 -0
  23. package/dist/react/components/item.d.ts +7 -0
  24. package/dist/react/components/item.js +9 -0
  25. package/dist/react/components/progress.d.ts +5 -0
  26. package/dist/react/components/progress.js +11 -0
  27. package/dist/react/components/separator.d.ts +5 -0
  28. package/dist/react/components/separator.js +7 -0
  29. package/dist/react/components/slider.d.ts +5 -0
  30. package/dist/react/components/slider.js +21 -0
  31. package/dist/react/components/stack.d.ts +7 -0
  32. package/dist/react/components/stack.js +32 -0
  33. package/dist/react/components/switch.d.ts +5 -0
  34. package/dist/react/components/switch.js +23 -0
  35. package/dist/react/components/text.d.ts +5 -0
  36. package/dist/react/components/text.js +25 -0
  37. package/dist/react/components/toggle-group.d.ts +5 -0
  38. package/dist/react/components/toggle-group.js +52 -0
  39. package/dist/react/hooks/use-snap-accent.d.ts +13 -0
  40. package/dist/react/hooks/use-snap-accent.js +32 -0
  41. package/dist/react/index.d.ts +47 -0
  42. package/dist/react/index.js +191 -0
  43. package/dist/react/lib/preview-primary-css.d.ts +6 -0
  44. package/dist/react/lib/preview-primary-css.js +43 -0
  45. package/dist/react/lib/resolve-palette-hex.d.ts +2 -0
  46. package/dist/react/lib/resolve-palette-hex.js +10 -0
  47. package/dist/schemas.d.ts +14 -1629
  48. package/dist/schemas.js +14 -526
  49. package/dist/ui/badge.d.ts +52 -0
  50. package/dist/ui/badge.js +9 -0
  51. package/dist/ui/button.d.ts +42 -28
  52. package/dist/ui/button.js +7 -9
  53. package/dist/ui/catalog.d.ts +280 -155
  54. package/dist/ui/catalog.js +102 -83
  55. package/dist/ui/icon.d.ts +56 -0
  56. package/dist/ui/icon.js +51 -0
  57. package/dist/ui/image.d.ts +1 -0
  58. package/dist/ui/image.js +2 -2
  59. package/dist/ui/index.d.ts +20 -22
  60. package/dist/ui/index.js +10 -11
  61. package/dist/ui/input.d.ts +17 -0
  62. package/dist/ui/input.js +13 -0
  63. package/dist/ui/item-group.d.ts +12 -0
  64. package/dist/ui/item-group.js +7 -0
  65. package/dist/ui/item.d.ts +14 -0
  66. package/dist/ui/item.js +9 -0
  67. package/dist/ui/progress.d.ts +1 -11
  68. package/dist/ui/progress.js +21 -4
  69. package/dist/ui/schema.js +3 -3
  70. package/dist/ui/separator.d.ts +9 -0
  71. package/dist/ui/separator.js +5 -0
  72. package/dist/ui/slider.d.ts +4 -3
  73. package/dist/ui/slider.js +34 -5
  74. package/dist/ui/stack.d.ts +22 -1
  75. package/dist/ui/stack.js +8 -1
  76. package/dist/ui/switch.d.ts +8 -0
  77. package/dist/ui/switch.js +7 -0
  78. package/dist/ui/text.d.ts +15 -7
  79. package/dist/ui/text.js +8 -4
  80. package/dist/ui/toggle-group.d.ts +23 -0
  81. package/dist/ui/toggle-group.js +19 -0
  82. package/dist/validator.d.ts +5 -1
  83. package/dist/validator.js +6 -136
  84. package/package.json +72 -52
  85. package/src/constants.ts +0 -179
  86. package/src/dataStore.ts +62 -0
  87. package/src/index.ts +10 -20
  88. package/src/react/accent-context.tsx +29 -0
  89. package/src/react/catalog-renderer.tsx +39 -0
  90. package/src/react/components/action-button.tsx +48 -0
  91. package/src/react/components/badge.tsx +37 -0
  92. package/src/react/components/icon.tsx +115 -0
  93. package/src/react/components/image.tsx +33 -0
  94. package/src/react/components/input.tsx +36 -0
  95. package/src/react/components/item-group.tsx +43 -0
  96. package/src/react/components/item.tsx +33 -0
  97. package/src/react/components/progress.tsx +29 -0
  98. package/src/react/components/separator.tsx +14 -0
  99. package/src/react/components/slider.tsx +43 -0
  100. package/src/react/components/stack.tsx +55 -0
  101. package/src/react/components/switch.tsx +46 -0
  102. package/src/react/components/text.tsx +43 -0
  103. package/src/react/components/toggle-group.tsx +85 -0
  104. package/src/react/hooks/use-snap-accent.ts +45 -0
  105. package/src/react/index.tsx +321 -0
  106. package/src/react/lib/preview-primary-css.ts +57 -0
  107. package/src/react/lib/resolve-palette-hex.ts +20 -0
  108. package/src/schemas.ts +18 -644
  109. package/src/ui/badge.ts +13 -0
  110. package/src/ui/button.ts +9 -12
  111. package/src/ui/catalog.ts +106 -86
  112. package/src/ui/icon.ts +56 -0
  113. package/src/ui/image.ts +3 -2
  114. package/src/ui/index.ts +26 -29
  115. package/src/ui/input.ts +17 -0
  116. package/src/ui/item-group.ts +11 -0
  117. package/src/ui/item.ts +13 -0
  118. package/src/ui/progress.ts +25 -7
  119. package/src/ui/schema.ts +3 -3
  120. package/src/ui/separator.ts +9 -0
  121. package/src/ui/slider.ts +40 -10
  122. package/src/ui/stack.ts +9 -1
  123. package/src/ui/switch.ts +11 -0
  124. package/src/ui/text.ts +9 -4
  125. package/src/ui/toggle-group.ts +23 -0
  126. package/src/validator.ts +6 -176
  127. package/dist/ui/bar-chart.d.ts +0 -30
  128. package/dist/ui/bar-chart.js +0 -15
  129. package/dist/ui/button-group.d.ts +0 -19
  130. package/dist/ui/button-group.js +0 -18
  131. package/dist/ui/divider.d.ts +0 -3
  132. package/dist/ui/divider.js +0 -2
  133. package/dist/ui/grid.d.ts +0 -22
  134. package/dist/ui/grid.js +0 -16
  135. package/dist/ui/group.d.ts +0 -7
  136. package/dist/ui/group.js +0 -5
  137. package/dist/ui/list.d.ts +0 -13
  138. package/dist/ui/list.js +0 -13
  139. package/dist/ui/spacer.d.ts +0 -9
  140. package/dist/ui/spacer.js +0 -5
  141. package/dist/ui/text-input.d.ts +0 -7
  142. package/dist/ui/text-input.js +0 -12
  143. package/dist/ui/toggle.d.ts +0 -7
  144. package/dist/ui/toggle.js +0 -6
  145. package/src/ui/bar-chart.ts +0 -20
  146. package/src/ui/button-group.ts +0 -26
  147. package/src/ui/divider.ts +0 -5
  148. package/src/ui/grid.ts +0 -25
  149. package/src/ui/group.ts +0 -8
  150. package/src/ui/list.ts +0 -17
  151. package/src/ui/spacer.ts +0 -8
  152. package/src/ui/text-input.ts +0 -15
  153. package/src/ui/toggle.ts +0 -9
package/src/schemas.ts CHANGED
@@ -1,64 +1,16 @@
1
1
  import { z } from "zod";
2
+ import type { Spec } from "@json-render/core";
2
3
  import {
3
- BUTTON_ACTION,
4
- BUTTON_ACTION_VALUES,
5
- CLIENT_ACTION,
6
- BUTTON_GROUP_STYLE,
7
- BUTTON_GROUP_STYLE_VALUES,
8
- BUTTON_LAYOUT_VALUES,
9
- BUTTON_STYLE_VALUES,
10
- DEFAULT_BUTTON_LAYOUT,
11
- DEFAULT_GRID_GAP,
12
- DEFAULT_LIST_STYLE,
13
- DEFAULT_SLIDER_STEP,
14
4
  EFFECT_VALUES,
15
- ELEMENT_TYPE,
16
- GRID_CELL_SIZE_VALUES,
17
- GRID_GAP_VALUES,
18
- GROUP_LAYOUT_VALUES,
19
- HEX_COLOR_6_RE,
20
- HTTP_PREFIX,
21
- HTTPS_PREFIX,
22
- IMAGE_ASPECT_VALUES,
23
- INTERACTIVE_ELEMENT_TYPES,
24
- LIMITS,
25
- LIST_STYLE_VALUES,
26
- MEDIA_ELEMENT_TYPES,
27
- PAGE_ROOT_TYPE,
28
- SLIDER_STEP_ALIGN_EPS,
29
- SPACER_SIZE,
30
- SPACER_SIZE_VALUES,
31
5
  SPEC_VERSION,
32
- TEXT_ALIGN_VALUES,
33
- TEXT_CONTENT_MAX,
34
- TEXT_STYLE,
35
- TEXT_STYLE_VALUES,
36
6
  } from "./constants";
37
7
  import {
38
- BAR_CHART_COLOR_VALUES,
39
8
  DEFAULT_THEME_ACCENT,
40
9
  PALETTE_COLOR_VALUES,
41
- PROGRESS_COLOR_VALUES,
42
10
  } from "./colors";
11
+ import { type SnapDataStore } from "./dataStore";
43
12
 
44
- /**
45
- * post/link/mini_app targets must be HTTPS in production; allow HTTP only for
46
- * loopback hosts so local snap servers (e.g. http://localhost:3014/snap) validate.
47
- */
48
- function isSecureOrLoopbackHttpButtonTarget(target: string): boolean {
49
- if (target.startsWith(HTTPS_PREFIX)) return true;
50
- if (!target.startsWith(HTTP_PREFIX)) return false;
51
- try {
52
- const u = new URL(target);
53
- if (u.protocol !== "http:") return false;
54
- const h = u.hostname.toLowerCase();
55
- return (
56
- h === "localhost" || h === "127.0.0.1" || h === "[::1]" || h === "::1"
57
- );
58
- } catch {
59
- return false;
60
- }
61
- }
13
+ // ─── Theme ─────────────────────────────────────────────
62
14
 
63
15
  const themeAccentSchema = z.enum(PALETTE_COLOR_VALUES, {
64
16
  message: `accent must be a palette color: ${PALETTE_COLOR_VALUES.join(", ")}`,
@@ -70,577 +22,36 @@ const themeSchema = z
70
22
  })
71
23
  .strict();
72
24
 
73
- const imageUrlSchema = z.string().refine((s) => s.startsWith(HTTPS_PREFIX), {
74
- message: "image URL must use HTTPS",
75
- });
76
-
77
- const textAlignSchema = z.enum(TEXT_ALIGN_VALUES);
78
-
79
- const textElementSchema = z
80
- .object({
81
- type: z.literal(ELEMENT_TYPE.text),
82
- style: z.enum(TEXT_STYLE_VALUES),
83
- content: z.string(),
84
- align: textAlignSchema.optional(),
85
- })
86
- .superRefine((val, ctx) => {
87
- const max = TEXT_CONTENT_MAX[val.style];
88
- if (val.content.length > max) {
89
- ctx.addIssue({
90
- code: "custom",
91
- message: `${val.style} text exceeds ${max} character limit (found ${val.content.length})`,
92
- path: ["content"],
93
- });
94
- }
95
- });
96
-
97
- const imageElementSchema = z.object({
98
- type: z.literal(ELEMENT_TYPE.image),
99
- url: imageUrlSchema,
100
- aspect: z.enum(IMAGE_ASPECT_VALUES),
101
- alt: z.string().optional(),
102
- });
103
-
104
- const dividerElementSchema = z.object({
105
- type: z.literal(ELEMENT_TYPE.divider),
106
- });
107
-
108
- const spacerElementSchema = z.object({
109
- type: z.literal(ELEMENT_TYPE.spacer),
110
- size: z.enum(SPACER_SIZE_VALUES).default(SPACER_SIZE.medium),
111
- });
112
-
113
- const progressElementSchema = z
114
- .object({
115
- type: z.literal(ELEMENT_TYPE.progress),
116
- value: z.number(),
117
- max: z.number(),
118
- label: z.string().max(60).optional(),
119
- color: z.enum(PROGRESS_COLOR_VALUES).optional(),
120
- })
121
- .superRefine((val, ctx) => {
122
- const { value, max } = val;
123
- if (!Number.isFinite(max)) {
124
- ctx.addIssue({
125
- code: "custom",
126
- message: "progress max must be a finite number",
127
- path: ["max"],
128
- });
129
- return;
130
- }
131
- if (max <= 0) {
132
- ctx.addIssue({
133
- code: "custom",
134
- message: `progress max must be greater than 0 (received ${max})`,
135
- path: ["max"],
136
- });
137
- return;
138
- }
139
- if (!Number.isFinite(value)) {
140
- ctx.addIssue({
141
- code: "custom",
142
- message: "progress value must be a finite number",
143
- path: ["value"],
144
- });
145
- return;
146
- }
147
- if (value < 0 || value > max) {
148
- ctx.addIssue({
149
- code: "custom",
150
- message: `progress value (${value}) must be between 0 and max (${max})`,
151
- path: ["value"],
152
- });
153
- }
154
- });
155
-
156
- const listItemSchema = z.object({
157
- content: z.string().max(LIMITS.listItemContentMaxChars),
158
- trailing: z.string().max(LIMITS.listItemTrailingMaxChars).optional(),
159
- });
160
-
161
- const listElementSchema = z.object({
162
- type: z.literal(ELEMENT_TYPE.list),
163
- style: z.enum(LIST_STYLE_VALUES).default(DEFAULT_LIST_STYLE),
164
- items: z
165
- .array(listItemSchema)
166
- .min(LIMITS.minListItems)
167
- .max(LIMITS.maxListItems),
168
- });
169
-
170
- const gridCellSchema = z.object({
171
- row: z.number().int().min(0),
172
- col: z.number().int().min(0),
173
- /** Hex background (#RRGGBB); omit for transparent */
174
- color: z
175
- .string()
176
- .regex(HEX_COLOR_6_RE, {
177
- message: "cell color must be a valid 6-digit hex color (#RRGGBB)",
178
- })
179
- .optional(),
180
- content: z.string().optional(),
181
- });
182
-
183
- const gridElementSchema = z
184
- .object({
185
- type: z.literal(ELEMENT_TYPE.grid),
186
- cols: z.number().int().min(LIMITS.minGridCols).max(LIMITS.maxGridCols),
187
- rows: z.number().int().min(LIMITS.minGridRows).max(LIMITS.maxGridRows),
188
- cells: z.array(gridCellSchema),
189
- cellSize: z.enum(GRID_CELL_SIZE_VALUES).optional(),
190
- gap: z.enum(GRID_GAP_VALUES).default(DEFAULT_GRID_GAP),
191
- interactive: z.boolean().optional(),
192
- })
193
- .superRefine((val, ctx) => {
194
- const { cols, rows, cells } = val;
195
- for (let i = 0; i < cells.length; i++) {
196
- const c = cells[i]!;
197
- const base = ["cells", i] as const;
198
- if (c.row < 0 || c.row >= rows) {
199
- ctx.addIssue({
200
- code: "custom",
201
- message: `grid cell row ${c.row} is out of bounds (expected 0 to ${
202
- rows - 1
203
- })`,
204
- path: [...base, "row"],
205
- });
206
- }
207
- if (c.col < 0 || c.col >= cols) {
208
- ctx.addIssue({
209
- code: "custom",
210
- message: `grid cell col ${c.col} is out of bounds (expected 0 to ${
211
- cols - 1
212
- })`,
213
- path: [...base, "col"],
214
- });
215
- }
216
- }
217
- });
218
-
219
- const textInputElementSchema = z.object({
220
- type: z.literal(ELEMENT_TYPE.text_input),
221
- name: z.string().min(1),
222
- placeholder: z.string().max(60).optional(),
223
- maxLength: z.number().int().min(1).max(LIMITS.maxTextInputChars).optional(),
224
- });
225
-
226
- const sliderElementSchema = z
227
- .object({
228
- type: z.literal(ELEMENT_TYPE.slider),
229
- name: z.string().min(1),
230
- min: z.number(),
231
- max: z.number(),
232
- step: z.number().default(DEFAULT_SLIDER_STEP),
233
- value: z.number().optional(),
234
- label: z.string().max(60).optional(),
235
- minLabel: z.string().max(20).optional(),
236
- maxLabel: z.string().max(20).optional(),
237
- })
238
- .superRefine((val, ctx) => {
239
- const { min, max, step, value } = val;
240
- if (min > max) {
241
- ctx.addIssue({
242
- code: "custom",
243
- message: `slider min (${min}) must be less than or equal to max (${max})`,
244
- path: ["min"],
245
- });
246
- return;
247
- }
248
- if (step !== undefined) {
249
- if (step <= 0 || !Number.isFinite(step)) {
250
- ctx.addIssue({
251
- code: "custom",
252
- message: "slider step must be a finite number greater than 0",
253
- path: ["step"],
254
- });
255
- return;
256
- }
257
- }
258
- if (value !== undefined) {
259
- if (!Number.isFinite(value)) {
260
- ctx.addIssue({
261
- code: "custom",
262
- message: "slider value must be a finite number",
263
- path: ["value"],
264
- });
265
- return;
266
- }
267
- if (value < min || value > max) {
268
- ctx.addIssue({
269
- code: "custom",
270
- message: `slider value (${value}) must be between min (${min}) and max (${max})`,
271
- path: ["value"],
272
- });
273
- return;
274
- }
275
- if (step !== undefined && max > min) {
276
- const delta = value - min;
277
- const steps = delta / step;
278
- const rounded = Math.round(steps);
279
- if (Math.abs(steps - rounded) > SLIDER_STEP_ALIGN_EPS) {
280
- ctx.addIssue({
281
- code: "custom",
282
- message: `slider value (${value}) is not reachable from min (${min}) with step (${step})`,
283
- path: ["value"],
284
- });
285
- }
286
- }
287
- }
288
- })
289
- .transform((val) => ({
290
- ...val,
291
- value: val.value ?? (val.min + val.max) / 2,
292
- }));
293
-
294
- const buttonGroupElementSchema = z
295
- .object({
296
- type: z.literal(ELEMENT_TYPE.button_group),
297
- name: z.string().min(1),
298
- options: z
299
- .array(z.string().max(LIMITS.maxButtonGroupOptionChars))
300
- .min(LIMITS.minButtonGroupOptions)
301
- .max(LIMITS.maxButtonGroupOptions),
302
- style: z.enum(BUTTON_GROUP_STYLE_VALUES).optional(),
303
- })
304
- .transform((val) => ({
305
- ...val,
306
- style:
307
- val.style ??
308
- (val.options.length <= 3
309
- ? BUTTON_GROUP_STYLE.row
310
- : BUTTON_GROUP_STYLE.stack),
311
- }));
312
-
313
- const toggleElementSchema = z.object({
314
- type: z.literal(ELEMENT_TYPE.toggle),
315
- name: z.string().min(1),
316
- label: z.string().max(60),
317
- value: z.boolean().default(false),
318
- });
319
-
320
- const barChartBarSchema = z.object({
321
- label: z.string().max(LIMITS.barChartLabelMaxChars),
322
- value: z.number().nonnegative(),
323
- color: z.enum(PALETTE_COLOR_VALUES).optional(),
324
- });
325
-
326
- const barChartElementSchema = z
327
- .object({
328
- type: z.literal(ELEMENT_TYPE.bar_chart),
329
- bars: z.array(barChartBarSchema).min(1).max(LIMITS.maxBarChartBars),
330
- max: z.number().nonnegative().optional(),
331
- color: z.enum(BAR_CHART_COLOR_VALUES).optional(),
332
- })
333
- .superRefine((val, ctx) => {
334
- if (val.max !== undefined) {
335
- for (let i = 0; i < val.bars.length; i++) {
336
- const bar = val.bars[i]!;
337
- if (bar.value > val.max) {
338
- ctx.addIssue({
339
- code: "custom",
340
- message: `bar value (${bar.value}) exceeds chart max (${val.max})`,
341
- path: ["bars", i, "value"],
342
- });
343
- }
344
- }
345
- }
346
- });
347
-
348
- const buttonActionSchema = z.enum(BUTTON_ACTION_VALUES);
349
-
350
- const buttonStyleSchema = z.enum(BUTTON_STYLE_VALUES);
351
-
352
- /* ------------------------------------------------------------------ */
353
- /* Client action schemas */
354
- /* ------------------------------------------------------------------ */
355
-
356
- const viewCastClientActionSchema = z
357
- .object({
358
- type: z.literal(CLIENT_ACTION.view_cast),
359
- hash: z.string().min(1),
360
- })
361
- .strict();
362
-
363
- const viewProfileClientActionSchema = z
364
- .object({
365
- type: z.literal(CLIENT_ACTION.view_profile),
366
- fid: z.number().int().nonnegative(),
367
- })
368
- .strict();
369
-
370
- const composeCastClientActionSchema = z
371
- .object({
372
- type: z.literal(CLIENT_ACTION.compose_cast),
373
- text: z.string().optional(),
374
- embeds: z
375
- .array(z.string())
376
- .max(2, { message: "compose_cast embeds: max 2 URLs" })
377
- .optional(),
378
- parent: z
379
- .object({
380
- type: z.literal("cast"),
381
- hash: z.string().min(1),
382
- })
383
- .strict()
384
- .optional(),
385
- channelKey: z.string().optional(),
386
- })
387
- .strict();
388
-
389
- const viewTokenClientActionSchema = z
390
- .object({
391
- type: z.literal(CLIENT_ACTION.view_token),
392
- /** CAIP-19 asset ID (e.g. "eip155:8453/erc20:0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913") */
393
- token: z.string().min(1),
394
- })
395
- .strict();
396
-
397
- const sendTokenClientActionSchema = z
398
- .object({
399
- type: z.literal(CLIENT_ACTION.send_token),
400
- /** CAIP-19 asset ID */
401
- token: z.string().optional(),
402
- /** Amount in raw token units (e.g. "1000000" for 1 USDC) */
403
- amount: z.string().optional(),
404
- recipientFid: z.number().int().nonnegative().optional(),
405
- recipientAddress: z.string().optional(),
406
- })
407
- .strict();
408
-
409
- const swapTokenClientActionSchema = z
410
- .object({
411
- type: z.literal(CLIENT_ACTION.swap_token),
412
- /** CAIP-19 asset ID to sell */
413
- sellToken: z.string().optional(),
414
- /** CAIP-19 asset ID to buy */
415
- buyToken: z.string().optional(),
416
- /** Amount in raw token units */
417
- sellAmount: z.string().optional(),
418
- })
419
- .strict();
420
-
421
- export const clientActionSchema = z.discriminatedUnion("type", [
422
- viewCastClientActionSchema,
423
- viewProfileClientActionSchema,
424
- composeCastClientActionSchema,
425
- viewTokenClientActionSchema,
426
- sendTokenClientActionSchema,
427
- swapTokenClientActionSchema,
428
- ]);
429
-
430
- export type ClientAction = z.infer<typeof clientActionSchema>;
431
-
432
- /* ------------------------------------------------------------------ */
433
- /* Button schema */
434
- /* ------------------------------------------------------------------ */
435
-
436
- const buttonSchema = z
437
- .object({
438
- label: z.string().min(1).max(LIMITS.maxButtonLabelChars),
439
- action: buttonActionSchema,
440
- /** URL target for post/link/mini_app buttons */
441
- target: z.string().min(1).optional(),
442
- /** Structured client action for client buttons */
443
- client_action: clientActionSchema.optional(),
444
- style: buttonStyleSchema.optional(),
445
- })
446
- .superRefine((val, ctx) => {
447
- if (val.action === BUTTON_ACTION.client) {
448
- // client buttons require client_action, must not have target
449
- if (val.client_action === undefined) {
450
- ctx.addIssue({
451
- code: "custom",
452
- message: `button with action "client" must include a "client_action" object`,
453
- path: ["client_action"],
454
- });
455
- }
456
- if (val.target !== undefined) {
457
- ctx.addIssue({
458
- code: "custom",
459
- message: `button with action "client" must not include "target"`,
460
- path: ["target"],
461
- });
462
- }
463
- } else {
464
- // post/link/mini_app buttons require target, must not have client_action
465
- if (val.target === undefined) {
466
- ctx.addIssue({
467
- code: "custom",
468
- message: `button with action "${val.action}" must include a "target" URL`,
469
- path: ["target"],
470
- });
471
- }
472
- if (val.client_action !== undefined) {
473
- ctx.addIssue({
474
- code: "custom",
475
- message: `button with action "${val.action}" must not include "client_action"`,
476
- path: ["client_action"],
477
- });
478
- }
479
- if (
480
- val.target &&
481
- (val.action === BUTTON_ACTION.post ||
482
- val.action === BUTTON_ACTION.link ||
483
- val.action === BUTTON_ACTION.mini_app) &&
484
- !isSecureOrLoopbackHttpButtonTarget(val.target)
485
- ) {
486
- ctx.addIssue({
487
- code: "custom",
488
- message: `button target must use HTTPS (or http:// on localhost / 127.0.0.1 for development) for action "${val.action}" (received: ${val.target})`,
489
- path: ["target"],
490
- });
491
- }
492
- }
493
- });
494
-
495
- export type Button = z.infer<typeof buttonSchema>;
496
-
497
- /** Child elements allowed inside `group` (no media, no nested group) */
498
- const groupChildElementSchema = z.discriminatedUnion("type", [
499
- textElementSchema,
500
- dividerElementSchema,
501
- spacerElementSchema,
502
- progressElementSchema,
503
- listElementSchema,
504
- textInputElementSchema,
505
- sliderElementSchema,
506
- buttonGroupElementSchema,
507
- toggleElementSchema,
508
- barChartElementSchema,
509
- ]);
510
-
511
- export type GroupChildElement = z.infer<typeof groupChildElementSchema>;
512
-
513
- const groupElementSchema = z.object({
514
- type: z.literal(ELEMENT_TYPE.group),
515
- layout: z.enum(GROUP_LAYOUT_VALUES),
516
- children: z
517
- .array(groupChildElementSchema)
518
- .min(LIMITS.minGroupChildren)
519
- .max(LIMITS.maxGroupChildren),
520
- });
521
-
522
- /** Any single page element, including media and `group` */
523
- const elementSchema = z.discriminatedUnion("type", [
524
- textElementSchema,
525
- imageElementSchema,
526
- dividerElementSchema,
527
- spacerElementSchema,
528
- progressElementSchema,
529
- listElementSchema,
530
- gridElementSchema,
531
- textInputElementSchema,
532
- sliderElementSchema,
533
- buttonGroupElementSchema,
534
- toggleElementSchema,
535
- groupElementSchema,
536
- barChartElementSchema,
537
- ]);
538
-
539
- export type Element = z.infer<typeof elementSchema>;
540
-
541
- export type SnapPageElementInput = z.input<typeof elementSchema>;
542
-
543
- const elementsSchema = z
544
- .object({
545
- type: z.literal(PAGE_ROOT_TYPE.stack),
546
- children: z
547
- .array(elementSchema)
548
- .min(1, { message: "stack must have at least 1 child element" })
549
- .max(LIMITS.maxElementsPerPage, {
550
- message: `cannot have more than ${LIMITS.maxElementsPerPage} elements`,
551
- }),
552
- })
553
- .strict();
554
-
555
- export type Elements = z.infer<typeof elementsSchema>;
25
+ // ─── Snap response ─────────────────────────────────────
26
+ // `ui` is a json-render Spec — validated by the catalog at runtime,
27
+ // typed here via the json-render Spec type.
556
28
 
557
29
  export const snapResponseSchema = z
558
30
  .object({
559
31
  version: z.literal(SPEC_VERSION),
560
- page: z
561
- .object({
562
- theme: themeSchema.optional().default({ accent: DEFAULT_THEME_ACCENT }),
563
- button_layout: z
564
- .enum(BUTTON_LAYOUT_VALUES)
565
- .default(DEFAULT_BUTTON_LAYOUT),
566
- effects: z.array(z.enum(EFFECT_VALUES)).optional(),
567
- elements: elementsSchema,
568
- buttons: z
569
- .array(buttonSchema)
570
- .max(LIMITS.maxButtonsPerPage, {
571
- message: `cannot have more than ${LIMITS.maxButtonsPerPage} buttons`,
572
- })
573
- .optional(),
574
- })
575
- .strict()
576
- .superRefine((page, ctx) => {
577
- const mediaCount = page.elements.children.filter((el) => {
578
- return MEDIA_ELEMENT_TYPES.includes(el.type);
579
- }).length;
580
- if (mediaCount > 1) {
581
- ctx.addIssue({
582
- code: "custom",
583
- message: `cannot have more than 1 media element (image or grid)`,
584
- path: ["elements", "children"],
585
- });
586
- }
587
- }),
32
+ theme: themeSchema.optional().default({ accent: DEFAULT_THEME_ACCENT }),
33
+ effects: z.array(z.enum(EFFECT_VALUES)).optional(),
34
+ ui: z.custom<Spec>(
35
+ (val) =>
36
+ val != null &&
37
+ typeof val === "object" &&
38
+ "root" in val &&
39
+ "elements" in val,
40
+ { message: "ui must be a json-render Spec with root and elements" },
41
+ ),
588
42
  })
589
43
  .strict();
590
44
 
591
- // canonical snap response type
592
45
  export type SnapResponse = z.infer<typeof snapResponseSchema>;
593
- // what snap handlers may return (keeps optional fields optional)
594
46
  export type SnapHandlerResult = z.input<typeof snapResponseSchema>;
595
47
 
596
- // extra constraints for the first page to make it look nicer
597
- export const firstPageResponseSchema = snapResponseSchema.superRefine(
598
- (response, ctx) => {
599
- const elements = response.page.elements.children;
600
-
601
- const hasTextTitleOrBody = elements.some(
602
- (el) =>
603
- el.type === ELEMENT_TYPE.text &&
604
- (el.style === TEXT_STYLE.title || el.style === TEXT_STYLE.body),
605
- );
606
- if (!hasTextTitleOrBody) {
607
- ctx.addIssue({
608
- code: "custom",
609
- message:
610
- 'first page must have at least one text element with style "title" or "body"',
611
- path: ["page", "elements", "children"],
612
- });
613
- }
614
-
615
- const hasInteractive = elements.some((el) =>
616
- INTERACTIVE_ELEMENT_TYPES.includes(el.type),
617
- );
618
- const hasMedia = elements.some((el) =>
619
- MEDIA_ELEMENT_TYPES.includes(el.type),
620
- );
621
- if (!hasInteractive && !hasMedia) {
622
- ctx.addIssue({
623
- code: "custom",
624
- message:
625
- "first page must have at least one interactive element (button_group, slider, text_input, toggle) or media element (image, grid)",
626
- path: ["page", "elements", "children"],
627
- });
628
- }
629
- },
630
- );
631
-
632
- export type FirstPageResponse = z.infer<typeof firstPageResponseSchema>;
48
+ // ─── POST payload ──────────────────────────────────────
633
49
 
634
50
  const postInputValueSchema = z.union([
635
51
  z.string(),
636
52
  z.number(),
637
53
  z.boolean(),
638
- z
639
- .object({
640
- row: z.number().int().nonnegative(),
641
- col: z.number().int().nonnegative(),
642
- })
643
- .strict(),
54
+ z.array(z.string()),
644
55
  ]);
645
56
 
646
57
  export const payloadSchema = z
@@ -648,7 +59,6 @@ export const payloadSchema = z
648
59
  fid: z.number().int().nonnegative(),
649
60
  inputs: z.record(z.string(), postInputValueSchema).default({}),
650
61
  button_index: z.number().int().nonnegative(),
651
- /** Unix time in seconds (wire format matches spec examples). */
652
62
  timestamp: z.number().int(),
653
63
  })
654
64
  .strict();
@@ -679,42 +89,6 @@ export const snapActionSchema = z.discriminatedUnion("type", [
679
89
 
680
90
  export type SnapAction = z.infer<typeof snapActionSchema>;
681
91
 
682
- export type DataStoreValue =
683
- | string
684
- | number
685
- | boolean
686
- | null
687
- | DataStoreValue[]
688
- | { [key: string]: DataStoreValue };
689
-
690
- export type SnapDataStoreOperations = {
691
- get(key: string): Promise<DataStoreValue | null>;
692
- set(key: string, value: DataStoreValue): Promise<void>;
693
- };
694
-
695
- export type SnapDataStore = SnapDataStoreOperations & {
696
- withLock<T>(fn: (store: SnapDataStoreOperations) => Promise<T>): Promise<T>;
697
- };
698
-
699
- export function createDefaultDataStore(): SnapDataStore {
700
- const err = new Error(
701
- "Data store is not configured. Use withUpstash() from @farcaster/snap-upstash or provide a data store implementation.",
702
- );
703
- return {
704
- get(_key: string): Promise<never> {
705
- return Promise.reject(err);
706
- },
707
- set(_key: string, _value: DataStoreValue): Promise<never> {
708
- return Promise.reject(err);
709
- },
710
- withLock<T>(
711
- _fn: (store: SnapDataStoreOperations) => Promise<T>,
712
- ): Promise<never> {
713
- return Promise.reject(err);
714
- },
715
- };
716
- }
717
-
718
92
  export type SnapContext = {
719
93
  action: SnapAction;
720
94
  request: Request;