@farcaster/snap 2.5.1 → 2.6.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 (80) hide show
  1. package/dist/constants.d.ts +1 -0
  2. package/dist/constants.js +1 -0
  3. package/dist/react/catalog-renderer.js +2 -0
  4. package/dist/react/components/action-button.js +9 -1
  5. package/dist/react/components/cell-grid.js +16 -2
  6. package/dist/react/components/image.js +5 -2
  7. package/dist/react/components/paginator.d.ts +7 -0
  8. package/dist/react/components/paginator.js +103 -0
  9. package/dist/react/components/stack.js +21 -18
  10. package/dist/react/components/text.js +13 -1
  11. package/dist/react/snap-version-context.d.ts +3 -0
  12. package/dist/react/snap-version-context.js +7 -0
  13. package/dist/react/snap-view-core.d.ts +1 -1
  14. package/dist/react/snap-view-core.js +27 -4
  15. package/dist/react-native/catalog-renderer.js +2 -0
  16. package/dist/react-native/components/snap-action-button.js +8 -2
  17. package/dist/react-native/components/snap-cell-grid.js +16 -2
  18. package/dist/react-native/components/snap-image.js +29 -4
  19. package/dist/react-native/components/snap-paginator.d.ts +5 -0
  20. package/dist/react-native/components/snap-paginator.js +194 -0
  21. package/dist/react-native/components/snap-text.js +4 -3
  22. package/dist/react-native/expand-state.d.ts +19 -0
  23. package/dist/react-native/expand-state.js +18 -0
  24. package/dist/react-native/index.d.ts +7 -1
  25. package/dist/react-native/index.js +3 -3
  26. package/dist/react-native/snap-version-context.d.ts +3 -0
  27. package/dist/react-native/snap-version-context.js +6 -0
  28. package/dist/react-native/snap-view-core.d.ts +1 -1
  29. package/dist/react-native/snap-view-core.js +27 -4
  30. package/dist/react-native/v1/snap-view.d.ts +7 -1
  31. package/dist/react-native/v1/snap-view.js +35 -11
  32. package/dist/react-native/v2/snap-view.d.ts +7 -1
  33. package/dist/react-native/v2/snap-view.js +60 -17
  34. package/dist/ui/catalog.d.ts +45 -0
  35. package/dist/ui/catalog.js +20 -3
  36. package/dist/ui/cell-grid.d.ts +5 -0
  37. package/dist/ui/cell-grid.js +2 -1
  38. package/dist/ui/image.d.ts +4 -1
  39. package/dist/ui/image.js +3 -1
  40. package/dist/ui/index.d.ts +2 -0
  41. package/dist/ui/index.js +1 -0
  42. package/dist/ui/paginator-state.d.ts +18 -0
  43. package/dist/ui/paginator-state.js +47 -0
  44. package/dist/ui/paginator.d.ts +17 -0
  45. package/dist/ui/paginator.js +8 -0
  46. package/dist/ui/text.d.ts +1 -0
  47. package/dist/ui/text.js +1 -0
  48. package/dist/validator.js +16 -3
  49. package/llms.txt +19 -4
  50. package/package.json +1 -1
  51. package/src/constants.ts +1 -0
  52. package/src/react/catalog-renderer.tsx +2 -0
  53. package/src/react/components/action-button.tsx +13 -2
  54. package/src/react/components/cell-grid.tsx +22 -2
  55. package/src/react/components/image.tsx +17 -0
  56. package/src/react/components/paginator.tsx +208 -0
  57. package/src/react/components/stack.tsx +20 -18
  58. package/src/react/components/text.tsx +13 -1
  59. package/src/react/snap-version-context.tsx +12 -0
  60. package/src/react/snap-view-core.tsx +44 -12
  61. package/src/react-native/catalog-renderer.tsx +2 -0
  62. package/src/react-native/components/snap-action-button.tsx +10 -2
  63. package/src/react-native/components/snap-cell-grid.tsx +22 -2
  64. package/src/react-native/components/snap-image.tsx +40 -1
  65. package/src/react-native/components/snap-paginator.tsx +283 -0
  66. package/src/react-native/components/snap-text.tsx +4 -2
  67. package/src/react-native/expand-state.ts +48 -0
  68. package/src/react-native/index.tsx +15 -0
  69. package/src/react-native/snap-version-context.tsx +10 -0
  70. package/src/react-native/snap-view-core.tsx +47 -12
  71. package/src/react-native/v1/snap-view.tsx +57 -10
  72. package/src/react-native/v2/snap-view.tsx +88 -17
  73. package/src/ui/catalog.ts +25 -3
  74. package/src/ui/cell-grid.ts +2 -0
  75. package/src/ui/image.ts +3 -1
  76. package/src/ui/index.ts +3 -0
  77. package/src/ui/paginator-state.ts +67 -0
  78. package/src/ui/paginator.ts +11 -0
  79. package/src/ui/text.ts +1 -0
  80. package/src/validator.ts +19 -3
@@ -4,8 +4,8 @@ import { Platform, Pressable, StyleSheet, Text, View } from "react-native";
4
4
  import { SnapThemeProvider, useSnapTheme } from "../theme.js";
5
5
  import { SnapLoadingOverlay, SnapViewCoreInner, resolveAccentHex, } from "../snap-view-core.js";
6
6
  import { validateSnapResponse, } from "@farcaster/snap";
7
+ import { getSnapExpansionState, SNAP_MAX_HEIGHT } from "../expand-state.js";
7
8
  // ─── Constants ───────────────────────────────────────
8
- const SNAP_MAX_HEIGHT = 500;
9
9
  const SNAP_WARNING_HEIGHT = 700;
10
10
  const SHOW_MORE_OVERHANG = 14;
11
11
  // ─── Validation fallback ─────────────────────────────
