@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
@@ -7,8 +7,7 @@ import {
7
7
  resolveAccentHex,
8
8
  } from "../snap-view-core";
9
9
  import type { SnapPage, SnapActionHandlers } from "../types";
10
-
11
- const SNAP_MAX_HEIGHT = 500;
10
+ import { getSnapExpansionState } from "../expand-state";
12
11
 
13
12
  // ─── SnapViewV1 (no validation) ──────────────────────
14
13
 
@@ -72,6 +71,9 @@ function SnapCardV1Inner({
72
71
  appearance,
73
72
  plain,
74
73
  loadingOverlay,
74
+ forceExpanded,
75
+ expandButtonLabel,
76
+ onExpandPress,
75
77
  }: {
76
78
  snap: SnapPage;
77
79
  handlers: SnapActionHandlers;
@@ -81,6 +83,9 @@ function SnapCardV1Inner({
81
83
  appearance: "light" | "dark";
82
84
  plain: boolean;
83
85
  loadingOverlay?: ReactNode;
86
+ forceExpanded?: boolean;
87
+ expandButtonLabel?: string;
88
+ onExpandPress?: () => void;
84
89
  }) {
85
90
  const { colors, mode } = useSnapTheme();
86
91
  const accentHex = resolveAccentHex(snap.theme?.accent, mode);
@@ -92,8 +97,14 @@ function SnapCardV1Inner({
92
97
  setContentHeight(0);
93
98
  }, [snap]);
94
99
 
95
- const isExpandable = contentHeight > SNAP_MAX_HEIGHT + 1;
96
- const isClipped = isExpandable && !isExpanded;
100
+ const expansion = getSnapExpansionState({
101
+ contentHeight,
102
+ internalExpanded: isExpanded,
103
+ forceExpanded,
104
+ onExpandPress,
105
+ expandButtonLabel,
106
+ });
107
+ const expandButtonInsideCard = typeof onExpandPress === "function";
97
108
 
98
109
  const isDark = mode === "dark";
99
110
  const pillBg = isDark ? "rgba(40,40,40,0.92)" : "rgba(255,255,255,0.92)";
@@ -112,14 +123,18 @@ function SnapCardV1Inner({
112
123
  ]}
113
124
  >
114
125
  <View
115
- style={isClipped ? { maxHeight: SNAP_MAX_HEIGHT, overflow: "hidden" } : undefined}
126
+ style={
127
+ expansion.clipped
128
+ ? { maxHeight: expansion.maxHeight, overflow: "hidden" }
129
+ : undefined
130
+ }
116
131
  >
117
132
  <View
118
133
  collapsable={false}
119
134
  onLayout={(event) => {
120
135
  const nextHeight = Math.round(event.nativeEvent.layout.height);
121
136
  setContentHeight((currentHeight) =>
122
- isClipped
137
+ expansion.clipped
123
138
  ? Math.max(currentHeight, nextHeight)
124
139
  : currentHeight === nextHeight
125
140
  ? currentHeight
@@ -142,8 +157,15 @@ function SnapCardV1Inner({
142
157
  : loadingOverlay
143
158
  : null}
144
159
  </View>
145
- {isExpandable ? (
146
- <View pointerEvents="box-none" style={cardStyles.expandFloat}>
160
+ {expansion.showButton ? (
161
+ <View
162
+ pointerEvents="box-none"
163
+ style={
164
+ expandButtonInsideCard
165
+ ? cardStyles.expandFloatInset
166
+ : cardStyles.expandFloat
167
+ }
168
+ >
147
169
  <Pressable
148
170
  style={({ pressed }) => [
149
171
  cardStyles.expandButton,
@@ -153,13 +175,17 @@ function SnapCardV1Inner({
153
175
  },
154
176
  ]}
155
177
  onPress={() => {
156
- setIsExpanded((value) => !value);
178
+ if (expansion.useInternalToggle) {
179
+ setIsExpanded((value) => !value);
180
+ } else {
181
+ onExpandPress?.();
182
+ }
157
183
  }}
158
184
  >
159
185
  <Text
160
186
  style={[cardStyles.expandButtonText, { color: colors.text }]}
161
187
  >
162
- {isExpanded ? "Show less" : "Show more"}
188
+ {expansion.buttonLabel}
163
189
  </Text>
164
190
  </Pressable>
165
191
  </View>
@@ -194,6 +220,9 @@ export function SnapCardV1({
194
220
  actionError,
195
221
  plain = false,
196
222
  loadingOverlay,
223
+ forceExpanded,
224
+ expandButtonLabel,
225
+ onExpandPress,
197
226
  }: {
198
227
  snap: SnapPage;
199
228
  handlers: SnapActionHandlers;
@@ -205,6 +234,12 @@ export function SnapCardV1({
205
234
  plain?: boolean;
206
235
  /** Custom content rendered while `loading` is true. Pass `null` to render nothing. */
207
236
  loadingOverlay?: ReactNode;
237
+ /** When true, render full content height without 500px clipping or expand controls. */
238
+ forceExpanded?: boolean;
239
+ /** Custom label for the collapsed expand button. */
240
+ expandButtonLabel?: string;
241
+ /** Called from the collapsed expand button instead of toggling internal state. */
242
+ onExpandPress?: () => void;
208
243
  }) {
209
244
  return (
210
245
  <SnapThemeProvider appearance={appearance} colors={colors}>
@@ -217,6 +252,9 @@ export function SnapCardV1({
217
252
  appearance={appearance}
218
253
  plain={plain}
219
254
  loadingOverlay={loadingOverlay}
255
+ forceExpanded={forceExpanded}
256
+ expandButtonLabel={expandButtonLabel}
257
+ onExpandPress={onExpandPress}
220
258
  />
221
259
  </SnapThemeProvider>
222
260
  );
@@ -235,6 +273,15 @@ const cardStyles = StyleSheet.create({
235
273
  alignItems: "center",
236
274
  justifyContent: "center",
237
275
  },
276
+ expandFloatInset: {
277
+ position: "absolute",
278
+ left: 0,
279
+ right: 0,
280
+ bottom: 10,
281
+ height: 28,
282
+ alignItems: "center",
283
+ justifyContent: "center",
284
+ },
238
285
  expandButton: {
239
286
  minWidth: 92,
240
287
  alignItems: "center",
@@ -12,10 +12,10 @@ import {
12
12
  type ValidationResult,
13
13
  } from "@farcaster/snap";
14
14
  import type { SnapPage, SnapActionHandlers } from "../types";
15
+ import { getSnapExpansionState, SNAP_MAX_HEIGHT } from "../expand-state";
15
16
 
16
17
  // ─── Constants ───────────────────────────────────────
17
18
 
18
- const SNAP_MAX_HEIGHT = 500;
19
19
  const SNAP_WARNING_HEIGHT = 700;
20
20
  const SHOW_MORE_OVERHANG = 14;
21
21
 
@@ -141,6 +141,9 @@ function SnapCardV2Inner({
141
141
  appearance,
142
142
  plain,
143
143
  loadingOverlay,
144
+ forceExpanded,
145
+ expandButtonLabel,
146
+ onExpandPress,
144
147
  }: {
145
148
  snap: SnapPage;
146
149
  handlers: SnapActionHandlers;
@@ -153,6 +156,9 @@ function SnapCardV2Inner({
153
156
  appearance: "light" | "dark";
154
157
  plain: boolean;
155
158
  loadingOverlay?: ReactNode;
159
+ forceExpanded?: boolean;
160
+ expandButtonLabel?: string;
161
+ onExpandPress?: () => void;
156
162
  }) {
157
163
  const { colors, mode } = useSnapTheme();
158
164
  const accentHex = resolveAccentHex(snap.theme?.accent, mode);
@@ -164,8 +170,15 @@ function SnapCardV2Inner({
164
170
  setContentHeight(0);
165
171
  }, [snap]);
166
172
 
167
- const isExpandable = !showOverflowWarning && contentHeight > SNAP_MAX_HEIGHT + 1;
168
- const isClipped = isExpandable && !isExpanded;
173
+ const expansion = getSnapExpansionState({
174
+ contentHeight,
175
+ internalExpanded: isExpanded,
176
+ forceExpanded,
177
+ onExpandPress,
178
+ expandButtonLabel,
179
+ showOverflowWarning,
180
+ });
181
+ const expandButtonInsideCard = typeof onExpandPress === "function";
169
182
 
170
183
  const content = (
171
184
  <SnapViewV2Inner
@@ -181,13 +194,19 @@ function SnapCardV2Inner({
181
194
  if (plain) {
182
195
  return (
183
196
  <>
184
- <View style={isClipped ? { maxHeight: SNAP_MAX_HEIGHT, overflow: "hidden" } : undefined}>
197
+ <View
198
+ style={
199
+ expansion.clipped
200
+ ? { maxHeight: expansion.maxHeight, overflow: "hidden" }
201
+ : undefined
202
+ }
203
+ >
185
204
  <View
186
205
  collapsable={false}
187
206
  onLayout={(e) => {
188
207
  const nextHeight = Math.round(e.nativeEvent.layout.height);
189
208
  setContentHeight((current) =>
190
- isClipped
209
+ expansion.clipped
191
210
  ? Math.max(current, nextHeight)
192
211
  : current === nextHeight
193
212
  ? current
@@ -203,7 +222,7 @@ function SnapCardV2Inner({
203
222
  ? <SnapLoadingOverlay appearance={mode} accentHex={accentHex} />
204
223
  : loadingOverlay
205
224
  : null}
206
- {isExpandable ? (
225
+ {expansion.showButton ? (
207
226
  <View style={[cardStyles.expandRow, cardStyles.expandRowPlain]}>
208
227
  <Pressable
209
228
  style={({ pressed }) => [
@@ -212,10 +231,16 @@ function SnapCardV2Inner({
212
231
  backgroundColor: pressed ? colors.mutedHover : colors.muted,
213
232
  },
214
233
  ]}
215
- onPress={() => setIsExpanded((value) => !value)}
234
+ onPress={() => {
235
+ if (expansion.useInternalToggle) {
236
+ setIsExpanded((value) => !value);
237
+ } else {
238
+ onExpandPress?.();
239
+ }
240
+ }}
216
241
  >
217
242
  <Text style={[cardStyles.expandButtonText, { color: colors.text }]}>
218
- {isExpanded ? "Show less" : "Show more"}
243
+ {expansion.buttonLabel}
219
244
  </Text>
220
245
  </Pressable>
221
246
  </View>
@@ -224,13 +249,20 @@ function SnapCardV2Inner({
224
249
  );
225
250
  }
226
251
 
227
- const overflowAmount = showOverflowWarning ? contentHeight - SNAP_MAX_HEIGHT : 0;
252
+ const overflowAmount = expansion.showOverflowWarning
253
+ ? contentHeight - SNAP_MAX_HEIGHT
254
+ : 0;
228
255
  const isDark = mode === "dark";
229
256
  const pillBg = isDark ? "rgba(40,40,40,0.92)" : "rgba(255,255,255,0.92)";
230
257
  const pillBgPressed = isDark ? "rgba(60,60,60,0.95)" : "rgba(240,240,240,0.95)";
231
258
 
232
259
  return (
233
- <View style={{ paddingBottom: isExpandable ? SHOW_MORE_OVERHANG : 0 }}>
260
+ <View
261
+ style={{
262
+ paddingBottom:
263
+ expansion.showButton && !expandButtonInsideCard ? SHOW_MORE_OVERHANG : 0,
264
+ }}
265
+ >
234
266
  <View style={{ position: "relative" }}>
235
267
  <View
236
268
  style={{
@@ -238,7 +270,9 @@ function SnapCardV2Inner({
238
270
  borderWidth: 1,
239
271
  borderColor: colors.border,
240
272
  backgroundColor: colors.surface,
241
- maxHeight: showOverflowWarning ? undefined : isClipped ? SNAP_MAX_HEIGHT : undefined,
273
+ maxHeight: expansion.showOverflowWarning
274
+ ? undefined
275
+ : expansion.maxHeight,
242
276
  overflow: "hidden",
243
277
  minHeight: 120,
244
278
  }}
@@ -248,7 +282,7 @@ function SnapCardV2Inner({
248
282
  onLayout={(e) => {
249
283
  const nextHeight = Math.round(e.nativeEvent.layout.height);
250
284
  setContentHeight((current) =>
251
- isClipped
285
+ expansion.clipped
252
286
  ? Math.max(current, nextHeight)
253
287
  : current === nextHeight
254
288
  ? current
@@ -259,7 +293,7 @@ function SnapCardV2Inner({
259
293
  >
260
294
  {content}
261
295
  </View>
262
- {showOverflowWarning && contentHeight > SNAP_MAX_HEIGHT && (
296
+ {expansion.showOverflowWarning && contentHeight > SNAP_MAX_HEIGHT && (
263
297
  <View style={{ position: "absolute", top: SNAP_MAX_HEIGHT, left: 0, right: 0, height: overflowAmount, zIndex: 10, pointerEvents: "none" }}>
264
298
  <View style={{ height: 1, borderTopWidth: 1, borderStyle: "dashed", borderColor: "rgba(255,100,100,0.6)" }} />
265
299
  <View style={{ position: "absolute", top: -10, right: 4, backgroundColor: "rgba(0,0,0,0.7)", paddingHorizontal: 4, paddingVertical: 1, borderRadius: 3 }}>
@@ -274,8 +308,15 @@ function SnapCardV2Inner({
274
308
  : loadingOverlay
275
309
  : null}
276
310
  </View>
277
- {isExpandable ? (
278
- <View pointerEvents="box-none" style={cardStyles.expandFloat}>
311
+ {expansion.showButton ? (
312
+ <View
313
+ pointerEvents="box-none"
314
+ style={
315
+ expandButtonInsideCard
316
+ ? cardStyles.expandFloatInset
317
+ : cardStyles.expandFloat
318
+ }
319
+ >
279
320
  <Pressable
280
321
  style={({ pressed }) => [
281
322
  cardStyles.expandButton,
@@ -284,10 +325,16 @@ function SnapCardV2Inner({
284
325
  borderColor: colors.border,
285
326
  },
286
327
  ]}
287
- onPress={() => setIsExpanded((value) => !value)}
328
+ onPress={() => {
329
+ if (expansion.useInternalToggle) {
330
+ setIsExpanded((value) => !value);
331
+ } else {
332
+ onExpandPress?.();
333
+ }
334
+ }}
288
335
  >
289
336
  <Text style={[cardStyles.expandButtonText, { color: colors.text }]}>
290
- {isExpanded ? "Show less" : "Show more"}
337
+ {expansion.buttonLabel}
291
338
  </Text>
292
339
  </Pressable>
293
340
  </View>
@@ -325,6 +372,9 @@ export function SnapCardV2({
325
372
  actionError,
326
373
  plain = false,
327
374
  loadingOverlay,
375
+ forceExpanded,
376
+ expandButtonLabel,
377
+ onExpandPress,
328
378
  }: {
329
379
  snap: SnapPage;
330
380
  handlers: SnapActionHandlers;
@@ -339,6 +389,12 @@ export function SnapCardV2({
339
389
  plain?: boolean;
340
390
  /** Custom content rendered while `loading` is true. Pass `null` to render nothing. */
341
391
  loadingOverlay?: ReactNode;
392
+ /** When true, render full content height without 500px clipping or expand controls. */
393
+ forceExpanded?: boolean;
394
+ /** Custom label for the collapsed expand button. */
395
+ expandButtonLabel?: string;
396
+ /** Called from the collapsed expand button instead of toggling internal state. */
397
+ onExpandPress?: () => void;
342
398
  }) {
343
399
  return (
344
400
  <SnapThemeProvider appearance={appearance} colors={colors}>
@@ -354,6 +410,9 @@ export function SnapCardV2({
354
410
  appearance={appearance}
355
411
  plain={plain}
356
412
  loadingOverlay={loadingOverlay}
413
+ forceExpanded={forceExpanded}
414
+ expandButtonLabel={expandButtonLabel}
415
+ onExpandPress={onExpandPress}
357
416
  />
358
417
  </SnapThemeProvider>
359
418
  );
@@ -373,6 +432,18 @@ const cardStyles = StyleSheet.create({
373
432
  alignItems: "center",
374
433
  justifyContent: "center",
375
434
  },
435
+ expandFloatInset: {
436
+ position: "absolute",
437
+ left: 0,
438
+ right: 0,
439
+ bottom: 10,
440
+ height: 28,
441
+ alignItems: "center",
442
+ justifyContent: "center",
443
+ },
444
+ expandRow: {
445
+ alignItems: "center",
446
+ },
376
447
  expandRowPlain: {
377
448
  paddingTop: 8,
378
449
  alignItems: "center",
package/src/ui/catalog.ts CHANGED
@@ -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";
@@ -72,7 +73,13 @@ export const snapJsonRenderCatalog = defineCatalog(snapJsonRenderSchema, {
72
73
  },
73
74
  image: {
74
75
  props: imageProps,
75
- description: "HTTPS image with fixed aspect ratio.",
76
+ description:
77
+ "HTTPS image with fixed aspect ratio. Supports compact 4:1 banners and optional title/subtitle overlay text.",
78
+ },
79
+ paginator: {
80
+ props: paginatorProps,
81
+ description:
82
+ "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.",
76
83
  },
77
84
  progress: {
78
85
  props: progressProps,
@@ -97,7 +104,7 @@ export const snapJsonRenderCatalog = defineCatalog(snapJsonRenderSchema, {
97
104
  text: {
98
105
  props: textProps,
99
106
  description:
100
- "Text block — size: md (body, default), sm (caption). Optional weight and align.",
107
+ "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.",
101
108
  },
102
109
  bar_chart: {
103
110
  props: barChartProps,
@@ -107,7 +114,7 @@ export const snapJsonRenderCatalog = defineCatalog(snapJsonRenderSchema, {
107
114
  cell_grid: {
108
115
  props: cellGridProps,
109
116
  description:
110
- "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.",
117
+ "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.",
111
118
  },
112
119
  },
113
120
  actions: {
@@ -165,5 +172,20 @@ export const snapJsonRenderCatalog = defineCatalog(snapJsonRenderSchema, {
165
172
  buyToken: z.string().optional(),
166
173
  }),
167
174
  },
175
+ paginator_next: {
176
+ description:
177
+ "Move the snap's paginator to the next page locally. Does not POST and is ignored when no paginator is rendered.",
178
+ params: z.object({ page: z.number().int().min(0).optional() }),
179
+ },
180
+ paginator_prev: {
181
+ description:
182
+ "Move the snap's paginator to the previous page locally. Does not POST and is ignored when no paginator is rendered.",
183
+ params: z.object({ page: z.number().int().min(0).optional() }),
184
+ },
185
+ paginator_go_to: {
186
+ description:
187
+ "Move the snap's paginator to a specific zero-based page index locally. Does not POST and is ignored when no paginator is rendered.",
188
+ params: z.object({ page: z.number().int().min(0) }),
189
+ },
168
190
  },
169
191
  });
@@ -7,6 +7,7 @@ import {
7
7
  GRID_MAX_ROWS,
8
8
  GRID_GAP_VALUES,
9
9
  GRID_CELL_ASPECT_RATIO_VALUES,
10
+ GRID_MAX_WIDTH_VALUES,
10
11
  } from "../constants.js";
11
12
 
12
13
  /** Palette name or `#rrggbb`; input is trimmed so palette and hex rules match runtime resolvers. */
@@ -38,6 +39,7 @@ export const cellGridProps = z
38
39
  gap: z.enum(GRID_GAP_VALUES).optional(),
39
40
  cellAspectRatio: z.enum(GRID_CELL_ASPECT_RATIO_VALUES).optional(),
40
41
  rowHeight: z.number().int().min(8).max(64).optional(),
42
+ maxWidth: z.enum(GRID_MAX_WIDTH_VALUES).optional(),
41
43
  select: z.enum(["off", "single", "multiple"]).optional(),
42
44
  })
43
45
  .superRefine((val, ctx) => {
package/src/ui/image.ts CHANGED
@@ -1,11 +1,13 @@
1
1
  import { z } from "zod";
2
2
 
3
- export const IMAGE_ASPECTS = ["1:1", "16:9", "4:3", "9:16"] as const;
3
+ export const IMAGE_ASPECTS = ["1:1", "16:9", "4:3", "9:16", "4:1"] as const;
4
4
 
5
5
  export const imageProps = z.object({
6
6
  url: z.string(),
7
7
  aspect: z.enum(IMAGE_ASPECTS),
8
8
  alt: z.string().optional(),
9
+ title: z.string().min(1).max(80).optional(),
10
+ subtitle: z.string().min(1).max(120).optional(),
9
11
  });
10
12
 
11
13
  export type ImageProps = z.infer<typeof imageProps>;
package/src/ui/index.ts CHANGED
@@ -28,6 +28,9 @@ export type { IconProps } from "./icon.js";
28
28
  export { imageProps } from "./image.js";
29
29
  export type { ImageProps } from "./image.js";
30
30
 
31
+ export { paginatorProps } from "./paginator.js";
32
+ export type { PaginatorProps } from "./paginator.js";
33
+
31
34
  export { progressProps } from "./progress.js";
32
35
  export type { ProgressProps } from "./progress.js";
33
36
 
@@ -0,0 +1,67 @@
1
+ export const SNAP_PAGINATOR_PAGE_PATH = "/ui/paginator/page";
2
+ export const SNAP_PAGINATOR_PAGE_COUNT_PATH = "/ui/paginator/pageCount";
3
+
4
+ export type SnapPaginatorAction =
5
+ | { action: "paginator_next" | "paginator_prev" }
6
+ | { action: "paginator_go_to"; page?: number };
7
+
8
+ type StateStoreAccess = {
9
+ get: (path: string) => unknown;
10
+ set: (path: string, value: unknown) => void;
11
+ };
12
+
13
+ export function clampPaginatorPage(value: number, pageCount: number): number {
14
+ return Math.min(Math.max(value, 0), Math.max(pageCount - 1, 0));
15
+ }
16
+
17
+ export function pageFromValue(value: unknown, fallback = 0): number {
18
+ return typeof value === "number" && Number.isInteger(value)
19
+ ? value
20
+ : fallback;
21
+ }
22
+
23
+ export function pageCountFromValue(value: unknown): number {
24
+ return typeof value === "number" && Number.isInteger(value) && value > 0
25
+ ? value
26
+ : 0;
27
+ }
28
+
29
+ export function getPaginatorAction(
30
+ on: Record<string, unknown> | undefined,
31
+ ): SnapPaginatorAction | null {
32
+ const press = on?.press as
33
+ | { action?: string; params?: Record<string, unknown> }
34
+ | undefined;
35
+ if (!press) return null;
36
+ if (press.action === "paginator_next") return { action: "paginator_next" };
37
+ if (press.action === "paginator_prev") return { action: "paginator_prev" };
38
+ if (press.action === "paginator_go_to") {
39
+ const page = press.params?.page;
40
+ return {
41
+ action: "paginator_go_to",
42
+ page: typeof page === "number" && Number.isInteger(page) ? page : 0,
43
+ };
44
+ }
45
+ return null;
46
+ }
47
+
48
+ export function runPaginatorAction(
49
+ store: StateStoreAccess,
50
+ action: SnapPaginatorAction | null,
51
+ ): boolean {
52
+ if (!action) return false;
53
+ const pageCount = pageCountFromValue(store.get(SNAP_PAGINATOR_PAGE_COUNT_PATH));
54
+ if (pageCount === 0) return false;
55
+ const currentPage = clampPaginatorPage(
56
+ pageFromValue(store.get(SNAP_PAGINATOR_PAGE_PATH), 0),
57
+ pageCount,
58
+ );
59
+ const nextPage =
60
+ action.action === "paginator_next"
61
+ ? currentPage + 1
62
+ : action.action === "paginator_prev"
63
+ ? currentPage - 1
64
+ : (action as { action: "paginator_go_to"; page?: number }).page ?? 0;
65
+ store.set(SNAP_PAGINATOR_PAGE_PATH, clampPaginatorPage(nextPage, pageCount));
66
+ return true;
67
+ }
@@ -0,0 +1,11 @@
1
+ import { z } from "zod";
2
+
3
+ export const paginatorProps = z.object({
4
+ initialPage: z.number().int().min(0).optional(),
5
+ showIndicators: z.boolean().optional(),
6
+ showControls: z.boolean().optional(),
7
+ controlsPosition: z.enum(["top", "bottom"]).optional(),
8
+ transition: z.enum(["slide", "fade", "scale", "none"]).optional(),
9
+ });
10
+
11
+ export type PaginatorProps = z.infer<typeof paginatorProps>;
package/src/ui/text.ts CHANGED
@@ -10,6 +10,7 @@ export const textProps = z.object({
10
10
  size: z.enum(TEXT_SIZES).optional(),
11
11
  weight: z.enum(TEXT_WEIGHTS).optional(),
12
12
  align: z.enum(TEXT_ALIGNS).optional(),
13
+ maxLines: z.number().int().min(1).max(6).optional(),
13
14
  });
14
15
 
15
16
  export type TextProps = z.infer<typeof textProps>;
package/src/validator.ts CHANGED
@@ -119,9 +119,14 @@ function validateStructure(ui: {
119
119
  });
120
120
  }
121
121
 
122
- // Root element has a stricter children limit
122
+ // Root element has a stricter children limit. Paginator pages are intentionally
123
+ // allowed to exceed per-container child caps while preserving global limits.
123
124
  const rootEl = elements[ui.root];
124
- if (rootEl?.children && rootEl.children.length > MAX_ROOT_CHILDREN) {
125
+ if (
126
+ rootEl?.type !== "paginator" &&
127
+ rootEl?.children &&
128
+ rootEl.children.length > MAX_ROOT_CHILDREN
129
+ ) {
125
130
  issues.push({
126
131
  code: "custom",
127
132
  message: `Root element "${ui.root}" exceeds maximum of ${MAX_ROOT_CHILDREN} children (found ${rootEl.children.length})`,
@@ -131,7 +136,7 @@ function validateStructure(ui: {
131
136
 
132
137
  for (const [id, el] of Object.entries(elements)) {
133
138
  if (id === ui.root) continue; // already checked above
134
- if (el.children && el.children.length > MAX_CHILDREN) {
139
+ if (el.type !== "paginator" && el.children && el.children.length > MAX_CHILDREN) {
135
140
  issues.push({
136
141
  code: "custom",
137
142
  message: `Element "${id}" exceeds maximum of ${MAX_CHILDREN} children (found ${el.children.length})`,
@@ -140,6 +145,17 @@ function validateStructure(ui: {
140
145
  }
141
146
  }
142
147
 
148
+ const paginatorIds = Object.entries(elements)
149
+ .filter(([, el]) => el.type === "paginator")
150
+ .map(([id]) => id);
151
+ if (paginatorIds.length > 1) {
152
+ issues.push({
153
+ code: "custom",
154
+ message: `Snap supports at most one paginator (found ${paginatorIds.length}: ${paginatorIds.join(", ")})`,
155
+ path: ["ui", "elements"],
156
+ });
157
+ }
158
+
143
159
  const depth = measureDepth(
144
160
  elements as Record<string, { children?: string[] }>,
145
161
  ui.root,