@farcaster/snap 2.0.2 → 2.1.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 (38) hide show
  1. package/dist/react/components/cell-grid.d.ts +3 -1
  2. package/dist/react/components/cell-grid.js +8 -4
  3. package/dist/react/index.d.ts +3 -1
  4. package/dist/react/index.js +3 -3
  5. package/dist/react/snap-view-core.d.ts +12 -1
  6. package/dist/react/snap-view-core.js +10 -5
  7. package/dist/react/v1/snap-view.d.ts +7 -2
  8. package/dist/react/v1/snap-view.js +48 -40
  9. package/dist/react/v2/snap-view.d.ts +6 -2
  10. package/dist/react/v2/snap-view.js +98 -33
  11. package/dist/react-native/components/snap-cell-grid.d.ts +1 -1
  12. package/dist/react-native/components/snap-cell-grid.js +10 -4
  13. package/dist/react-native/confetti-overlay.js +33 -36
  14. package/dist/react-native/index.d.ts +3 -1
  15. package/dist/react-native/index.js +3 -3
  16. package/dist/react-native/snap-view-core.d.ts +11 -1
  17. package/dist/react-native/snap-view-core.js +25 -9
  18. package/dist/react-native/v1/snap-view.d.ts +9 -3
  19. package/dist/react-native/v1/snap-view.js +51 -52
  20. package/dist/react-native/v2/snap-view.d.ts +8 -3
  21. package/dist/react-native/v2/snap-view.js +92 -21
  22. package/dist/ui/catalog.js +2 -2
  23. package/dist/validator.js +8 -33
  24. package/llms.txt +26 -3
  25. package/package.json +1 -1
  26. package/src/react/components/cell-grid.tsx +11 -5
  27. package/src/react/index.tsx +5 -0
  28. package/src/react/snap-view-core.tsx +23 -8
  29. package/src/react/v1/snap-view.tsx +84 -55
  30. package/src/react/v2/snap-view.tsx +165 -52
  31. package/src/react-native/components/snap-cell-grid.tsx +11 -4
  32. package/src/react-native/confetti-overlay.tsx +40 -37
  33. package/src/react-native/index.tsx +5 -0
  34. package/src/react-native/snap-view-core.tsx +56 -14
  35. package/src/react-native/v1/snap-view.tsx +71 -47
  36. package/src/react-native/v2/snap-view.tsx +166 -28
  37. package/src/ui/catalog.ts +2 -2
  38. package/src/validator.ts +22 -46
@@ -1,7 +1,11 @@
1
- import { useEffect, useState } from "react";
1
+ import { type ReactNode, useEffect, useState } from "react";
2
2
  import { View, Text, StyleSheet, Pressable } from "react-native";
3
3
  import { SnapThemeProvider, useSnapTheme, type SnapNativeColors } from "../theme";
4
- import { SnapViewCoreInner } from "../snap-view-core";
4
+ import {
5
+ SnapLoadingOverlay,
6
+ SnapViewCoreInner,
7
+ resolveAccentHex,
8
+ } from "../snap-view-core";
5
9
  import type { SnapPage, SnapActionHandlers } from "../types";
6
10
 
7
11
  const SNAP_MAX_HEIGHT = 500;
@@ -12,13 +16,20 @@ export function SnapViewV1Inner({
12
16
  snap,
13
17
  handlers,
14
18
  loading = false,
19
+ loadingOverlay,
15
20
  }: {
16
21
  snap: SnapPage;
17
22
  handlers: SnapActionHandlers;
18
23
  loading?: boolean;
24
+ loadingOverlay?: ReactNode;
19
25
  }) {
20
26
  return (
21
- <SnapViewCoreInner snap={snap} handlers={handlers} loading={loading} />
27
+ <SnapViewCoreInner
28
+ snap={snap}
29
+ handlers={handlers}
30
+ loading={loading}
31
+ loadingOverlay={loadingOverlay}
32
+ />
22
33
  );
23
34
  }
24
35
 
