@farcaster/snap 2.5.1 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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 +19 -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 +10 -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 +20 -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 +11 -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
@@ -1,10 +1,15 @@
1
1
  "use client";
2
2
 
3
3
  import { useState } from "react";
4
+ import { useStateStore } from "@json-render/react";
4
5
  import { ExternalLink } from "lucide-react";
5
6
  import { Button } from "@neynar/ui/button";
6
7
  import { cn } from "@neynar/ui/utils";
7
8
  import { useSnapColors } from "../hooks/use-snap-colors";
9
+ import {
10
+ getPaginatorAction,
11
+ runPaginatorAction,
12
+ } from "../../ui/paginator-state";
8
13
  import { useSnapStackDirection } from "../stack-direction-context";
9
14
  import { ICON_MAP } from "./icon";
10
15
 
@@ -36,6 +41,8 @@ export function SnapActionButton({
36
41
  const iconName = props.icon ? String(props.icon) : undefined;
37
42
  const colors = useSnapColors();
38
43
  const [hovered, setHovered] = useState(false);
44
+ const stateStore = useStateStore();
45
+ const paginatorAction = getPaginatorAction(element.on);
39
46
 
40
47
  const Icon = iconName ? ICON_MAP[iconName] : undefined;
41
48
  const showExternalIcon = isExternalLinkAction(element.on);
@@ -75,9 +82,13 @@ export function SnapActionButton({
75
82
  <Button
76
83
  type="button"
77
84
  variant={isPrimary ? "default" : "secondary"}
78
- className={cn("w-full gap-2")}
85
+ className={cn("h-8 w-full gap-2 px-3 text-sm")}
79
86
  style={style}
80
- onClick={() => emit("press")}
87
+ onClick={() => {
88
+ if (!runPaginatorAction(stateStore, paginatorAction)) {
89
+ emit("press");
90
+ }
91
+ }}
81
92
  onPointerEnter={() => setHovered(true)}
82
93
  onPointerLeave={() => setHovered(false)}
83
94
  >
@@ -5,6 +5,10 @@ import { useStateStore } from "@json-render/react";
5
5
  import { cn } from "@neynar/ui/utils";
6
6
  import { POST_GRID_TAP_KEY, readableTextOnHex } from "@farcaster/snap";
7
7
  import { useSnapColors } from "../hooks/use-snap-colors";
8
+ import {
9
+ getPaginatorAction,
10
+ runPaginatorAction,
11
+ } from "../../ui/paginator-state";
8
12
 
9
13
  export function SnapCellGrid({
10
14
  element: { props, on },
@@ -13,8 +17,10 @@ export function SnapCellGrid({
13
17
  element: { props: Record<string, unknown>; on?: Record<string, unknown> };
14
18
  emit: (name: string) => void;
15
19
  }) {
16
- const { get, set } = useStateStore();
20
+ const stateStore = useStateStore();
21
+ const { get, set } = stateStore;
17
22
  const colors = useSnapColors();
23
+ const paginatorAction = getPaginatorAction(on);
18
24
  const cols = Number(props.cols ?? 2);
19
25
  const rows = Number(props.rows ?? 2);
20
26
  const select = String(props.select ?? "off");
@@ -28,6 +34,13 @@ export function SnapCellGrid({
28
34
  const gapPx = gapMap[gap] ?? 1;
29
35
  const rowHeight = typeof props.rowHeight === "number" ? props.rowHeight : 28;
30
36
  const squareCells = props.cellAspectRatio === "square";
37
+ const maxWidthKey = typeof props.maxWidth === "string" ? props.maxWidth : undefined;
38
+ const maxWidthMap: Record<string, number | undefined> = {
39
+ sm: 160,
40
+ md: 220,
41
+ lg: undefined,
42
+ };
43
+ const maxWidth = maxWidthKey ? maxWidthMap[maxWidthKey] : undefined;
31
44
 
32
45
  const name = props.name ? String(props.name) : POST_GRID_TAP_KEY;
33
46
  const tapPath = `/inputs/${name}`;
@@ -75,7 +88,12 @@ export function SnapCellGrid({
75
88
  } else {
76
89
  set(tapPath, wire);
77
90
  }
78
- if (hasPressAction) emit("press");
91
+ if (
92
+ hasPressAction &&
93
+ !runPaginatorAction(stateStore, paginatorAction)
94
+ ) {
95
+ emit("press");
96
+ }
79
97
  };
80
98
 
81
99
  /** Cells without a palette `color` — subtle fill so empty slots read as tiles. */
@@ -138,6 +156,8 @@ export function SnapCellGrid({
138
156
  style={{
139
157
  display: "grid",
140
158
  width: "100%",
159
+ maxWidth,
160
+ marginInline: maxWidth ? "auto" : undefined,
141
161
  gridTemplateColumns: `repeat(${cols}, 1fr)`,
142
162
  gap: gapPx,
143
163
  padding: 4,
@@ -17,6 +17,9 @@ export function SnapImage({
17
17
  }) {
18
18
  const url = String(props.url ?? "");
19
19
  const alt = String(props.alt ?? "");
20
+ const title = props.title ? String(props.title) : "";
21
+ const subtitle = props.subtitle ? String(props.subtitle) : "";
22
+ const hasOverlay = title.length > 0 || subtitle.length > 0;
20
23
  const ratio = aspectToRatio(String(props.aspect ?? "1:1"));
21
24
  const stackDir = useSnapStackDirection();
22
25
  const inHorizontalStack = stackDir === "horizontal";
@@ -35,6 +38,20 @@ export function SnapImage({
35
38
  alt={alt}
36
39
  className="absolute inset-0 size-full object-cover"
37
40
  />
41
+ {hasOverlay && (
42
+ <div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/75 via-black/35 to-transparent p-3 pt-8 text-white">
43
+ {title && (
44
+ <div className="truncate text-sm font-semibold leading-5">
45
+ {title}
46
+ </div>
47
+ )}
48
+ {subtitle && (
49
+ <div className="truncate text-xs font-medium leading-4 text-white/85">
50
+ {subtitle}
51
+ </div>
52
+ )}
53
+ </div>
54
+ )}
38
55
  </AspectRatio>
39
56
  );
40
57
  }
@@ -0,0 +1,208 @@
1
+ "use client";
2
+
3
+ import {
4
+ Children,
5
+ type CSSProperties,
6
+ type ReactNode,
7
+ useEffect,
8
+ useMemo,
9
+ useState,
10
+ } from "react";
11
+ import { useStateStore } from "@json-render/react";
12
+ import { ChevronLeft, ChevronRight } from "lucide-react";
13
+ import { cn } from "@neynar/ui/utils";
14
+ import { useSnapColors } from "../hooks/use-snap-colors";
15
+ import {
16
+ clampPaginatorPage,
17
+ pageFromValue,
18
+ SNAP_PAGINATOR_PAGE_COUNT_PATH,
19
+ SNAP_PAGINATOR_PAGE_PATH,
20
+ } from "../../ui/paginator-state";
21
+
22
+ function clampInitialPage(value: unknown, pageCount: number): number {
23
+ if (typeof value !== "number" || !Number.isInteger(value)) return 0;
24
+ return Math.min(Math.max(value, 0), Math.max(pageCount - 1, 0));
25
+ }
26
+
27
+ export function SnapPaginator({
28
+ element: { props },
29
+ children,
30
+ }: {
31
+ element: { props: Record<string, unknown> };
32
+ children?: ReactNode;
33
+ }) {
34
+ const pages = useMemo(
35
+ () => Children.toArray(children),
36
+ [children],
37
+ );
38
+ const colors = useSnapColors();
39
+ const { get, set } = useStateStore();
40
+ const initialPage = clampInitialPage(props.initialPage, pages.length);
41
+ const page = clampPaginatorPage(
42
+ pageFromValue(get(SNAP_PAGINATOR_PAGE_PATH), initialPage),
43
+ pages.length,
44
+ );
45
+ const activePage = Math.min(page, Math.max(pages.length - 1, 0));
46
+ const showControls = props.showControls !== false && pages.length > 1;
47
+ const showIndicators = props.showIndicators !== false && pages.length > 1;
48
+ const controlsPosition = props.controlsPosition === "top" ? "top" : "bottom";
49
+ const transition =
50
+ props.transition === "fade" ||
51
+ props.transition === "scale" ||
52
+ props.transition === "none"
53
+ ? props.transition
54
+ : "slide";
55
+ const showControlBar = showControls || showIndicators;
56
+ const [transitionDirection, setTransitionDirection] =
57
+ useState<"next" | "previous">("next");
58
+
59
+ const canGoPrev = activePage > 0;
60
+ const canGoNext = activePage < pages.length - 1;
61
+ const goToPage = (targetPage: number) => {
62
+ const nextPage = clampPaginatorPage(targetPage, pages.length);
63
+ if (nextPage !== activePage) {
64
+ setTransitionDirection(nextPage > activePage ? "next" : "previous");
65
+ }
66
+ set(SNAP_PAGINATOR_PAGE_PATH, nextPage);
67
+ };
68
+ const goPrev = () => goToPage(activePage - 1);
69
+ const goNext = () => goToPage(activePage + 1);
70
+
71
+ useEffect(() => {
72
+ if (pages.length === 0) return;
73
+ const nextPage = clampPaginatorPage(
74
+ pageFromValue(get(SNAP_PAGINATOR_PAGE_PATH), initialPage),
75
+ pages.length,
76
+ );
77
+ if (get(SNAP_PAGINATOR_PAGE_PATH) !== nextPage) {
78
+ set(SNAP_PAGINATOR_PAGE_PATH, nextPage);
79
+ }
80
+ if (get(SNAP_PAGINATOR_PAGE_COUNT_PATH) !== pages.length) {
81
+ set(SNAP_PAGINATOR_PAGE_COUNT_PATH, pages.length);
82
+ }
83
+ }, [get, initialPage, pages.length, set]);
84
+
85
+ if (pages.length === 0) return null;
86
+
87
+ const pageAnimation =
88
+ transition === "none"
89
+ ? undefined
90
+ : transition === "fade"
91
+ ? "snapPaginatorFade 180ms ease-out"
92
+ : transition === "scale"
93
+ ? "snapPaginatorScale 240ms cubic-bezier(0.16, 1, 0.3, 1)"
94
+ : "snapPaginatorSlide 260ms cubic-bezier(0.16, 1, 0.3, 1)";
95
+
96
+ const controlBar = showControlBar ? (
97
+ <div className="flex min-h-7 w-full items-center justify-between gap-2">
98
+ {showControls ? (
99
+ <button
100
+ type="button"
101
+ aria-label="Previous page"
102
+ disabled={!canGoPrev}
103
+ onClick={goPrev}
104
+ className={cn(
105
+ "inline-flex size-7 items-center justify-center rounded-md border text-sm transition-opacity",
106
+ canGoPrev ? "cursor-pointer opacity-100" : "cursor-default opacity-35",
107
+ )}
108
+ style={{
109
+ borderColor: colors.border,
110
+ backgroundColor: colors.muted,
111
+ color: colors.text,
112
+ }}
113
+ >
114
+ <ChevronLeft size={15} />
115
+ </button>
116
+ ) : (
117
+ <span />
118
+ )}
119
+
120
+ {showIndicators ? (
121
+ <div className="flex flex-1 items-center justify-center gap-1.5">
122
+ {pages.map((_, index) => {
123
+ const current = index === activePage;
124
+ return (
125
+ <span
126
+ key={index}
127
+ aria-label={`Page ${index + 1}${current ? ", current" : ""}`}
128
+ className={cn(
129
+ "block rounded-full",
130
+ current ? "size-2.5" : "size-2",
131
+ )}
132
+ style={{
133
+ backgroundColor: current
134
+ ? colors.accent
135
+ : colors.mode === "dark"
136
+ ? "rgba(255,255,255,0.5)"
137
+ : "rgba(0,0,0,0.28)",
138
+ boxShadow: current
139
+ ? `0 0 0 2px ${colors.mode === "dark" ? "rgba(255,255,255,0.18)" : "rgba(0,0,0,0.12)"}`
140
+ : undefined,
141
+ }}
142
+ />
143
+ );
144
+ })}
145
+ </div>
146
+ ) : (
147
+ <span className="flex-1" />
148
+ )}
149
+
150
+ {showControls ? (
151
+ <button
152
+ type="button"
153
+ aria-label="Next page"
154
+ disabled={!canGoNext}
155
+ onClick={goNext}
156
+ className={cn(
157
+ "inline-flex size-7 items-center justify-center rounded-md border text-sm transition-opacity",
158
+ canGoNext ? "cursor-pointer opacity-100" : "cursor-default opacity-35",
159
+ )}
160
+ style={{
161
+ borderColor: colors.border,
162
+ backgroundColor: colors.muted,
163
+ color: colors.text,
164
+ }}
165
+ >
166
+ <ChevronRight size={15} />
167
+ </button>
168
+ ) : (
169
+ <span />
170
+ )}
171
+ </div>
172
+ ) : null;
173
+
174
+ return (
175
+ <div className="flex w-full min-w-0 flex-col gap-2">
176
+ {controlsPosition === "top" ? controlBar : null}
177
+ <div
178
+ key={activePage}
179
+ data-snap-paginator-page
180
+ className="w-full min-w-0"
181
+ style={{
182
+ "--snap-paginator-x": transitionDirection === "previous" ? "-22px" : "22px",
183
+ animation: pageAnimation,
184
+ } as CSSProperties}
185
+ >
186
+ {pages[activePage]}
187
+ </div>
188
+ {controlsPosition === "bottom" ? controlBar : null}
189
+ <style>{`
190
+ @keyframes snapPaginatorSlide {
191
+ from { opacity: 0.35; transform: translateX(var(--snap-paginator-x, 22px)) scale(0.985); }
192
+ to { opacity: 1; transform: translateX(0) scale(1); }
193
+ }
194
+ @keyframes snapPaginatorFade {
195
+ from { opacity: 0.2; }
196
+ to { opacity: 1; }
197
+ }
198
+ @keyframes snapPaginatorScale {
199
+ from { opacity: 0.25; transform: scale(0.94); }
200
+ to { opacity: 1; transform: scale(1); }
201
+ }
202
+ @media (prefers-reduced-motion: reduce) {
203
+ [data-snap-paginator-page] { animation: none !important; }
204
+ }
205
+ `}</style>
206
+ </div>
207
+ );
208
+ }
@@ -13,18 +13,18 @@ import {
13
13
  useSnapStackDirection,
14
14
  } from "../stack-direction-context";
15
15
 
16
- const VGAP: Record<string, string> = {
17
- none: "gap-0",
18
- sm: "gap-1",
19
- md: "gap-4",
20
- lg: "gap-6",
16
+ const VGAP: Record<string, number> = {
17
+ none: 0,
18
+ sm: 4,
19
+ md: 16,
20
+ lg: 24,
21
21
  };
22
22
 
23
- const HGAP: Record<string, string> = {
24
- none: "gap-0",
25
- sm: "gap-1",
26
- md: "gap-2",
27
- lg: "gap-4",
23
+ const HGAP: Record<string, number> = {
24
+ none: 0,
25
+ sm: 4,
26
+ md: 8,
27
+ lg: 16,
28
28
  };
29
29
 
30
30
  const JUSTIFY_FLEX: Record<string, string> = {
@@ -100,7 +100,7 @@ export function SnapStack({
100
100
  : isHorizontal
101
101
  ? defaultHorizontalGapSize(horizontalChildCount)
102
102
  : "md";
103
- const gap = isHorizontal
103
+ const gapPx = isHorizontal
104
104
  ? (HGAP[gapKey] ?? HGAP.md!)
105
105
  : (VGAP[gapKey] ?? VGAP.md!);
106
106
  const columnGridClass =
@@ -125,13 +125,15 @@ export function SnapStack({
125
125
  /** Single flex row (nowrap): peers stay side-by-side and shrink via min-w-0 / flex-1 on nested stacks. */
126
126
  const horizontalFlexClasses =
127
127
  "flex min-w-0 flex-row flex-nowrap items-stretch [&>*]:min-w-0";
128
- const equalWidthStyle: CSSProperties | undefined =
129
- explicitEqualWidth && equalWidthColumnCount !== undefined
128
+ const layoutStyle: CSSProperties = {
129
+ gap: gapPx,
130
+ ...(explicitEqualWidth && equalWidthColumnCount !== undefined
130
131
  ? {
131
132
  display: "grid",
132
133
  gridTemplateColumns: `repeat(${equalWidthColumnCount}, minmax(0, 1fr))`,
133
134
  }
134
- : undefined;
135
+ : {}),
136
+ };
135
137
 
136
138
  return (
137
139
  <SnapStackDirectionProvider
@@ -142,11 +144,11 @@ export function SnapStack({
142
144
  rootWidthClass,
143
145
  isHorizontal
144
146
  ? explicitEqualWidth && columnGridClass
145
- ? cn(columnGridClass, gap)
146
- : cn(horizontalFlexClasses, gap, justifyBlockGrid ? justifyFlex : undefined)
147
- : cn("flex min-w-0 w-full flex-col", gap, justifyFlex),
147
+ ? columnGridClass
148
+ : cn(horizontalFlexClasses, justifyBlockGrid ? justifyFlex : undefined)
149
+ : cn("flex min-w-0 w-full flex-col", justifyFlex),
148
150
  )}
149
- style={equalWidthStyle}
151
+ style={layoutStyle}
150
152
  >
151
153
  {children}
152
154
  </div>
@@ -4,6 +4,7 @@ import { Text } from "@neynar/ui/typography";
4
4
  import { cn } from "@neynar/ui/utils";
5
5
  import { useSnapColors } from "../hooks/use-snap-colors";
6
6
  import { useSnapStackDirection } from "../stack-direction-context";
7
+ import { useSnapVersion } from "../snap-version-context";
7
8
 
8
9
  const SIZE_MAP = {
9
10
  md: { textSize: "base" as const },
@@ -22,7 +23,14 @@ export function SnapText({
22
23
  const config = SIZE_MAP[size] ?? SIZE_MAP.md;
23
24
  const colors = useSnapColors();
24
25
  const stackDir = useSnapStackDirection();
26
+ const snapVersion = useSnapVersion();
25
27
  const inHorizontalStack = stackDir === "horizontal";
28
+ const maxLines =
29
+ typeof props.maxLines === "number"
30
+ ? props.maxLines
31
+ : snapVersion === "2.0"
32
+ ? 1
33
+ : undefined;
26
34
 
27
35
  return (
28
36
  <Text
@@ -40,7 +48,18 @@ export function SnapText({
40
48
  */
41
49
  inHorizontalStack ? "min-w-0 shrink" : "min-w-0",
42
50
  )}
43
- style={{ color: colors.text }}
51
+ style={{
52
+ color: colors.text,
53
+ lineHeight: size === "sm" ? 1.35 : 1.4,
54
+ ...(maxLines
55
+ ? {
56
+ display: "-webkit-box",
57
+ WebkitBoxOrient: "vertical",
58
+ WebkitLineClamp: maxLines,
59
+ overflow: "hidden",
60
+ }
61
+ : {}),
62
+ }}
44
63
  >
45
64
  {content}
46
65
  </Text>
@@ -0,0 +1,12 @@
1
+ "use client";
2
+
3
+ import { createContext, useContext } from "react";
4
+ import type { SpecVersion } from "../constants.js";
5
+
6
+ const SnapVersionContext = createContext<SpecVersion>("1.0");
7
+
8
+ export const SnapVersionProvider = SnapVersionContext.Provider;
9
+
10
+ export function useSnapVersion(): SpecVersion {
11
+ return useContext(SnapVersionContext);
12
+ }
@@ -4,6 +4,7 @@ import type { Spec } from "@json-render/core";
4
4
  import { snapJsonRenderCatalog } from "../ui/index.js";
5
5
  import { SnapCatalogView } from "./catalog-renderer";
6
6
  import { SnapPreviewAccentProvider } from "./accent-context";
7
+ import { SnapVersionProvider } from "./snap-version-context";
7
8
  import { resolveSnapPaletteHex } from "./lib/resolve-palette-hex";
8
9
  import { snapPreviewPrimaryCssProperties } from "./lib/preview-primary-css";
9
10
  import {
@@ -21,8 +22,13 @@ import type { JsonValue, SnapActionHandlers, SnapPage } from "./index";
21
22
 
22
23
  export function applyStatePaths(
23
24
  model: Record<string, unknown>,
24
- changes: { path: string; value: unknown }[] | Record<string, unknown>,
25
+ changes:
26
+ | { path: string; value: unknown }[]
27
+ | Record<string, unknown>
28
+ | null
29
+ | undefined,
25
30
  ): void {
31
+ if (!changes) return;
26
32
  const entries = Array.isArray(changes)
27
33
  ? changes.map((c) => [c.path, c.value] as const)
28
34
  : Object.entries(changes);
@@ -53,6 +59,30 @@ export function applyStatePaths(
53
59
  }
54
60
  }
55
61
 
62
+ function withDefaultElementProps(spec: Spec): Spec {
63
+ if (!spec || typeof spec !== "object" || !("elements" in spec)) return spec;
64
+ const elements = spec.elements as unknown as Record<
65
+ string,
66
+ Record<string, unknown>
67
+ >;
68
+ if (!elements || typeof elements !== "object") return spec;
69
+
70
+ let changed = false;
71
+ const nextElements: Record<string, Record<string, unknown>> = {};
72
+ for (const [id, element] of Object.entries(elements)) {
73
+ if (element.props !== undefined) {
74
+ nextElements[id] = element;
75
+ continue;
76
+ }
77
+ changed = true;
78
+ nextElements[id] = { ...element, props: {} };
79
+ }
80
+
81
+ return changed
82
+ ? ({ ...spec, elements: nextElements } as unknown as Spec)
83
+ : spec;
84
+ }
85
+
56
86
  const CONFETTI_COLORS = [
57
87
  "#907AA9",
58
88
  "#EC4899",
@@ -330,7 +360,7 @@ export function SnapViewCore({
330
360
  */
331
361
  loadingOverlay?: ReactNode;
332
362
  }) {
333
- const spec = snap.ui;
363
+ const spec = useMemo(() => withDefaultElementProps(snap.ui), [snap.ui]);
334
364
  const initialState = useMemo(() => spec.state ?? { inputs: {} }, [spec]);
335
365
 
336
366
  const stateRef = useRef<Record<string, unknown>>(initialState);
@@ -465,16 +495,18 @@ export function SnapViewCore({
465
495
  pageAccent={snap.theme?.accent}
466
496
  appearance={appearance}
467
497
  >
468
- <SnapCatalogView
469
- key={pageKey}
470
- spec={spec}
471
- state={initialState}
472
- loading={false}
473
- onStateChange={(changes) => {
474
- applyStatePaths(stateRef.current, changes);
475
- }}
476
- onAction={handleAction}
477
- />
498
+ <SnapVersionProvider value={snap.version === "2.0" ? "2.0" : "1.0"}>
499
+ <SnapCatalogView
500
+ key={pageKey}
501
+ spec={spec}
502
+ state={initialState}
503
+ loading={false}
504
+ onStateChange={(changes) => {
505
+ applyStatePaths(stateRef.current, changes);
506
+ }}
507
+ onAction={handleAction}
508
+ />
509
+ </SnapVersionProvider>
478
510
  </SnapPreviewAccentProvider>
479
511
  </div>
480
512
  </div>
@@ -7,6 +7,7 @@ import { SnapImage } from "./components/snap-image";
7
7
  import { SnapInput } from "./components/snap-input";
8
8
  import { SnapItem } from "./components/snap-item";
9
9
  import { SnapItemGroup } from "./components/snap-item-group";
10
+ import { SnapPaginator } from "./components/snap-paginator";
10
11
  import { SnapProgress } from "./components/snap-progress";
11
12
  import { SnapSeparator } from "./components/snap-separator";
12
13
  import { SnapSlider } from "./components/snap-slider";
@@ -29,6 +30,7 @@ export const SnapCatalogView = createRenderer(snapJsonRenderCatalog, {
29
30
  input: SnapInput,
30
31
  item: SnapItem,
31
32
  item_group: SnapItemGroup,
33
+ paginator: SnapPaginator,
32
34
  progress: SnapProgress,
33
35
  separator: SnapSeparator,
34
36
  slider: SnapSlider,
@@ -1,10 +1,15 @@
1
1
  declare const __DEV__: boolean;
2
2
 
3
3
  import type { ComponentRenderProps } from "@json-render/react-native";
4
+ import { useStateStore } from "@json-render/react-native";
4
5
  import { Pressable, StyleSheet, Text, View } from "react-native";
5
6
  import { ExternalLink } from "lucide-react-native";
6
7
  import { useSnapPalette } from "../use-snap-palette";
7
8
  import { useSnapTheme } from "../theme";
9
+ import {
10
+ getPaginatorAction,
11
+ runPaginatorAction,
12
+ } from "../../ui/paginator-state";
8
13
  import { useSnapStackDirection } from "../stack-direction-context";
9
14
  import { ICON_MAP } from "./snap-icon";
10
15
 
@@ -37,6 +42,8 @@ export function SnapActionButton({
37
42
 
38
43
  const on = (element as unknown as { on?: Record<string, unknown> }).on;
39
44
  const showExternalIcon = isExternalLinkAction(on);
45
+ const stateStore = useStateStore();
46
+ const paginatorAction = getPaginatorAction(on);
40
47
 
41
48
  return (
42
49
  <View style={inHorizontalStack ? styles.outerHorizontal : styles.outer}>
@@ -50,6 +57,7 @@ export function SnapActionButton({
50
57
  pressed && styles.pressed,
51
58
  ]}
52
59
  onPress={() => {
60
+ if (runPaginatorAction(stateStore, paginatorAction)) return;
53
61
  void (async () => {
54
62
  try {
55
63
  await emit("press");
@@ -100,10 +108,10 @@ const styles = StyleSheet.create({
100
108
  gap: 8,
101
109
  },
102
110
  btnDefault: {
103
- paddingVertical: 10,
111
+ paddingVertical: 8,
104
112
  },
105
113
  btnOther: {
106
- paddingVertical: 8,
114
+ paddingVertical: 6,
107
115
  },
108
116
  pressed: { opacity: 0.88 },
109
117
  });
@@ -4,6 +4,10 @@ import { useStateStore } from "@json-render/react-native";
4
4
  import { useSnapPalette } from "../use-snap-palette";
5
5
  import { useSnapTheme } from "../theme";
6
6
  import { POST_GRID_TAP_KEY, readableTextOnHex } from "@farcaster/snap";
7
+ import {
8
+ getPaginatorAction,
9
+ runPaginatorAction,
10
+ } from "../../ui/paginator-state";
7
11
 
8
12
  export function SnapCellGrid({
9
13
  element,
@@ -13,12 +17,21 @@ export function SnapCellGrid({
13
17
  const on = (element as unknown as { on?: Record<string, unknown> }).on;
14
18
  const { hex, appearance } = useSnapPalette();
15
19
  const { colors } = useSnapTheme();
16
- const { get, set } = useStateStore();
20
+ const stateStore = useStateStore();
21
+ const { get, set } = stateStore;
22
+ const paginatorAction = getPaginatorAction(on);
17
23
  const cols = Number(props.cols ?? 2);
18
24
  const rows = Number(props.rows ?? 2);
19
25
  const cells = Array.isArray(props.cells) ? props.cells : [];
20
26
  const rowHeight = typeof props.rowHeight === "number" ? props.rowHeight : 28;
21
27
  const squareCells = props.cellAspectRatio === "square";
28
+ const maxWidthKey = typeof props.maxWidth === "string" ? props.maxWidth : undefined;
29
+ const maxWidthMap: Record<string, number | undefined> = {
30
+ sm: 160,
31
+ md: 220,
32
+ lg: undefined,
33
+ };
34
+ const maxWidth = maxWidthKey ? maxWidthMap[maxWidthKey] : undefined;
22
35
  const gap = String(props.gap ?? "sm");
23
36
  const gapMap: Record<string, number> = { none: 0, sm: 1, md: 2, lg: 4 };
24
37
  const gapPx = gapMap[gap] ?? 1;
@@ -75,7 +88,12 @@ export function SnapCellGrid({
75
88
  } else {
76
89
  set(tapPath, wire);
77
90
  }
78
- if (hasPressAction) emit("press");
91
+ if (
92
+ hasPressAction &&
93
+ !runPaginatorAction(stateStore, paginatorAction)
94
+ ) {
95
+ emit("press");
96
+ }
79
97
  };
80
98
 
81
99
  const ringOuter = appearance === "dark" ? "#fff" : "#000";
@@ -168,6 +186,8 @@ export function SnapCellGrid({
168
186
  style={[
169
187
  styles.wrap,
170
188
  {
189
+ maxWidth,
190
+ alignSelf: maxWidth ? "center" : undefined,
171
191
  gap: gapPx,
172
192
  backgroundColor: colors.muted,
173
193
  padding: 4,