@@ -51,7 +51,7 @@ export function SnapViewV2({ snap, handlers, loading = false, appearance = "dark
51
51
  return (_jsx(SnapThemeProvider, { appearance: appearance, colors: colors, children: _jsx(SnapViewV2Inner, { snap: snap, handlers: handlers, loading: loading, onValidationError: onValidationError, validationErrorFallback: validationErrorFallback, loadingOverlay: loadingOverlay }) }));
52
52
  }
53
53
  // ─── SnapCardV2 (card frame + height limits) ─────────
54
- function SnapCardV2Inner({ snap, handlers, loading, borderRadius, showOverflowWarning, onValidationError, validationErrorFallback, actionError, appearance, plain, loadingOverlay, }) {
54
+ function SnapCardV2Inner({ snap, handlers, loading, borderRadius, showOverflowWarning, onValidationError, validationErrorFallback, actionError, appearance, plain, loadingOverlay, forceExpanded, expandButtonLabel, onExpandPress, }) {
55
55
  const { colors, mode } = useSnapTheme();
56
56
  const accentHex = resolveAccentHex(snap.theme?.accent, mode);
57
57
  const [contentHeight, setContentHeight] = useState(0);
@@ -60,13 +60,22 @@ function SnapCardV2Inner({ snap, handlers, loading, borderRadius, showOverflowWa
60
60
  setIsExpanded(false);
61
61
  setContentHeight(0);
62
62
  }, [snap]);
63
- const isExpandable = !showOverflowWarning && contentHeight > SNAP_MAX_HEIGHT + 1;
64
- const isClipped = isExpandable && !isExpanded;
63
+ const expansion = getSnapExpansionState({
64
+ contentHeight,
65
+ internalExpanded: isExpanded,
66
+ forceExpanded,
67
+ onExpandPress,
68
+ expandButtonLabel,
69
+ showOverflowWarning,
70
+ });
71
+ const expandButtonInsideCard = typeof onExpandPress === "function";
65
72
  const content = (_jsx(SnapViewV2Inner, { snap: snap, handlers: handlers, loading: loading, onValidationError: onValidationError, validationErrorFallback: validationErrorFallback, loadingOverlay: null }));
66
73
  if (plain) {
67
- return (_jsxs(_Fragment, { children: [_jsx(View, { style: isClipped ? { maxHeight: SNAP_MAX_HEIGHT, overflow: "hidden" } : undefined, children: _jsx(View, { collapsable: false, onLayout: (e) => {
74
+ return (_jsxs(_Fragment, { children: [_jsx(View, { style: expansion.clipped
75
+ ? { maxHeight: expansion.maxHeight, overflow: "hidden" }
76
+ : undefined, children: _jsx(View, { collapsable: false, onLayout: (e) => {
68
77
  const nextHeight = Math.round(e.nativeEvent.layout.height);
69
- setContentHeight((current) => isClipped
78
+ setContentHeight((current) => expansion.clipped
70
79
  ? Math.max(current, nextHeight)
71
80
  : current === nextHeight
72
81
  ? current
@@ -75,43 +84,65 @@ function SnapCardV2Inner({ snap, handlers, loading, borderRadius, showOverflowWa
75
84
  ? loadingOverlay === undefined
76
85
  ? _jsx(SnapLoadingOverlay, { appearance: mode, accentHex: accentHex })
77
86
  : loadingOverlay
78
- : null, isExpandable ? (_jsx(View, { style: [cardStyles.expandRow, cardStyles.expandRowPlain], children: _jsx(Pressable, { style: ({ pressed }) => [
87
+ : null, expansion.showButton ? (_jsx(View, { style: [cardStyles.expandRow, cardStyles.expandRowPlain], children: _jsx(Pressable, { style: ({ pressed }) => [
79
88
  cardStyles.expandButton,
80
89
  {
81
90
  backgroundColor: pressed ? colors.mutedHover : colors.muted,
82
91
  },
83
- ], onPress: () => setIsExpanded((value) => !value), children: _jsx(Text, { style: [cardStyles.expandButtonText, { color: colors.text }], children: isExpanded ? "Show less" : "Show more" }) }) })) : null] }));
92
+ ], onPress: () => {
93
+ if (expansion.useInternalToggle) {
94
+ setIsExpanded((value) => !value);
95
+ }
96
+ else {
97
+ onExpandPress?.();
98
+ }
99
+ }, children: _jsx(Text, { style: [cardStyles.expandButtonText, { color: colors.text }], children: expansion.buttonLabel }) }) })) : null] }));
84
100
  }
85
- const overflowAmount = showOverflowWarning ? contentHeight - SNAP_MAX_HEIGHT : 0;
101
+ const overflowAmount = expansion.showOverflowWarning
102
+ ? contentHeight - SNAP_MAX_HEIGHT
103
+ : 0;
86
104
  const isDark = mode === "dark";
87
105
  const pillBg = isDark ? "rgba(40,40,40,0.92)" : "rgba(255,255,255,0.92)";
88
106
  const pillBgPressed = isDark ? "rgba(60,60,60,0.95)" : "rgba(240,240,240,0.95)";
89
- return (_jsxs(View, { style: { paddingBottom: isExpandable ? SHOW_MORE_OVERHANG : 0 }, children: [_jsxs(View, { style: { position: "relative" }, children: [_jsxs(View, { style: {
107
+ return (_jsxs(View, { style: {
108
+ paddingBottom: expansion.showButton && !expandButtonInsideCard ? SHOW_MORE_OVERHANG : 0,
109
+ }, children: [_jsxs(View, { style: { position: "relative" }, children: [_jsxs(View, { style: {
90
110
  borderRadius,
91
111
  borderWidth: 1,
92
112
  borderColor: colors.border,
93
113
  backgroundColor: colors.surface,
94
- maxHeight: showOverflowWarning ? undefined : isClipped ? SNAP_MAX_HEIGHT : undefined,
114
+ maxHeight: expansion.showOverflowWarning
115
+ ? undefined
116
+ : expansion.maxHeight,
95
117
  overflow: "hidden",
96
118
  minHeight: 120,
97
119
  }, children: [_jsx(View, { collapsable: false, onLayout: (e) => {
98
120
  const nextHeight = Math.round(e.nativeEvent.layout.height);
99
- setContentHeight((current) => isClipped
121
+ setContentHeight((current) => expansion.clipped
100
122
  ? Math.max(current, nextHeight)
101
123
  : current === nextHeight
102
124
  ? current
103
125
  : nextHeight);
104
- }, style: { paddingHorizontal: 16, paddingVertical: 16 }, children: content }), showOverflowWarning && contentHeight > SNAP_MAX_HEIGHT && (_jsxs(View, { style: { position: "absolute", top: SNAP_MAX_HEIGHT, left: 0, right: 0, height: overflowAmount, zIndex: 10, pointerEvents: "none" }, children: [_jsx(View, { style: { height: 1, borderTopWidth: 1, borderStyle: "dashed", borderColor: "rgba(255,100,100,0.6)" } }), _jsx(View, { style: { position: "absolute", top: -10, right: 4, backgroundColor: "rgba(0,0,0,0.7)", paddingHorizontal: 4, paddingVertical: 1, borderRadius: 3 }, children: _jsxs(Text, { style: { fontSize: 10, color: "rgba(255,100,100,0.7)", fontFamily: Platform.select({ ios: "Menlo", default: "monospace" }) }, children: [SNAP_MAX_HEIGHT, "px"] }) }), _jsx(View, { style: { flex: 1, backgroundColor: "rgba(255,50,50,0.15)" } })] })), loading
126
+ }, style: { paddingHorizontal: 16, paddingVertical: 16 }, children: content }), expansion.showOverflowWarning && contentHeight > SNAP_MAX_HEIGHT && (_jsxs(View, { style: { position: "absolute", top: SNAP_MAX_HEIGHT, left: 0, right: 0, height: overflowAmount, zIndex: 10, pointerEvents: "none" }, children: [_jsx(View, { style: { height: 1, borderTopWidth: 1, borderStyle: "dashed", borderColor: "rgba(255,100,100,0.6)" } }), _jsx(View, { style: { position: "absolute", top: -10, right: 4, backgroundColor: "rgba(0,0,0,0.7)", paddingHorizontal: 4, paddingVertical: 1, borderRadius: 3 }, children: _jsxs(Text, { style: { fontSize: 10, color: "rgba(255,100,100,0.7)", fontFamily: Platform.select({ ios: "Menlo", default: "monospace" }) }, children: [SNAP_MAX_HEIGHT, "px"] }) }), _jsx(View, { style: { flex: 1, backgroundColor: "rgba(255,50,50,0.15)" } })] })), loading
105
127
  ? loadingOverlay === undefined
106
128
  ? _jsx(SnapLoadingOverlay, { appearance: mode, accentHex: accentHex })
107
129
  : loadingOverlay
108
- : null] }), isExpandable ? (_jsx(View, { pointerEvents: "box-none", style: cardStyles.expandFloat, children: _jsx(Pressable, { style: ({ pressed }) => [
130
+ : null] }), expansion.showButton ? (_jsx(View, { pointerEvents: "box-none", style: expandButtonInsideCard
131
+ ? cardStyles.expandFloatInset
132
+ : cardStyles.expandFloat, children: _jsx(Pressable, { style: ({ pressed }) => [
109
133
  cardStyles.expandButton,
110
134
  {
111
135
  backgroundColor: pressed ? pillBgPressed : pillBg,
112
136
  borderColor: colors.border,
113
137
  },
114
- ], onPress: () => setIsExpanded((value) => !value), children: _jsx(Text, { style: [cardStyles.expandButtonText, { color: colors.text }], children: isExpanded ? "Show less" : "Show more" }) }) })) : null] }), actionError && (_jsx(Text, { style: {
138
+ ], onPress: () => {
139
+ if (expansion.useInternalToggle) {
140
+ setIsExpanded((value) => !value);
141
+ }
142
+ else {
143
+ onExpandPress?.();
144
+ }
145
+ }, children: _jsx(Text, { style: [cardStyles.expandButtonText, { color: colors.text }], children: expansion.buttonLabel }) }) })) : null] }), actionError && (_jsx(Text, { style: {
115
146
  paddingHorizontal: 12,
116
147
  paddingVertical: 8,
117
148
  fontSize: 13,
@@ -120,8 +151,8 @@ function SnapCardV2Inner({ snap, handlers, loading, borderRadius, showOverflowWa
120
151
  : "rgba(200,0,0,0.8)",
121
152
  }, children: actionError }))] }));
122
153
  }
123
- export function SnapCardV2({ snap, handlers, loading = false, appearance = "dark", colors, borderRadius = 16, showOverflowWarning = false, onValidationError, validationErrorFallback, actionError, plain = false, loadingOverlay, }) {
124
- return (_jsx(SnapThemeProvider, { appearance: appearance, colors: colors, children: _jsx(SnapCardV2Inner, { snap: snap, handlers: handlers, loading: loading, borderRadius: borderRadius, showOverflowWarning: showOverflowWarning, onValidationError: onValidationError, validationErrorFallback: validationErrorFallback, actionError: actionError, appearance: appearance, plain: plain, loadingOverlay: loadingOverlay }) }));
154
+ export function SnapCardV2({ snap, handlers, loading = false, appearance = "dark", colors, borderRadius = 16, showOverflowWarning = false, onValidationError, validationErrorFallback, actionError, plain = false, loadingOverlay, forceExpanded, expandButtonLabel, onExpandPress, }) {
155
+ return (_jsx(SnapThemeProvider, { appearance: appearance, colors: colors, children: _jsx(SnapCardV2Inner, { snap: snap, handlers: handlers, loading: loading, borderRadius: borderRadius, showOverflowWarning: showOverflowWarning, onValidationError: onValidationError, validationErrorFallback: validationErrorFallback, actionError: actionError, appearance: appearance, plain: plain, loadingOverlay: loadingOverlay, forceExpanded: forceExpanded, expandButtonLabel: expandButtonLabel, onExpandPress: onExpandPress }) }));
125
156
  }
126
157
  const cardStyles = StyleSheet.create({
127
158
  frameRing: { alignSelf: "stretch" },
@@ -137,6 +168,18 @@ const cardStyles = StyleSheet.create({
137
168
  alignItems: "center",
138
169
  justifyContent: "center",
139
170
  },
171
+ expandFloatInset: {
172
+ position: "absolute",
173
+ left: 0,
174
+ right: 0,
175
+ bottom: 10,
176
+ height: 28,
177
+ alignItems: "center",
178
+ justifyContent: "center",
179
+ },
180
+ expandRow: {
181
+ alignItems: "center",
182
+ },
140
183
  expandRowPlain: {
141
184
  paddingTop: 8,
142
185
  alignItems: "center",
@@ -319,8 +319,29 @@ export declare const snapJsonRenderCatalog: import("@json-render/core").Catalog<
319
319
  "16:9": "16:9";
320
320
  "4:3": "4:3";
321
321
  "9:16": "9:16";
322
+ "4:1": "4:1";
322
323
  }>;
323
324
  alt: z.ZodOptional<z.ZodString>;
325
+ title: z.ZodOptional<z.ZodString>;
326
+ subtitle: z.ZodOptional<z.ZodString>;
327
+ }, z.core.$strip>;
328
+ description: string;
329
+ };
330
+ paginator: {
331
+ props: z.ZodObject<{
332
+ initialPage: z.ZodOptional<z.ZodNumber>;
333
+ showIndicators: z.ZodOptional<z.ZodBoolean>;
334
+ showControls: z.ZodOptional<z.ZodBoolean>;
335
+ controlsPosition: z.ZodOptional<z.ZodEnum<{
336
+ top: "top";
337
+ bottom: "bottom";
338
+ }>>;
339
+ transition: z.ZodOptional<z.ZodEnum<{
340
+ none: "none";
341
+ slide: "slide";
342
+ fade: "fade";
343
+ scale: "scale";
344
+ }>>;
324
345
  }, z.core.$strip>;
325
346
  description: string;
326
347
  };
@@ -393,6 +414,7 @@ export declare const snapJsonRenderCatalog: import("@json-render/core").Catalog<
393
414
  left: "left";
394
415
  right: "right";
395
416
  }>>;
417
+ maxLines: z.ZodOptional<z.ZodNumber>;
396
418
  }, z.core.$strip>;
397
419
  description: string;
398
420
  };
@@ -469,6 +491,11 @@ export declare const snapJsonRenderCatalog: import("@json-render/core").Catalog<
469
491
  square: "square";
470
492
  }>>;
471
493
  rowHeight: z.ZodOptional<z.ZodNumber>;
494
+ maxWidth: z.ZodOptional<z.ZodEnum<{
495
+ sm: "sm";
496
+ md: "md";
497
+ lg: "lg";
498
+ }>>;
472
499
  select: z.ZodOptional<z.ZodEnum<{
473
500
  multiple: "multiple";
474
501
  off: "off";
@@ -545,5 +572,23 @@ export declare const snapJsonRenderCatalog: import("@json-render/core").Catalog<
545
572
  buyToken: z.ZodOptional<z.ZodString>;
546
573
  }, z.core.$strip>;
547
574
  };
575
+ paginator_next: {
576
+ description: string;
577
+ params: z.ZodObject<{
578
+ page: z.ZodOptional<z.ZodNumber>;
579
+ }, z.core.$strip>;
580
+ };
581
+ paginator_prev: {
582
+ description: string;
583
+ params: z.ZodObject<{
584
+ page: z.ZodOptional<z.ZodNumber>;
585
+ }, z.core.$strip>;
586
+ };
587
+ paginator_go_to: {
588
+ description: string;
589
+ params: z.ZodObject<{
590
+ page: z.ZodNumber;
591
+ }, z.core.$strip>;
592
+ };
548
593
  };
549
594
  }>;
@@ -10,6 +10,7 @@ import { inputProps } from "./input.js";
10
10
  import { itemProps } from "./item.js";
11
11
  import { itemGroupProps } from "./item-group.js";
12
12
  import { imageProps } from "./image.js";
13
+ import { paginatorProps } from "./paginator.js";
13
14
  import { progressProps } from "./progress.js";
14
15
  import { separatorProps } from "./separator.js";
15
16
  import { sliderProps } from "./slider.js";
@@ -62,7 +63,11 @@ export const snapJsonRenderCatalog = defineCatalog(snapJsonRenderSchema, {
62
63
  },
63
64
  image: {
64
65
  props: imageProps,
65
- description: "HTTPS image with fixed aspect ratio.",
66
+ description: "HTTPS image with fixed aspect ratio. Supports compact 4:1 banners and optional title/subtitle overlay text.",
67
+ },
68
+ paginator: {
69
+ props: paginatorProps,
70
+ description: "Client-side paginator. Children are page element ids; the @farcaster/snap React/React Native components render one page at a time with optional built-in previous/next controls and indicators, optional top/bottom controlsPosition, and author-controlled local transition (slide | fade | scale | none). Buttons or cell_grid cells in the same snap can bind paginator_next, paginator_prev, or paginator_go_to for custom local navigation. Only one paginator is supported per rendered snap in this release. Page index is json-render local UI state and is not posted as input.",
66
71
  },
67
72
  progress: {
68
73
  props: progressProps,
@@ -82,7 +87,7 @@ export const snapJsonRenderCatalog = defineCatalog(snapJsonRenderSchema, {
82
87
  },
83
88
  text: {
84
89
  props: textProps,
85
- description: "Text block — size: md (body, default), sm (caption). Optional weight and align.",
90
+ description: "Text block — size: md (body, default), sm (caption). Optional weight, align, and maxLines. Text does not clamp by default; set maxLines to bound rendered lines.",
86
91
  },
87
92
  bar_chart: {
88
93
  props: barChartProps,
@@ -90,7 +95,7 @@ export const snapJsonRenderCatalog = defineCatalog(snapJsonRenderSchema, {
90
95
  },
91
96
  cell_grid: {
92
97
  props: cellGridProps,
93
- description: "Cell grid — sparse colored cells on a rows×cols grid. Cell color and textColor are palette names or literal #rrggbb hex values (hex ignores page accent); textColor overrides the default auto-contrast text color. 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.",
98
+ description: "Cell grid — sparse colored cells on a rows×cols grid. Set maxWidth to sm or md to render compact square boards centered instead of stretching full-width; lg is the default full-width behavior. Cell color and textColor are palette names or literal #rrggbb hex values (hex ignores page accent); textColor overrides the default auto-contrast text color. 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.",
94
99
  },
95
100
  },
96
101
  actions: {
@@ -146,5 +151,17 @@ export const snapJsonRenderCatalog = defineCatalog(snapJsonRenderSchema, {
146
151
  buyToken: z.string().optional(),
147
152
  }),
148
153
  },
154
+ paginator_next: {
155
+ description: "Move the snap's paginator to the next page locally. Does not POST and is ignored when no paginator is rendered.",
156
+ params: z.object({ page: z.number().int().min(0).optional() }),
157
+ },
158
+ paginator_prev: {
159
+ description: "Move the snap's paginator to the previous page locally. Does not POST and is ignored when no paginator is rendered.",
160
+ params: z.object({ page: z.number().int().min(0).optional() }),
161
+ },
162
+ paginator_go_to: {
163
+ description: "Move the snap's paginator to a specific zero-based page index locally. Does not POST and is ignored when no paginator is rendered.",
164
+ params: z.object({ page: z.number().int().min(0) }),
165
+ },
149
166
  },
150
167
  });
@@ -40,6 +40,11 @@ export declare const cellGridProps: z.ZodObject<{
40
40
  square: "square";
41
41
  }>>;
42
42
  rowHeight: z.ZodOptional<z.ZodNumber>;
43
+ maxWidth: z.ZodOptional<z.ZodEnum<{
44
+ sm: "sm";
45
+ md: "md";
46
+ lg: "lg";
47
+ }>>;
43
48
  select: z.ZodOptional<z.ZodEnum<{
44
49
  multiple: "multiple";
45
50
  off: "off";
@@ -1,6 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { isSnapHexColorString, PALETTE_COLOR_VALUES } from "../colors.js";
3
- import { GRID_MIN_COLS, GRID_MAX_COLS, GRID_MIN_ROWS, GRID_MAX_ROWS, GRID_GAP_VALUES, GRID_CELL_ASPECT_RATIO_VALUES, } from "../constants.js";
3
+ import { GRID_MIN_COLS, GRID_MAX_COLS, GRID_MIN_ROWS, GRID_MAX_ROWS, GRID_GAP_VALUES, GRID_CELL_ASPECT_RATIO_VALUES, GRID_MAX_WIDTH_VALUES, } from "../constants.js";
4
4
  /** Palette name or `#rrggbb`; input is trimmed so palette and hex rules match runtime resolvers. */
5
5
  const cellGridCellColorSchema = (field) => z.preprocess((v) => (typeof v === "string" ? v.trim() : v), z.union([
6
6
  z.enum(PALETTE_COLOR_VALUES),
@@ -25,6 +25,7 @@ export const cellGridProps = z
25
25
  gap: z.enum(GRID_GAP_VALUES).optional(),
26
26
  cellAspectRatio: z.enum(GRID_CELL_ASPECT_RATIO_VALUES).optional(),
27
27
  rowHeight: z.number().int().min(8).max(64).optional(),
28
+ maxWidth: z.enum(GRID_MAX_WIDTH_VALUES).optional(),
28
29
  select: z.enum(["off", "single", "multiple"]).optional(),
29
30
  })
30
31
  .superRefine((val, ctx) => {
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- export declare const IMAGE_ASPECTS: readonly ["1:1", "16:9", "4:3", "9:16"];
2
+ export declare const IMAGE_ASPECTS: readonly ["1:1", "16:9", "4:3", "9:16", "4:1"];
3
3
  export declare const imageProps: z.ZodObject<{
4
4
  url: z.ZodString;
5
5
  aspect: z.ZodEnum<{
@@ -7,7 +7,10 @@ export declare const imageProps: z.ZodObject<{
7
7
  "16:9": "16:9";
8
8
  "4:3": "4:3";
9
9
  "9:16": "9:16";
10
+ "4:1": "4:1";
10
11
  }>;
11
12
  alt: z.ZodOptional<z.ZodString>;
13
+ title: z.ZodOptional<z.ZodString>;
14
+ subtitle: z.ZodOptional<z.ZodString>;
12
15
  }, z.core.$strip>;
13
16
  export type ImageProps = z.infer<typeof imageProps>;
package/dist/ui/image.js CHANGED
@@ -1,7 +1,9 @@
1
1
  import { z } from "zod";
2
- export const IMAGE_ASPECTS = ["1:1", "16:9", "4:3", "9:16"];
2
+ export const IMAGE_ASPECTS = ["1:1", "16:9", "4:3", "9:16", "4:1"];
3
3
  export const imageProps = z.object({
4
4
  url: z.string(),
5
5
  aspect: z.enum(IMAGE_ASPECTS),
6
6
  alt: z.string().optional(),
7
+ title: z.string().min(1).max(80).optional(),
8
+ subtitle: z.string().min(1).max(120).optional(),
7
9
  });
@@ -18,6 +18,8 @@ export { iconProps, ICON_NAMES } from "./icon.js";
18
18
  export type { IconProps } from "./icon.js";
19
19
  export { imageProps } from "./image.js";
20
20
  export type { ImageProps } from "./image.js";
21
+ export { paginatorProps } from "./paginator.js";
22
+ export type { PaginatorProps } from "./paginator.js";
21
23
  export { progressProps } from "./progress.js";
22
24
  export type { ProgressProps } from "./progress.js";
23
25
  export { separatorProps } from "./separator.js";
package/dist/ui/index.js CHANGED
@@ -9,6 +9,7 @@ export { itemProps, itemMediaProps } from "./item.js";
9
9
  export { itemGroupProps } from "./item-group.js";
10
10
  export { iconProps, ICON_NAMES } from "./icon.js";
11
11
  export { imageProps } from "./image.js";
12
+ export { paginatorProps } from "./paginator.js";
12
13
  export { progressProps } from "./progress.js";
13
14
  export { separatorProps } from "./separator.js";
14
15
  export { sliderProps } from "./slider.js";
@@ -0,0 +1,18 @@
1
+ export declare const SNAP_PAGINATOR_PAGE_PATH = "/ui/paginator/page";
2
+ export declare const SNAP_PAGINATOR_PAGE_COUNT_PATH = "/ui/paginator/pageCount";
3
+ export type SnapPaginatorAction = {
4
+ action: "paginator_next" | "paginator_prev";
5
+ } | {
6
+ action: "paginator_go_to";
7
+ page?: number;
8
+ };
9
+ type StateStoreAccess = {
10
+ get: (path: string) => unknown;
11
+ set: (path: string, value: unknown) => void;
12
+ };
13
+ export declare function clampPaginatorPage(value: number, pageCount: number): number;
14
+ export declare function pageFromValue(value: unknown, fallback?: number): number;
15
+ export declare function pageCountFromValue(value: unknown): number;
16
+ export declare function getPaginatorAction(on: Record<string, unknown> | undefined): SnapPaginatorAction | null;
17
+ export declare function runPaginatorAction(store: StateStoreAccess, action: SnapPaginatorAction | null): boolean;
18
+ export {};
@@ -0,0 +1,47 @@
1
+ export const SNAP_PAGINATOR_PAGE_PATH = "/ui/paginator/page";
2
+ export const SNAP_PAGINATOR_PAGE_COUNT_PATH = "/ui/paginator/pageCount";
3
+ export function clampPaginatorPage(value, pageCount) {
4
+ return Math.min(Math.max(value, 0), Math.max(pageCount - 1, 0));
5
+ }
6
+ export function pageFromValue(value, fallback = 0) {
7
+ return typeof value === "number" && Number.isInteger(value)
8
+ ? value
9
+ : fallback;
10
+ }
11
+ export function pageCountFromValue(value) {
12
+ return typeof value === "number" && Number.isInteger(value) && value > 0
13
+ ? value
14
+ : 0;
15
+ }
16
+ export function getPaginatorAction(on) {
17
+ const press = on?.press;
18
+ if (!press)
19
+ return null;
20
+ if (press.action === "paginator_next")
21
+ return { action: "paginator_next" };
22
+ if (press.action === "paginator_prev")
23
+ return { action: "paginator_prev" };
24
+ if (press.action === "paginator_go_to") {
25
+ const page = press.params?.page;
26
+ return {
27
+ action: "paginator_go_to",
28
+ page: typeof page === "number" && Number.isInteger(page) ? page : 0,
29
+ };
30
+ }
31
+ return null;
32
+ }
33
+ export function runPaginatorAction(store, action) {
34
+ if (!action)
35
+ return false;
36
+ const pageCount = pageCountFromValue(store.get(SNAP_PAGINATOR_PAGE_COUNT_PATH));
37
+ if (pageCount === 0)
38
+ return false;
39
+ const currentPage = clampPaginatorPage(pageFromValue(store.get(SNAP_PAGINATOR_PAGE_PATH), 0), pageCount);
40
+ const nextPage = action.action === "paginator_next"
41
+ ? currentPage + 1
42
+ : action.action === "paginator_prev"
43
+ ? currentPage - 1
44
+ : action.page ?? 0;
45
+ store.set(SNAP_PAGINATOR_PAGE_PATH, clampPaginatorPage(nextPage, pageCount));
46
+ return true;
47
+ }
@@ -0,0 +1,17 @@
1
+ import { z } from "zod";
2
+ export declare const paginatorProps: z.ZodObject<{
3
+ initialPage: z.ZodOptional<z.ZodNumber>;
4
+ showIndicators: z.ZodOptional<z.ZodBoolean>;
5
+ showControls: z.ZodOptional<z.ZodBoolean>;
6
+ controlsPosition: z.ZodOptional<z.ZodEnum<{
7
+ top: "top";
8
+ bottom: "bottom";
9
+ }>>;
10
+ transition: z.ZodOptional<z.ZodEnum<{
11
+ none: "none";
12
+ slide: "slide";
13
+ fade: "fade";
14
+ scale: "scale";
15
+ }>>;
16
+ }, z.core.$strip>;
17
+ export type PaginatorProps = z.infer<typeof paginatorProps>;
@@ -0,0 +1,8 @@
1
+ import { z } from "zod";
2
+ export const paginatorProps = z.object({
3
+ initialPage: z.number().int().min(0).optional(),
4
+ showIndicators: z.boolean().optional(),
5
+ showControls: z.boolean().optional(),
6
+ controlsPosition: z.enum(["top", "bottom"]).optional(),
7
+ transition: z.enum(["slide", "fade", "scale", "none"]).optional(),
8
+ });
package/dist/ui/text.d.ts CHANGED
@@ -18,5 +18,6 @@ export declare const textProps: z.ZodObject<{
18
18
  left: "left";
19
19
  right: "right";
20
20
  }>>;
21
+ maxLines: z.ZodOptional<z.ZodNumber>;
21
22
  }, z.core.$strip>;
22
23
  export type TextProps = z.infer<typeof textProps>;
package/dist/ui/text.js CHANGED
@@ -8,4 +8,5 @@ export const textProps = z.object({
8
8
  size: z.enum(TEXT_SIZES).optional(),
9
9
  weight: z.enum(TEXT_WEIGHTS).optional(),
10
10
  align: z.enum(TEXT_ALIGNS).optional(),
11
+ maxLines: z.number().int().min(1).max(6).optional(),
11
12
  });
package/dist/validator.js CHANGED
@@ -76,9 +76,12 @@ function validateStructure(ui) {
76
76
  path: ["ui", "elements"],
77
77
  });
78
78
  }
79
- // Root element has a stricter children limit
79
+ // Root element has a stricter children limit. Paginator pages are intentionally
80
+ // allowed to exceed per-container child caps while preserving global limits.
80
81
  const rootEl = elements[ui.root];
81
- if (rootEl?.children && rootEl.children.length > MAX_ROOT_CHILDREN) {
82
+ if (rootEl?.type !== "paginator" &&
83
+ rootEl?.children &&
84
+ rootEl.children.length > MAX_ROOT_CHILDREN) {
82
85
  issues.push({
83
86
  code: "custom",
84
87
  message: `Root element "${ui.root}" exceeds maximum of ${MAX_ROOT_CHILDREN} children (found ${rootEl.children.length})`,
@@ -88,7 +91,7 @@ function validateStructure(ui) {
88
91
  for (const [id, el] of Object.entries(elements)) {
89
92
  if (id === ui.root)
90
93
  continue; // already checked above
91
- if (el.children && el.children.length > MAX_CHILDREN) {
94
+ if (el.type !== "paginator" && el.children && el.children.length > MAX_CHILDREN) {
92
95
  issues.push({
93
96
  code: "custom",
94
97
  message: `Element "${id}" exceeds maximum of ${MAX_CHILDREN} children (found ${el.children.length})`,
@@ -96,6 +99,16 @@ function validateStructure(ui) {
96
99
  });
97
100
  }
98
101
  }
102
+ const paginatorIds = Object.entries(elements)
103
+ .filter(([, el]) => el.type === "paginator")
104
+ .map(([id]) => id);
105
+ if (paginatorIds.length > 1) {
106
+ issues.push({
107
+ code: "custom",
108
+ message: `Snap supports at most one paginator (found ${paginatorIds.length}: ${paginatorIds.join(", ")})`,
109
+ path: ["ui", "elements"],
110
+ });
111
+ }
99
112
  const depth = measureDepth(elements, ui.root);
100
113
  if (depth > MAX_DEPTH) {
101
114
  issues.push({
package/llms.txt CHANGED
@@ -36,10 +36,10 @@ Top-level fields: `version` (required, `"1.0"` or `"2.0"`), `theme` (optional, `
36
36
  |------------|-------|
37
37
  | Total elements | Max **64** in `ui.elements` |
38
38
  | Root children | Max **7** children on the root element |
39
- | Children per element | Max **6** per non-root container (`stack`, `item_group`) |
39
+ | Children per element | Max **6** per non-root container (`stack`, `item_group`); `paginator` pages are exempt |
40
40
  | Nesting depth | Max **5** levels from root to deepest leaf |
41
41
 
42
- ## Components (16 total)
42
+ ## Components (17 total)
43
43
 
44
44
  ### Display Components
45
45
 
@@ -61,8 +61,9 @@ Top-level fields: `version` (required, `"1.0"` or `"2.0"`), `theme` (optional, `
61
61
 
62
62
  **image** — HTTPS image with fixed aspect ratio.
63
63
  - `url` (string, required)
64
- - `aspect` (required): `"1:1"` | `"16:9"` | `"4:3"` | `"9:16"`
64
+ - `aspect` (required): `"1:1"` | `"16:9"` | `"4:3"` | `"9:16"` | `"4:1"`
65
65
  - `alt` (string, optional)
66
+ - `title` (string, optional, max 80) and `subtitle` (string, optional, max 120) render readable overlay text. Use this for hero-like image titles; do not create a separate hero component.
66
67
 
67
68
  **item** — Content row matching shadcn Item: optional left media, content, and right-side actions slot.
68
69
  - `title` (string, required, max 100)
@@ -84,6 +85,7 @@ Top-level fields: `version` (required, `"1.0"` or `"2.0"`), `theme` (optional, `
84
85
  - `size` (optional): `"md"` (body) | `"sm"` (caption). Default: `"md"`
85
86
  - `weight` (optional): `"bold"` | `"normal"`. Default: `"normal"`
86
87
  - `align` (optional): `"left"` | `"center"` | `"right"`. Default: `"left"`
88
+ - `maxLines` (optional): integer 1–6. Default: no clamp. Set this when body text should use a bounded number of visible lines.
87
89
 
88
90
  ### Data Components
89
91
 
@@ -100,14 +102,27 @@ Top-level fields: `version` (required, `"1.0"` or `"2.0"`), `theme` (optional, `
100
102
  - `gap` (optional): `"none"` (0px) | `"sm"` (1px) | `"md"` (2px) | `"lg"` (4px). Default: `"sm"`
101
103
  - `cellAspectRatio` (optional): `"auto"` | `"square"`. Default: `"auto"`. Use `"square"` for game boards whose cells must stay square as snap width changes.
102
104
  - `rowHeight` (number, optional, 8–64): pixel height per row. Default: 28. Grid height = rows × rowHeight
105
+ - `maxWidth` (optional): `"sm"` | `"md"` | `"lg"`. Default: `"lg"` (full-width). Use `sm`/`md` with `cellAspectRatio: "square"` for small centered boards that should not stretch full-width.
103
106
  - `select` (optional): `"off"` | `"single"` | `"multiple"`. Default: `"off"`. With `select: "off"`, bind `on.press` for press-to-act (each press writes the cell's `value` or `"row,col"` to `inputs[name]` and fires the action). With `"single"` / `"multiple"`, presses accumulate selection state and pair with a separate submit `button` (multi-select joins values with `|`); `on.press` is ignored.
104
107
  - Events: `press` — fires on cell press, only when `select: "off"`; `inputs[name]` is set to the pressed cell's `value` (or `"row,col"` fallback) before the bound action runs
105
108
 
106
109
  ### Container Components
107
110
 
111
+ **paginator** — Client-side page navigation without a server round trip.
112
+ - Children are page element IDs; one child renders at a time.
113
+ - `initialPage` (optional): zero-based page index. Default: `0`
114
+ - `showIndicators` (optional): boolean. Default: `true`
115
+ - `showControls` (optional): boolean. Default: `true`
116
+ - `controlsPosition` (optional): `"top"` | `"bottom"`. Default: `"bottom"`. Moves the built-in pagination bar above or below page content; use `"top"` to keep controls stable when page heights vary.
117
+ - `transition` (optional): `"slide"` | `"fade"` | `"scale"` | `"none"`. Default: `"slide"`. Controls the local page-change animation.
118
+ - The current page is json-render local UI state only and is never included in POST `inputs`.
119
+ - Set both `showControls: false` and `showIndicators: false` to hide the built-in pagination bar.
120
+ - Buttons or tappable `cell_grid` cells anywhere in the same snap can bind local actions: `paginator_next` with `params: {}`, `paginator_prev` with `params: {}`, and `paginator_go_to` with `params: { "page": 0 }`. These affect the snap's rendered paginator, never POST, and support one paginator per snap in this release.
121
+ - Paginator children may exceed the normal per-container child limit, but the snap still must stay within 64 total elements and max nesting depth.
122
+
108
123
  **stack** — Layout container.
109
124
  - `direction` (optional): `"vertical"` | `"horizontal"`. Default: `"vertical"`
110
- - `gap` (optional): `"none"` | `"sm"` | `"md"` | `"lg"`. Vertical px: 0/8/16/24. Horizontal px: 0/4/8/16 (tighter, since children sit side-by-side). Default for vertical: `"md"`. Default for horizontal is column-aware: 2 cols → `"lg"` (16px), 3 cols → `"md"` (8px), 4+ cols → `"sm"` (4px), unknown → `"md"` (8px). An explicit value always wins — override the default when you have a deliberate visual reason (e.g. tighter toolbar, extra breathing room around a hero row).
125
+ - `gap` (optional): `"none"` | `"sm"` | `"md"` | `"lg"`. Vertical px: 0/4/16/24. Horizontal px: 0/4/8/16 (tighter, since children sit side-by-side). Default for vertical: `"md"`. Default for horizontal is column-aware: 2 cols → `"lg"` (16px), 3 cols → `"md"` (8px), 4+ cols → `"sm"` (4px), unknown → `"md"` (8px). An explicit value always wins — override the default when you have a deliberate visual reason (e.g. tighter toolbar, extra breathing room around a hero row).
111
126
  - `justify` (optional): `"start"` | `"center"` | `"end"` | `"between"` | `"around"`
112
127
  - `columns` (optional, horizontal only): `2`–`6` — CSS grid with equal columns (mixed children or layout that needs fixed column counts).
113
128
  - Children are element IDs
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@farcaster/snap",
3
- "version": "2.5.1",
3
+ "version": "2.6.1",
4
4
  "description": "Farcaster Snaps 🫰",
5
5
  "repository": {
6
6
  "type": "git",
package/src/constants.ts CHANGED
@@ -21,6 +21,7 @@ export const GRID_MIN_ROWS = 2;
21
21
  export const GRID_MAX_ROWS = 16;
22
22
  export const GRID_GAP_VALUES = ["none", "sm", "md", "lg"] as const;
23
23
  export const GRID_CELL_ASPECT_RATIO_VALUES = ["auto", "square"] as const;
24
+ export const GRID_MAX_WIDTH_VALUES = ["sm", "md", "lg"] as const;
24
25
 
25
26
  // ─── Snap structural limits ───────────────────────────
26
27
  export const MAX_ELEMENTS = 64;
@@ -9,6 +9,7 @@ import { SnapImage } from "./components/image";
9
9
  import { SnapInput } from "./components/input";
10
10
  import { SnapItem } from "./components/item";
11
11
  import { SnapItemGroup } from "./components/item-group";
12
+ import { SnapPaginator } from "./components/paginator";
12
13
  import { SnapProgress } from "./components/progress";
13
14
  import { SnapSeparator } from "./components/separator";
14
15
  import { SnapSlider } from "./components/slider";
@@ -31,6 +32,7 @@ export const SnapCatalogView = createRenderer(snapJsonRenderCatalog, {
31
32
  input: SnapInput,
32
33
  item: SnapItem,
33
34
  item_group: SnapItemGroup,
35
+ paginator: SnapPaginator,
34
36
  progress: SnapProgress,
35
37
  separator: SnapSeparator,
36
38
  slider: SnapSlider,