@@ -28,16 +39,24 @@ export function SnapViewV1({
28
39
  loading = false,
29
40
  appearance = "dark",
30
41
  colors,
42
+ loadingOverlay,
31
43
  }: {
32
44
  snap: SnapPage;
33
45
  handlers: SnapActionHandlers;
34
46
  loading?: boolean;
35
47
  appearance?: "light" | "dark";
36
48
  colors?: Partial<SnapNativeColors>;
49
+ /** Custom content rendered while `loading` is true. Pass `null` to render nothing. */
50
+ loadingOverlay?: ReactNode;
37
51
  }) {
38
52
  return (
39
53
  <SnapThemeProvider appearance={appearance} colors={colors}>
40
- <SnapViewV1Inner snap={snap} handlers={handlers} loading={loading} />
54
+ <SnapViewV1Inner
55
+ snap={snap}
56
+ handlers={handlers}
57
+ loading={loading}
58
+ loadingOverlay={loadingOverlay}
59
+ />
41
60
  </SnapThemeProvider>
42
61
  );
43
62
  }
@@ -52,6 +71,7 @@ function SnapCardV1Inner({
52
71
  actionError,
53
72
  appearance,
54
73
  plain,
74
+ loadingOverlay,
55
75
  }: {
56
76
  snap: SnapPage;
57
77
  handlers: SnapActionHandlers;
@@ -60,8 +80,10 @@ function SnapCardV1Inner({
60
80
  actionError?: string | null;
61
81
  appearance: "light" | "dark";
62
82
  plain: boolean;
83
+ loadingOverlay?: ReactNode;
63
84
  }) {
64
- const { colors } = useSnapTheme();
85
+ const { colors, mode } = useSnapTheme();
86
+ const accentHex = resolveAccentHex(snap.theme?.accent, mode);
65
87
  const [contentHeight, setContentHeight] = useState(0);
66
88
  const [isExpanded, setIsExpanded] = useState(false);
67
89
 
@@ -73,6 +95,9 @@ function SnapCardV1Inner({
73
95
  const isExpandable = contentHeight > SNAP_MAX_HEIGHT + 1;
74
96
  const isClipped = isExpandable && !isExpanded;
75
97
 
98
+ const isDark = mode === "dark";
99
+ const pillBg = isDark ? "rgba(40,40,40,0.92)" : "rgba(255,255,255,0.92)";
100
+ const pillBgPressed = isDark ? "rgba(60,60,60,0.95)" : "rgba(240,240,240,0.95)";
76
101
  return (
77
102
  <>
78
103
  <View style={cardStyles.frameRing}>
@@ -107,40 +132,38 @@ function SnapCardV1Inner({
107
132
  snap={snap}
108
133
  handlers={handlers}
109
134
  loading={loading}
135
+ loadingOverlay={null}
110
136
  />
111
137
  </View>
112
138
  </View>
113
- {isExpandable ? (
114
- <View
115
- style={[
116
- cardStyles.expandRow,
117
- plain
118
- ? cardStyles.expandRowPlain
119
- : { borderTopColor: colors.border },
139
+ {loading
140
+ ? loadingOverlay === undefined
141
+ ? <SnapLoadingOverlay appearance={mode} accentHex={accentHex} />
142
+ : loadingOverlay
143
+ : null}
144
+ </View>
145
+ {isExpandable ? (
146
+ <View pointerEvents="box-none" style={cardStyles.expandFloat}>
147
+ <Pressable
148
+ style={({ pressed }) => [
149
+ cardStyles.expandButton,
150
+ {
151
+ backgroundColor: pressed ? pillBgPressed : pillBg,
152
+ borderColor: colors.border,
153
+ },
120
154
  ]}
155
+ onPress={() => {
156
+ setIsExpanded((value) => !value);
157
+ }}
121
158
  >
122
- <Pressable
123
- style={({ pressed }) => [
124
- cardStyles.expandButton,
125
- {
126
- backgroundColor: pressed
127
- ? colors.mutedHover
128
- : colors.muted,
129
- },
130
- ]}
131
- onPress={() => {
132
- setIsExpanded((value) => !value);
133
- }}
159
+ <Text
160
+ style={[cardStyles.expandButtonText, { color: colors.text }]}
134
161
  >
135
- <Text
136
- style={[cardStyles.expandButtonText, { color: colors.text }]}
137
- >
138
- {isExpanded ? "Show less" : "Show more"}
139
- </Text>
140
- </Pressable>
141
- </View>
142
- ) : null}
143
- </View>
162
+ {isExpanded ? "Show less" : "Show more"}
163
+ </Text>
164
+ </Pressable>
165
+ </View>
166
+ ) : null}
144
167
  </View>
145
168
  {actionError && (
146
169
  <Text
@@ -170,6 +193,7 @@ export function SnapCardV1({
170
193
  borderRadius = 16,
171
194
  actionError,
172
195
  plain = false,
196
+ loadingOverlay,
173
197
  }: {
174
198
  snap: SnapPage;
175
199
  handlers: SnapActionHandlers;
@@ -179,6 +203,8 @@ export function SnapCardV1({
179
203
  borderRadius?: number;
180
204
  actionError?: string | null;
181
205
  plain?: boolean;
206
+ /** Custom content rendered while `loading` is true. Pass `null` to render nothing. */
207
+ loadingOverlay?: ReactNode;
182
208
  }) {
183
209
  return (
184
210
  <SnapThemeProvider appearance={appearance} colors={colors}>
@@ -190,6 +216,7 @@ export function SnapCardV1({
190
216
  actionError={actionError}
191
217
  appearance={appearance}
192
218
  plain={plain}
219
+ loadingOverlay={loadingOverlay}
193
220
  />
194
221
  </SnapThemeProvider>
195
222
  );
@@ -199,30 +226,27 @@ const cardStyles = StyleSheet.create({
199
226
  frameRing: { alignSelf: "stretch" },
200
227
  card: { overflow: "hidden", borderWidth: 1, minHeight: 120 },
201
228
  body: { paddingHorizontal: 16, paddingVertical: 16 },
202
- expandRow: {
229
+ expandFloat: {
230
+ position: "absolute",
231
+ left: 0,
232
+ right: 0,
233
+ bottom: -14,
234
+ height: 28,
203
235
  alignItems: "center",
204
- paddingHorizontal: 16,
205
- paddingTop: 10,
206
- paddingBottom: 12,
207
- borderTopWidth: StyleSheet.hairlineWidth,
208
- },
209
- expandRowPlain: {
210
- paddingHorizontal: 0,
211
- paddingTop: 8,
212
- paddingBottom: 0,
213
- borderTopWidth: 0,
236
+ justifyContent: "center",
214
237
  },
215
238
  expandButton: {
216
239
  minWidth: 92,
217
240
  alignItems: "center",
218
241
  justifyContent: "center",
219
242
  borderRadius: 9999,
243
+ borderWidth: 1,
220
244
  paddingHorizontal: 10,
221
- paddingVertical: 6,
245
+ paddingVertical: 4,
222
246
  },
223
247
  expandButtonText: {
224
- fontSize: 13,
225
- lineHeight: 18,
248
+ fontSize: 12,
249
+ lineHeight: 16,
226
250
  fontWeight: "600",
227
251
  },
228
252
  actionError: { paddingHorizontal: 12, paddingVertical: 8, fontSize: 13 },
@@ -1,8 +1,12 @@
1
1
  import type { ReactNode } from "react";
2
2
  import { useEffect, useMemo, useState } from "react";
3
- import { Platform, StyleSheet, Text, View } from "react-native";
3
+ import { Platform, Pressable, StyleSheet, Text, View } from "react-native";
4
4
  import { SnapThemeProvider, useSnapTheme, type SnapNativeColors } from "../theme";
5
- import { SnapViewCoreInner } from "../snap-view-core";
5
+ import {
6
+ SnapLoadingOverlay,
7
+ SnapViewCoreInner,
8
+ resolveAccentHex,
9
+ } from "../snap-view-core";
6
10
  import {
7
11
  validateSnapResponse,
8
12
  type ValidationResult,
@@ -47,12 +51,14 @@ export function SnapViewV2Inner({
47
51
  loading = false,
48
52
  onValidationError,
49
53
  validationErrorFallback,
54
+ loadingOverlay,
50
55
  }: {
51
56
  snap: SnapPage;
52
57
  handlers: SnapActionHandlers;
53
58
  loading?: boolean;
54
59
  onValidationError?: (result: ValidationResult) => void;
55
60
  validationErrorFallback?: ReactNode;
61
+ loadingOverlay?: ReactNode;
56
62
  }) {
57
63
  const validation = useMemo(() => validateSnapResponse(snap), [snap]);
58
64
  const valid = validation.valid;
@@ -77,7 +83,12 @@ export function SnapViewV2Inner({
77
83
  }
78
84
 
79
85
  return (
80
- <SnapViewCoreInner snap={snap} handlers={handlers} loading={loading} />
86
+ <SnapViewCoreInner
87
+ snap={snap}
88
+ handlers={handlers}
89
+ loading={loading}
90
+ loadingOverlay={loadingOverlay}
91
+ />
81
92
  );
82
93
  }
83
94
 
@@ -89,6 +100,7 @@ export function SnapViewV2({
89
100
  colors,
90
101
  onValidationError,
91
102
  validationErrorFallback,
103
+ loadingOverlay,
92
104
  }: {
93
105
  snap: SnapPage;
94
106
  handlers: SnapActionHandlers;
@@ -97,6 +109,8 @@ export function SnapViewV2({
97
109
  colors?: Partial<SnapNativeColors>;
98
110
  onValidationError?: (result: ValidationResult) => void;
99
111
  validationErrorFallback?: ReactNode;
112
+ /** Custom content rendered while `loading` is true. Pass `null` to render nothing. */
113
+ loadingOverlay?: ReactNode;
100
114
  }) {
101
115
  return (
102
116
  <SnapThemeProvider appearance={appearance} colors={colors}>
@@ -106,6 +120,7 @@ export function SnapViewV2({
106
120
  loading={loading}
107
121
  onValidationError={onValidationError}
108
122
  validationErrorFallback={validationErrorFallback}
123
+ loadingOverlay={loadingOverlay}
109
124
  />
110
125
  </SnapThemeProvider>
111
126
  );
@@ -124,6 +139,7 @@ function SnapCardV2Inner({
124
139
  actionError,
125
140
  appearance,
126
141
  plain,
142
+ loadingOverlay,
127
143
  }: {
128
144
  snap: SnapPage;
129
145
  handlers: SnapActionHandlers;
@@ -135,9 +151,20 @@ function SnapCardV2Inner({
135
151
  actionError?: string | null;
136
152
  appearance: "light" | "dark";
137
153
  plain: boolean;
154
+ loadingOverlay?: ReactNode;
138
155
  }) {
139
- const { colors } = useSnapTheme();
156
+ const { colors, mode } = useSnapTheme();
157
+ const accentHex = resolveAccentHex(snap.theme?.accent, mode);
140
158
  const [contentHeight, setContentHeight] = useState(0);
159
+ const [isExpanded, setIsExpanded] = useState(false);
160
+
161
+ useEffect(() => {
162
+ setIsExpanded(false);
163
+ setContentHeight(0);
164
+ }, [snap]);
165
+
166
+ const isExpandable = !showOverflowWarning && contentHeight > SNAP_MAX_HEIGHT + 1;
167
+ const isClipped = isExpandable && !isExpanded;
141
168
 
142
169
  const content = (
143
170
  <SnapViewV2Inner
@@ -146,44 +173,124 @@ function SnapCardV2Inner({
146
173
  loading={loading}
147
174
  onValidationError={onValidationError}
148
175
  validationErrorFallback={validationErrorFallback}
176
+ loadingOverlay={null}
149
177
  />
150
178
  );
151
179
 
152
180
  if (plain) {
153
- return content;
181
+ return (
182
+ <>
183
+ <View style={isClipped ? { maxHeight: SNAP_MAX_HEIGHT, overflow: "hidden" } : undefined}>
184
+ <View
185
+ collapsable={false}
186
+ onLayout={(e) => {
187
+ const nextHeight = Math.round(e.nativeEvent.layout.height);
188
+ setContentHeight((current) =>
189
+ isClipped
190
+ ? Math.max(current, nextHeight)
191
+ : current === nextHeight
192
+ ? current
193
+ : nextHeight,
194
+ );
195
+ }}
196
+ >
197
+ {content}
198
+ </View>
199
+ </View>
200
+ {loading
201
+ ? loadingOverlay === undefined
202
+ ? <SnapLoadingOverlay appearance={mode} accentHex={accentHex} />
203
+ : loadingOverlay
204
+ : null}
205
+ {isExpandable ? (
206
+ <View style={[cardStyles.expandRow, cardStyles.expandRowPlain]}>
207
+ <Pressable
208
+ style={({ pressed }) => [
209
+ cardStyles.expandButton,
210
+ {
211
+ backgroundColor: pressed ? colors.mutedHover : colors.muted,
212
+ },
213
+ ]}
214
+ onPress={() => setIsExpanded((value) => !value)}
215
+ >
216
+ <Text style={[cardStyles.expandButtonText, { color: colors.text }]}>
217
+ {isExpanded ? "Show less" : "Show more"}
218
+ </Text>
219
+ </Pressable>
220
+ </View>
221
+ ) : null}
222
+ </>
223
+ );
154
224
  }
155
225
 
156
226
  const overflowAmount = showOverflowWarning ? contentHeight - SNAP_MAX_HEIGHT : 0;
227
+ const isDark = mode === "dark";
228
+ const pillBg = isDark ? "rgba(40,40,40,0.92)" : "rgba(255,255,255,0.92)";
229
+ const pillBgPressed = isDark ? "rgba(60,60,60,0.95)" : "rgba(240,240,240,0.95)";
157
230
 
158
231
  return (
159
232
  <>
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
- >
233
+ <View style={{ position: "relative" }}>
171
234
  <View
172
- collapsable={false}
173
- onLayout={(e) => setContentHeight(Math.round(e.nativeEvent.layout.height))}
174
- style={{ paddingHorizontal: 16, paddingVertical: 16 }}
235
+ style={{
236
+ borderRadius,
237
+ borderWidth: 1,
238
+ borderColor: colors.border,
239
+ backgroundColor: colors.surface,
240
+ maxHeight: showOverflowWarning ? undefined : isClipped ? SNAP_MAX_HEIGHT : undefined,
241
+ overflow: "hidden",
242
+ minHeight: 120,
243
+ }}
175
244
  >
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>
245
+ <View
246
+ collapsable={false}
247
+ onLayout={(e) => {
248
+ const nextHeight = Math.round(e.nativeEvent.layout.height);
249
+ setContentHeight((current) =>
250
+ isClipped
251
+ ? Math.max(current, nextHeight)
252
+ : current === nextHeight
253
+ ? current
254
+ : nextHeight,
255
+ );
256
+ }}
257
+ style={{ paddingHorizontal: 16, paddingVertical: 16 }}
258
+ >
259
+ {content}
260
+ </View>
261
+ {showOverflowWarning && contentHeight > SNAP_MAX_HEIGHT && (
262
+ <View style={{ position: "absolute", top: SNAP_MAX_HEIGHT, left: 0, right: 0, height: overflowAmount, zIndex: 10, pointerEvents: "none" }}>
263
+ <View style={{ height: 1, borderTopWidth: 1, borderStyle: "dashed", borderColor: "rgba(255,100,100,0.6)" }} />
264
+ <View style={{ position: "absolute", top: -10, right: 4, backgroundColor: "rgba(0,0,0,0.7)", paddingHorizontal: 4, paddingVertical: 1, borderRadius: 3 }}>
265
+ <Text style={{ fontSize: 10, color: "rgba(255,100,100,0.7)", fontFamily: Platform.select({ ios: "Menlo", default: "monospace" }) }}>{SNAP_MAX_HEIGHT}px</Text>
266
+ </View>
267
+ <View style={{ flex: 1, backgroundColor: "rgba(255,50,50,0.15)" }} />
183
268
  </View>
184
- <View style={{ flex: 1, backgroundColor: "rgba(255,50,50,0.15)" }} />
269
+ )}
270
+ {loading
271
+ ? loadingOverlay === undefined
272
+ ? <SnapLoadingOverlay appearance={mode} accentHex={accentHex} />
273
+ : loadingOverlay
274
+ : null}
275
+ </View>
276
+ {isExpandable ? (
277
+ <View pointerEvents="box-none" style={cardStyles.expandFloat}>
278
+ <Pressable
279
+ style={({ pressed }) => [
280
+ cardStyles.expandButton,
281
+ {
282
+ backgroundColor: pressed ? pillBgPressed : pillBg,
283
+ borderColor: colors.border,
284
+ },
285
+ ]}
286
+ onPress={() => setIsExpanded((value) => !value)}
287
+ >
288
+ <Text style={[cardStyles.expandButtonText, { color: colors.text }]}>
289
+ {isExpanded ? "Show less" : "Show more"}
290
+ </Text>
291
+ </Pressable>
185
292
  </View>
186
- )}
293
+ ) : null}
187
294
  </View>
188
295
  {actionError && (
189
296
  <Text
@@ -216,6 +323,7 @@ export function SnapCardV2({
216
323
  validationErrorFallback,
217
324
  actionError,
218
325
  plain = false,
326
+ loadingOverlay,
219
327
  }: {
220
328
  snap: SnapPage;
221
329
  handlers: SnapActionHandlers;
@@ -228,6 +336,8 @@ export function SnapCardV2({
228
336
  validationErrorFallback?: ReactNode;
229
337
  actionError?: string | null;
230
338
  plain?: boolean;
339
+ /** Custom content rendered while `loading` is true. Pass `null` to render nothing. */
340
+ loadingOverlay?: ReactNode;
231
341
  }) {
232
342
  return (
233
343
  <SnapThemeProvider appearance={appearance} colors={colors}>
@@ -242,6 +352,7 @@ export function SnapCardV2({
242
352
  actionError={actionError}
243
353
  appearance={appearance}
244
354
  plain={plain}
355
+ loadingOverlay={loadingOverlay}
245
356
  />
246
357
  </SnapThemeProvider>
247
358
  );
@@ -252,6 +363,33 @@ const cardStyles = StyleSheet.create({
252
363
  card: { borderWidth: 1, minHeight: 120, overflow: "hidden" },
253
364
  body: { paddingHorizontal: 16, paddingVertical: 16 },
254
365
  actionError: { paddingHorizontal: 12, paddingVertical: 8, fontSize: 13 },
366
+ expandFloat: {
367
+ position: "absolute",
368
+ left: 0,
369
+ right: 0,
370
+ bottom: -14,
371
+ height: 28,
372
+ alignItems: "center",
373
+ justifyContent: "center",
374
+ },
375
+ expandRowPlain: {
376
+ paddingTop: 8,
377
+ alignItems: "center",
378
+ },
379
+ expandButton: {
380
+ minWidth: 92,
381
+ alignItems: "center",
382
+ justifyContent: "center",
383
+ borderRadius: 9999,
384
+ borderWidth: 1,
385
+ paddingHorizontal: 10,
386
+ paddingVertical: 4,
387
+ },
388
+ expandButtonText: {
389
+ fontSize: 12,
390
+ lineHeight: 16,
391
+ fontWeight: "600",
392
+ },
255
393
  warningOverlay: {
256
394
  position: "absolute",
257
395
  top: SNAP_MAX_HEIGHT,
package/src/ui/catalog.ts CHANGED
@@ -58,7 +58,7 @@ export const snapJsonRenderCatalog = defineCatalog(snapJsonRenderSchema, {
58
58
  item: {
59
59
  props: itemProps,
60
60
  description:
61
- "Content row with title and optional description. Children render in the actions slot (right side) — use badge, button, or text elements.",
61
+ "Content row with title and optional description. Children render in the actions slot (right side) — badge, button, and icon elements are all valid. The item itself is not interactive, so avoid navigation-style icons (`chevron-right`, `arrow-right`, `external-link`) that imply the row navigates.",
62
62
  },
63
63
  item_group: {
64
64
  props: itemGroupProps,
@@ -107,7 +107,7 @@ export const snapJsonRenderCatalog = defineCatalog(snapJsonRenderSchema, {
107
107
  cell_grid: {
108
108
  props: cellGridProps,
109
109
  description:
110
- "Cell grid — sparse colored cells on a rows×cols grid. Optional gap and selection mode (taps write to inputs[name]).",
110
+ "Cell grid — sparse colored cells on a rows×cols grid. Two interaction modes: leave select 'off' and bind on.press to fire an action per cell press (inputs[name] is the pressed 'row,col' before the action runs); or set select 'single'/'multiple' for press-to-select with a visual ring (no auto-fire — pair with a separate submit button). on.press is ignored when select is on.",
111
111
  },
112
112
  },
113
113
  actions: {
package/src/validator.ts CHANGED
@@ -1,6 +1,12 @@
1
1
  import { z } from "zod";
2
2
  import { snapResponseSchema } from "./schemas";
3
- import { MAX_CHILDREN, MAX_DEPTH, MAX_ELEMENTS, MAX_ROOT_CHILDREN, SPEC_VERSION_1 } from "./constants";
3
+ import {
4
+ MAX_CHILDREN,
5
+ MAX_DEPTH,
6
+ MAX_ELEMENTS,
7
+ MAX_ROOT_CHILDREN,
8
+ SPEC_VERSION_1,
9
+ } from "./constants";
4
10
  import { snapJsonRenderCatalog } from "./ui/catalog.js";
5
11
 
6
12
  export type ValidationResult = {
@@ -18,9 +24,6 @@ const URL_TARGET_ACTIONS = new Set([
18
24
  "open_mini_app",
19
25
  ]);
20
26
 
21
- /** Image file extensions allowed in image URLs. */
22
- const ALLOWED_IMAGE_EXTENSIONS = new Set(["jpg", "jpeg", "png", "gif", "webp"]);
23
-
24
27
  /**
25
28
  * Returns true if the URL is a loopback address (localhost dev exception).
26
29
  */
@@ -48,34 +51,6 @@ function validateUrl(raw: string): string | null {
48
51
  return `URL must use HTTPS (got ${url.protocol.replace(":", "")}): "${raw}"`;
49
52
  }
50
53
 
51
- /**
52
- * Validate an image URL: must pass URL validation + have an allowed extension.
53
- */
54
- function validateImageUrl(raw: string): string | null {
55
- const urlError = validateUrl(raw);
56
- if (urlError) return urlError;
57
-
58
- let url: URL;
59
- try {
60
- url = new URL(raw);
61
- } catch {
62
- return null; // already caught above
63
- }
64
-
65
- const pathname = url.pathname;
66
- const lastDot = pathname.lastIndexOf(".");
67
- if (lastDot === -1) {
68
- return `Image URL must end with a supported extension (${[...ALLOWED_IMAGE_EXTENSIONS].join(", ")}): "${raw}"`;
69
- }
70
-
71
- const ext = pathname.slice(lastDot + 1).toLowerCase();
72
- if (!ALLOWED_IMAGE_EXTENSIONS.has(ext)) {
73
- return `Image URL has unsupported extension ".${ext}" (allowed: ${[...ALLOWED_IMAGE_EXTENSIONS].join(", ")}): "${raw}"`;
74
- }
75
-
76
- return null;
77
- }
78
-
79
54
  // ─── Depth measurement ────────────────────────────────
80
55
 
81
56
  /**
@@ -118,9 +93,10 @@ type ElementShape = {
118
93
  * - Children per element ≤ MAX_CHILDREN
119
94
  * - Nesting depth ≤ MAX_DEPTH
120
95
  */
121
- function validateStructure(
122
- ui: { root: string; elements: Record<string, unknown> },
123
- ): z.core.$ZodIssue[] {
96
+ function validateStructure(ui: {
97
+ root: string;
98
+ elements: Record<string, unknown>;
99
+ }): z.core.$ZodIssue[] {
124
100
  const issues: z.core.$ZodIssue[] = [];
125
101
  const elements = ui.elements as Record<string, ElementShape>;
126
102
 
@@ -173,19 +149,17 @@ function validateStructure(
173
149
 
174
150
  /**
175
151
  * Validate all URLs in the snap:
176
- * - image.url: must be HTTPS with allowed extension
177
- * - action target URLs (submit, open_url, open_snap, open_mini_app): must be HTTPS
152
+ * - image.url: must use HTTPS (or HTTP on loopback for dev)
153
+ * - action target URLs (submit, open_url, open_snap, open_mini_app): must use HTTPS (or HTTP on loopback for dev)
178
154
  */
179
- function validateUrls(
180
- elements: Record<string, unknown>,
181
- ): z.core.$ZodIssue[] {
155
+ function validateUrls(elements: Record<string, unknown>): z.core.$ZodIssue[] {
182
156
  const issues: z.core.$ZodIssue[] = [];
183
157
  const els = elements as Record<string, ElementShape>;
184
158
 
185
159
  for (const [id, el] of Object.entries(els)) {
186
160
  // Validate image URLs
187
161
  if (el.type === "image" && typeof el.props?.url === "string") {
188
- const error = validateImageUrl(el.props.url);
162
+ const error = validateUrl(el.props.url);
189
163
  if (error) {
190
164
  issues.push({
191
165
  code: "custom",
@@ -242,11 +216,13 @@ export function validateSnapResponse(json: unknown): ValidationResult {
242
216
  if (!(ui.root in ui.elements)) {
243
217
  return {
244
218
  valid: false,
245
- issues: [{
246
- code: "custom",
247
- message: `ui.root "${ui.root}" does not exist in ui.elements`,
248
- path: ["ui", "root"],
249
- }],
219
+ issues: [
220
+ {
221
+ code: "custom",
222
+ message: `ui.root "${ui.root}" does not exist in ui.elements`,
223
+ path: ["ui", "root"],
224
+ },
225
+ ],
250
226
  };
251
227
  }
252
228