@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
@@ -13,6 +13,7 @@ export declare const GRID_MIN_ROWS = 2;
13
13
  export declare const GRID_MAX_ROWS = 16;
14
14
  export declare const GRID_GAP_VALUES: readonly ["none", "sm", "md", "lg"];
15
15
  export declare const GRID_CELL_ASPECT_RATIO_VALUES: readonly ["auto", "square"];
16
+ export declare const GRID_MAX_WIDTH_VALUES: readonly ["sm", "md", "lg"];
16
17
  export declare const MAX_ELEMENTS = 64;
17
18
  export declare const MAX_ROOT_CHILDREN = 7;
18
19
  export declare const MAX_CHILDREN = 6;
package/dist/constants.js CHANGED
@@ -16,6 +16,7 @@ export const GRID_MIN_ROWS = 2;
16
16
  export const GRID_MAX_ROWS = 16;
17
17
  export const GRID_GAP_VALUES = ["none", "sm", "md", "lg"];
18
18
  export const GRID_CELL_ASPECT_RATIO_VALUES = ["auto", "square"];
19
+ export const GRID_MAX_WIDTH_VALUES = ["sm", "md", "lg"];
19
20
  // ─── Snap structural limits ───────────────────────────
20
21
  export const MAX_ELEMENTS = 64;
21
22
  export const MAX_ROOT_CHILDREN = 7;
@@ -8,6 +8,7 @@ import { SnapImage } from "./components/image.js";
8
8
  import { SnapInput } from "./components/input.js";
9
9
  import { SnapItem } from "./components/item.js";
10
10
  import { SnapItemGroup } from "./components/item-group.js";
11
+ import { SnapPaginator } from "./components/paginator.js";
11
12
  import { SnapProgress } from "./components/progress.js";
12
13
  import { SnapSeparator } from "./components/separator.js";
13
14
  import { SnapSlider } from "./components/slider.js";
@@ -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,12 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
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.js";
9
+ import { getPaginatorAction, runPaginatorAction, } from "../../ui/paginator-state.js";
8
10
  import { useSnapStackDirection } from "../stack-direction-context.js";
9
11
  import { ICON_MAP } from "./icon.js";
10
12
  function isExternalLinkAction(on) {
@@ -23,6 +25,8 @@ export function SnapActionButton({ element, emit, }) {
23
25
  const iconName = props.icon ? String(props.icon) : undefined;
24
26
  const colors = useSnapColors();
25
27
  const [hovered, setHovered] = useState(false);
28
+ const stateStore = useStateStore();
29
+ const paginatorAction = getPaginatorAction(element.on);
26
30
  const Icon = iconName ? ICON_MAP[iconName] : undefined;
27
31
  const showExternalIcon = isExternalLinkAction(element.on);
28
32
  const inHorizontalStack = useSnapStackDirection() === "horizontal";
@@ -50,5 +54,9 @@ export function SnapActionButton({ element, emit, }) {
50
54
  */
51
55
  _jsx("div", { className: inHorizontalStack
52
56
  ? "min-w-0 flex-auto"
53
- : "w-full min-w-0", style: inHorizontalStack ? { flex: "1 1 auto" } : undefined, children: _jsxs(Button, { type: "button", variant: isPrimary ? "default" : "secondary", className: cn("w-full gap-2"), style: style, onClick: () => emit("press"), onPointerEnter: () => setHovered(true), onPointerLeave: () => setHovered(false), children: [Icon && _jsx(Icon, { size: 16 }), label, showExternalIcon && (_jsx(ExternalLink, { size: 14, style: { opacity: 0.6 } }))] }) }));
57
+ : "w-full min-w-0", style: inHorizontalStack ? { flex: "1 1 auto" } : undefined, children: _jsxs(Button, { type: "button", variant: isPrimary ? "default" : "secondary", className: cn("h-8 w-full gap-2 px-3 text-sm"), style: style, onClick: () => {
58
+ if (!runPaginatorAction(stateStore, paginatorAction)) {
59
+ emit("press");
60
+ }
61
+ }, onPointerEnter: () => setHovered(true), onPointerLeave: () => setHovered(false), children: [Icon && _jsx(Icon, { size: 16 }), label, showExternalIcon && (_jsx(ExternalLink, { size: 14, style: { opacity: 0.6 } }))] }) }));
54
62
  }
@@ -4,9 +4,12 @@ import { useStateStore } from "@json-render/react";
4
4
  import { cn } from "@neynar/ui/utils";
5
5
  import { POST_GRID_TAP_KEY, readableTextOnHex } from "@farcaster/snap";
6
6
  import { useSnapColors } from "../hooks/use-snap-colors.js";
7
+ import { getPaginatorAction, runPaginatorAction, } from "../../ui/paginator-state.js";
7
8
  export function SnapCellGrid({ element: { props, on }, emit, }) {
8
- const { get, set } = useStateStore();
9
+ const stateStore = useStateStore();
10
+ const { get, set } = stateStore;
9
11
  const colors = useSnapColors();
12
+ const paginatorAction = getPaginatorAction(on);
10
13
  const cols = Number(props.cols ?? 2);
11
14
  const rows = Number(props.rows ?? 2);
12
15
  const select = String(props.select ?? "off");
@@ -20,6 +23,13 @@ export function SnapCellGrid({ element: { props, on }, emit, }) {
20
23
  const gapPx = gapMap[gap] ?? 1;
21
24
  const rowHeight = typeof props.rowHeight === "number" ? props.rowHeight : 28;
22
25
  const squareCells = props.cellAspectRatio === "square";
26
+ const maxWidthKey = typeof props.maxWidth === "string" ? props.maxWidth : undefined;
27
+ const maxWidthMap = {
28
+ sm: 160,
29
+ md: 220,
30
+ lg: undefined,
31
+ };
32
+ const maxWidth = maxWidthKey ? maxWidthMap[maxWidthKey] : undefined;
23
33
  const name = props.name ? String(props.name) : POST_GRID_TAP_KEY;
24
34
  const tapPath = `/inputs/${name}`;
25
35
  const tapRaw = get(tapPath);
@@ -61,8 +71,10 @@ export function SnapCellGrid({ element: { props, on }, emit, }) {
61
71
  else {
62
72
  set(tapPath, wire);
63
73
  }
64
- if (hasPressAction)
74
+ if (hasPressAction &&
75
+ !runPaginatorAction(stateStore, paginatorAction)) {
65
76
  emit("press");
77
+ }
66
78
  };
67
79
  /** Cells without a palette `color` — subtle fill so empty slots read as tiles. */
68
80
  const emptyCellBg = colors.mode === "dark"
@@ -101,6 +113,8 @@ export function SnapCellGrid({ element: { props, on }, emit, }) {
101
113
  return (_jsx("div", { style: {
102
114
  display: "grid",
103
115
  width: "100%",
116
+ maxWidth,
117
+ marginInline: maxWidth ? "auto" : undefined,
104
118
  gridTemplateColumns: `repeat(${cols}, 1fr)`,
105
119
  gap: gapPx,
106
120
  padding: 4,
@@ -1,5 +1,5 @@
1
1
  "use client";
2
- import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { AspectRatio } from "@neynar/ui/aspect-ratio";
4
4
  import { cn } from "@neynar/ui/utils";
5
5
  import { useSnapStackDirection } from "../stack-direction-context.js";
@@ -12,8 +12,11 @@ function aspectToRatio(aspect) {
12
12
  export function SnapImage({ element: { props }, }) {
13
13
  const url = String(props.url ?? "");
14
14
  const alt = String(props.alt ?? "");
15
+ const title = props.title ? String(props.title) : "";
16
+ const subtitle = props.subtitle ? String(props.subtitle) : "";
17
+ const hasOverlay = title.length > 0 || subtitle.length > 0;
15
18
  const ratio = aspectToRatio(String(props.aspect ?? "1:1"));
16
19
  const stackDir = useSnapStackDirection();
17
20
  const inHorizontalStack = stackDir === "horizontal";
18
- return (_jsx(AspectRatio, { ratio: ratio, className: cn("relative overflow-hidden rounded-lg", inHorizontalStack ? "min-w-0 flex-1 basis-0" : "w-full"), children: _jsx("img", { src: url, alt: alt, className: "absolute inset-0 size-full object-cover" }) }));
21
+ return (_jsxs(AspectRatio, { ratio: ratio, className: cn("relative overflow-hidden rounded-lg", inHorizontalStack ? "min-w-0 flex-1 basis-0" : "w-full"), children: [_jsx("img", { src: url, alt: alt, className: "absolute inset-0 size-full object-cover" }), hasOverlay && (_jsxs("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", children: [title && (_jsx("div", { className: "truncate text-sm font-semibold leading-5", children: title })), subtitle && (_jsx("div", { className: "truncate text-xs font-medium leading-4 text-white/85", children: subtitle }))] }))] }));
19
22
  }
@@ -0,0 +1,7 @@
1
+ import { type ReactNode } from "react";
2
+ export declare function SnapPaginator({ element: { props }, children, }: {
3
+ element: {
4
+ props: Record<string, unknown>;
5
+ };
6
+ children?: ReactNode;
7
+ }): import("react/jsx-runtime").JSX.Element | null;
@@ -0,0 +1,103 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { Children, useEffect, useMemo, useState, } from "react";
4
+ import { useStateStore } from "@json-render/react";
5
+ import { ChevronLeft, ChevronRight } from "lucide-react";
6
+ import { cn } from "@neynar/ui/utils";
7
+ import { useSnapColors } from "../hooks/use-snap-colors.js";
8
+ import { clampPaginatorPage, pageFromValue, SNAP_PAGINATOR_PAGE_COUNT_PATH, SNAP_PAGINATOR_PAGE_PATH, } from "../../ui/paginator-state.js";
9
+ function clampInitialPage(value, pageCount) {
10
+ if (typeof value !== "number" || !Number.isInteger(value))
11
+ return 0;
12
+ return Math.min(Math.max(value, 0), Math.max(pageCount - 1, 0));
13
+ }
14
+ export function SnapPaginator({ element: { props }, children, }) {
15
+ const pages = useMemo(() => Children.toArray(children), [children]);
16
+ const colors = useSnapColors();
17
+ const { get, set } = useStateStore();
18
+ const initialPage = clampInitialPage(props.initialPage, pages.length);
19
+ const page = clampPaginatorPage(pageFromValue(get(SNAP_PAGINATOR_PAGE_PATH), initialPage), pages.length);
20
+ const activePage = Math.min(page, Math.max(pages.length - 1, 0));
21
+ const showControls = props.showControls !== false && pages.length > 1;
22
+ const showIndicators = props.showIndicators !== false && pages.length > 1;
23
+ const controlsPosition = props.controlsPosition === "top" ? "top" : "bottom";
24
+ const transition = props.transition === "fade" ||
25
+ props.transition === "scale" ||
26
+ props.transition === "none"
27
+ ? props.transition
28
+ : "slide";
29
+ const showControlBar = showControls || showIndicators;
30
+ const [transitionDirection, setTransitionDirection] = useState("next");
31
+ const canGoPrev = activePage > 0;
32
+ const canGoNext = activePage < pages.length - 1;
33
+ const goToPage = (targetPage) => {
34
+ const nextPage = clampPaginatorPage(targetPage, pages.length);
35
+ if (nextPage !== activePage) {
36
+ setTransitionDirection(nextPage > activePage ? "next" : "previous");
37
+ }
38
+ set(SNAP_PAGINATOR_PAGE_PATH, nextPage);
39
+ };
40
+ const goPrev = () => goToPage(activePage - 1);
41
+ const goNext = () => goToPage(activePage + 1);
42
+ useEffect(() => {
43
+ if (pages.length === 0)
44
+ return;
45
+ const nextPage = clampPaginatorPage(pageFromValue(get(SNAP_PAGINATOR_PAGE_PATH), initialPage), pages.length);
46
+ if (get(SNAP_PAGINATOR_PAGE_PATH) !== nextPage) {
47
+ set(SNAP_PAGINATOR_PAGE_PATH, nextPage);
48
+ }
49
+ if (get(SNAP_PAGINATOR_PAGE_COUNT_PATH) !== pages.length) {
50
+ set(SNAP_PAGINATOR_PAGE_COUNT_PATH, pages.length);
51
+ }
52
+ }, [get, initialPage, pages.length, set]);
53
+ if (pages.length === 0)
54
+ return null;
55
+ const pageAnimation = transition === "none"
56
+ ? undefined
57
+ : transition === "fade"
58
+ ? "snapPaginatorFade 180ms ease-out"
59
+ : transition === "scale"
60
+ ? "snapPaginatorScale 240ms cubic-bezier(0.16, 1, 0.3, 1)"
61
+ : "snapPaginatorSlide 260ms cubic-bezier(0.16, 1, 0.3, 1)";
62
+ const controlBar = showControlBar ? (_jsxs("div", { className: "flex min-h-7 w-full items-center justify-between gap-2", children: [showControls ? (_jsx("button", { type: "button", "aria-label": "Previous page", disabled: !canGoPrev, onClick: goPrev, className: cn("inline-flex size-7 items-center justify-center rounded-md border text-sm transition-opacity", canGoPrev ? "cursor-pointer opacity-100" : "cursor-default opacity-35"), style: {
63
+ borderColor: colors.border,
64
+ backgroundColor: colors.muted,
65
+ color: colors.text,
66
+ }, children: _jsx(ChevronLeft, { size: 15 }) })) : (_jsx("span", {})), showIndicators ? (_jsx("div", { className: "flex flex-1 items-center justify-center gap-1.5", children: pages.map((_, index) => {
67
+ const current = index === activePage;
68
+ return (_jsx("span", { "aria-label": `Page ${index + 1}${current ? ", current" : ""}`, className: cn("block rounded-full", current ? "size-2.5" : "size-2"), style: {
69
+ backgroundColor: current
70
+ ? colors.accent
71
+ : colors.mode === "dark"
72
+ ? "rgba(255,255,255,0.5)"
73
+ : "rgba(0,0,0,0.28)",
74
+ boxShadow: current
75
+ ? `0 0 0 2px ${colors.mode === "dark" ? "rgba(255,255,255,0.18)" : "rgba(0,0,0,0.12)"}`
76
+ : undefined,
77
+ } }, index));
78
+ }) })) : (_jsx("span", { className: "flex-1" })), showControls ? (_jsx("button", { type: "button", "aria-label": "Next page", disabled: !canGoNext, onClick: goNext, className: cn("inline-flex size-7 items-center justify-center rounded-md border text-sm transition-opacity", canGoNext ? "cursor-pointer opacity-100" : "cursor-default opacity-35"), style: {
79
+ borderColor: colors.border,
80
+ backgroundColor: colors.muted,
81
+ color: colors.text,
82
+ }, children: _jsx(ChevronRight, { size: 15 }) })) : (_jsx("span", {}))] })) : null;
83
+ return (_jsxs("div", { className: "flex w-full min-w-0 flex-col gap-2", children: [controlsPosition === "top" ? controlBar : null, _jsx("div", { "data-snap-paginator-page": true, className: "w-full min-w-0", style: {
84
+ "--snap-paginator-x": transitionDirection === "previous" ? "-22px" : "22px",
85
+ animation: pageAnimation,
86
+ }, children: pages[activePage] }, activePage), controlsPosition === "bottom" ? controlBar : null, _jsx("style", { children: `
87
+ @keyframes snapPaginatorSlide {
88
+ from { opacity: 0.35; transform: translateX(var(--snap-paginator-x, 22px)) scale(0.985); }
89
+ to { opacity: 1; transform: translateX(0) scale(1); }
90
+ }
91
+ @keyframes snapPaginatorFade {
92
+ from { opacity: 0.2; }
93
+ to { opacity: 1; }
94
+ }
95
+ @keyframes snapPaginatorScale {
96
+ from { opacity: 0.25; transform: scale(0.94); }
97
+ to { opacity: 1; transform: scale(1); }
98
+ }
99
+ @media (prefers-reduced-motion: reduce) {
100
+ [data-snap-paginator-page] { animation: none !important; }
101
+ }
102
+ ` })] }));
103
+ }
@@ -4,16 +4,16 @@ import { cn } from "@neynar/ui/utils";
4
4
  import { childrenShouldUseHorizontalButtonLayout, childrenAreAllButtons, countRenderableChildren, defaultHorizontalGapSize, } from "../../stack-horizontal-utils.js";
5
5
  import { SnapStackDirectionProvider, useSnapStackDirection, } from "../stack-direction-context.js";
6
6
  const VGAP = {
7
- none: "gap-0",
8
- sm: "gap-1",
9
- md: "gap-4",
10
- lg: "gap-6",
7
+ none: 0,
8
+ sm: 4,
9
+ md: 16,
10
+ lg: 24,
11
11
  };
12
12
  const HGAP = {
13
- none: "gap-0",
14
- sm: "gap-1",
15
- md: "gap-2",
16
- lg: "gap-4",
13
+ none: 0,
14
+ sm: 4,
15
+ md: 8,
16
+ lg: 16,
17
17
  };
18
18
  const JUSTIFY_FLEX = {
19
19
  start: "justify-start",
@@ -71,7 +71,7 @@ export function SnapStack({ element: { props }, children, }) {
71
71
  : isHorizontal
72
72
  ? defaultHorizontalGapSize(horizontalChildCount)
73
73
  : "md";
74
- const gap = isHorizontal
74
+ const gapPx = isHorizontal
75
75
  ? (HGAP[gapKey] ?? HGAP.md)
76
76
  : (VGAP[gapKey] ?? VGAP.md);
77
77
  const columnGridClass = explicitEqualWidth && equalWidthColumnCount !== undefined
@@ -90,15 +90,18 @@ export function SnapStack({ element: { props }, children, }) {
90
90
  (!isHorizontal || !explicitEqualWidth);
91
91
  /** Single flex row (nowrap): peers stay side-by-side and shrink via min-w-0 / flex-1 on nested stacks. */
92
92
  const horizontalFlexClasses = "flex min-w-0 flex-row flex-nowrap items-stretch [&>*]:min-w-0";
93
- const equalWidthStyle = explicitEqualWidth && equalWidthColumnCount !== undefined
94
- ? {
95
- display: "grid",
96
- gridTemplateColumns: `repeat(${equalWidthColumnCount}, minmax(0, 1fr))`,
97
- }
98
- : undefined;
93
+ const layoutStyle = {
94
+ gap: gapPx,
95
+ ...(explicitEqualWidth && equalWidthColumnCount !== undefined
96
+ ? {
97
+ display: "grid",
98
+ gridTemplateColumns: `repeat(${equalWidthColumnCount}, minmax(0, 1fr))`,
99
+ }
100
+ : {}),
101
+ };
99
102
  return (_jsx(SnapStackDirectionProvider, { direction: isHorizontal ? "horizontal" : "vertical", children: _jsx("div", { className: cn(rootWidthClass, isHorizontal
100
103
  ? explicitEqualWidth && columnGridClass
101
- ? cn(columnGridClass, gap)
102
- : cn(horizontalFlexClasses, gap, justifyBlockGrid ? justifyFlex : undefined)
103
- : cn("flex min-w-0 w-full flex-col", gap, justifyFlex)), style: equalWidthStyle, children: children }) }));
104
+ ? columnGridClass
105
+ : cn(horizontalFlexClasses, justifyBlockGrid ? justifyFlex : undefined)
106
+ : cn("flex min-w-0 w-full flex-col", justifyFlex)), style: layoutStyle, children: children }) }));
104
107
  }
@@ -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.js";
6
6
  import { useSnapStackDirection } from "../stack-direction-context.js";
7
+ import { useSnapVersion } from "../snap-version-context.js";
7
8
  const SIZE_MAP = {
8
9
  md: { textSize: "base" },
9
10
  sm: { textSize: "sm" },
@@ -16,7 +17,13 @@ export function SnapText({ element: { props }, }) {
16
17
  const config = SIZE_MAP[size] ?? SIZE_MAP.md;
17
18
  const colors = useSnapColors();
18
19
  const stackDir = useSnapStackDirection();
20
+ const snapVersion = useSnapVersion();
19
21
  const inHorizontalStack = stackDir === "horizontal";
22
+ const maxLines = typeof props.maxLines === "number"
23
+ ? props.maxLines
24
+ : snapVersion === "2.0"
25
+ ? 1
26
+ : undefined;
20
27
  return (_jsx(Text, { size: config.textSize, weight: weight, align: align, className: cn(
21
28
  /**
22
29
  * Row peers hug content like RN `wrapRow` — `min-w-0 shrink` lets text wrap
@@ -26,5 +33,16 @@ export function SnapText({ element: { props }, }) {
26
33
  * column's height, distributing siblings when the row is taller than its
27
34
  * content (e.g. text next to a tall image).
28
35
  */
29
- inHorizontalStack ? "min-w-0 shrink" : "min-w-0"), style: { color: colors.text }, children: content }));
36
+ inHorizontalStack ? "min-w-0 shrink" : "min-w-0"), style: {
37
+ color: colors.text,
38
+ lineHeight: size === "sm" ? 1.35 : 1.4,
39
+ ...(maxLines
40
+ ? {
41
+ display: "-webkit-box",
42
+ WebkitBoxOrient: "vertical",
43
+ WebkitLineClamp: maxLines,
44
+ overflow: "hidden",
45
+ }
46
+ : {}),
47
+ }, children: content }));
30
48
  }
@@ -0,0 +1,3 @@
1
+ import type { SpecVersion } from "../constants.js";
2
+ export declare const SnapVersionProvider: import("react").Provider<"1.0" | "2.0">;
3
+ export declare function useSnapVersion(): SpecVersion;
@@ -0,0 +1,7 @@
1
+ "use client";
2
+ import { createContext, useContext } from "react";
3
+ const SnapVersionContext = createContext("1.0");
4
+ export const SnapVersionProvider = SnapVersionContext.Provider;
5
+ export function useSnapVersion() {
6
+ return useContext(SnapVersionContext);
7
+ }
@@ -3,7 +3,7 @@ import type { SnapActionHandlers, SnapPage } from "./index.js";
3
3
  export declare function applyStatePaths(model: Record<string, unknown>, changes: {
4
4
  path: string;
5
5
  value: unknown;
6
- }[] | Record<string, unknown>): void;
6
+ }[] | Record<string, unknown> | null | undefined): void;
7
7
  export declare function SnapLoadingOverlay({ appearance, accentHex, active, }: {
8
8
  appearance: "light" | "dark";
9
9
  accentHex: string;
@@ -3,11 +3,14 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
3
3
  import { snapJsonRenderCatalog } from "../ui/index.js";
4
4
  import { SnapCatalogView } from "./catalog-renderer.js";
5
5
  import { SnapPreviewAccentProvider } from "./accent-context.js";
6
+ import { SnapVersionProvider } from "./snap-version-context.js";
6
7
  import { resolveSnapPaletteHex } from "./lib/resolve-palette-hex.js";
7
8
  import { snapPreviewPrimaryCssProperties } from "./lib/preview-primary-css.js";
8
9
  import { useCallback, useEffect, useMemo, useRef, useState, } from "react";
9
10
  // ─── Internal helpers ──────────────────────────────────
10
11
  export function applyStatePaths(model, changes) {
12
+ if (!changes)
13
+ return;
11
14
  const entries = Array.isArray(changes)
12
15
  ? changes.map((c) => [c.path, c.value])
13
16
  : Object.entries(changes);
@@ -38,6 +41,26 @@ export function applyStatePaths(model, changes) {
38
41
  }
39
42
  }
40
43
  }
44
+ function withDefaultElementProps(spec) {
45
+ if (!spec || typeof spec !== "object" || !("elements" in spec))
46
+ return spec;
47
+ const elements = spec.elements;
48
+ if (!elements || typeof elements !== "object")
49
+ return spec;
50
+ let changed = false;
51
+ const nextElements = {};
52
+ for (const [id, element] of Object.entries(elements)) {
53
+ if (element.props !== undefined) {
54
+ nextElements[id] = element;
55
+ continue;
56
+ }
57
+ changed = true;
58
+ nextElements[id] = { ...element, props: {} };
59
+ }
60
+ return changed
61
+ ? { ...spec, elements: nextElements }
62
+ : spec;
63
+ }
41
64
  const CONFETTI_COLORS = [
42
65
  "#907AA9",
43
66
  "#EC4899",
@@ -217,7 +240,7 @@ const PALETTE = [
217
240
  // ─── SnapViewCore ────────────────────────────────────
218
241
  // Shared rendering logic used by both v1 and v2.
219
242
  export function SnapViewCore({ snap, handlers, loading = false, appearance = "dark", loadingOverlay, }) {
220
- const spec = snap.ui;
243
+ const spec = useMemo(() => withDefaultElementProps(snap.ui), [snap.ui]);
221
244
  const initialState = useMemo(() => spec.state ?? { inputs: {} }, [spec]);
222
245
  const stateRef = useRef(initialState);
223
246
  useEffect(() => {
@@ -316,7 +339,7 @@ export function SnapViewCore({ snap, handlers, loading = false, appearance = "da
316
339
  break;
317
340
  }
318
341
  }, [handlers]);
319
- return (_jsxs("div", { style: { position: "relative", width: "100%" }, children: [showConfetti && _jsx(ConfettiOverlay, {}, confettiKey), showFireworks && _jsx(FireworksOverlay, {}, fireworksKey), loadingOverlay === undefined ? (_jsx(SnapLoadingOverlay, { appearance: appearance, accentHex: accentHex, active: loading })) : loading ? (_jsx(_Fragment, { children: loadingOverlay })) : null, _jsx("div", { style: previewSurfaceStyle, children: _jsx(SnapPreviewAccentProvider, { pageAccent: snap.theme?.accent, appearance: appearance, children: _jsx(SnapCatalogView, { spec: spec, state: initialState, loading: false, onStateChange: (changes) => {
320
- applyStatePaths(stateRef.current, changes);
321
- }, onAction: handleAction }, pageKey) }) })] }));
342
+ return (_jsxs("div", { style: { position: "relative", width: "100%" }, children: [showConfetti && _jsx(ConfettiOverlay, {}, confettiKey), showFireworks && _jsx(FireworksOverlay, {}, fireworksKey), loadingOverlay === undefined ? (_jsx(SnapLoadingOverlay, { appearance: appearance, accentHex: accentHex, active: loading })) : loading ? (_jsx(_Fragment, { children: loadingOverlay })) : null, _jsx("div", { style: previewSurfaceStyle, children: _jsx(SnapPreviewAccentProvider, { pageAccent: snap.theme?.accent, appearance: appearance, children: _jsx(SnapVersionProvider, { value: snap.version === "2.0" ? "2.0" : "1.0", children: _jsx(SnapCatalogView, { spec: spec, state: initialState, loading: false, onStateChange: (changes) => {
343
+ applyStatePaths(stateRef.current, changes);
344
+ }, onAction: handleAction }, pageKey) }) }) })] }));
322
345
  }
@@ -7,6 +7,7 @@ import { SnapImage } from "./components/snap-image.js";
7
7
  import { SnapInput } from "./components/snap-input.js";
8
8
  import { SnapItem } from "./components/snap-item.js";
9
9
  import { SnapItemGroup } from "./components/snap-item-group.js";
10
+ import { SnapPaginator } from "./components/snap-paginator.js";
10
11
  import { SnapProgress } from "./components/snap-progress.js";
11
12
  import { SnapSeparator } from "./components/snap-separator.js";
12
13
  import { SnapSlider } from "./components/snap-slider.js";
@@ -28,6 +29,7 @@ export const SnapCatalogView = createRenderer(snapJsonRenderCatalog, {
28
29
  input: SnapInput,
29
30
  item: SnapItem,
30
31
  item_group: SnapItemGroup,
32
+ paginator: SnapPaginator,
31
33
  progress: SnapProgress,
32
34
  separator: SnapSeparator,
33
35
  slider: SnapSlider,
@@ -1,8 +1,10 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useStateStore } from "@json-render/react-native";
2
3
  import { Pressable, StyleSheet, Text, View } from "react-native";
3
4
  import { ExternalLink } from "lucide-react-native";
4
5
  import { useSnapPalette } from "../use-snap-palette.js";
5
6
  import { useSnapTheme } from "../theme.js";
7
+ import { getPaginatorAction, runPaginatorAction, } from "../../ui/paginator-state.js";
6
8
  import { useSnapStackDirection } from "../stack-direction-context.js";
7
9
  import { ICON_MAP } from "./snap-icon.js";
8
10
  function isExternalLinkAction(on) {
@@ -26,6 +28,8 @@ export function SnapActionButton({ element, emit, }) {
26
28
  const inHorizontalStack = useSnapStackDirection() === "horizontal";
27
29
  const on = element.on;
28
30
  const showExternalIcon = isExternalLinkAction(on);
31
+ const stateStore = useStateStore();
32
+ const paginatorAction = getPaginatorAction(on);
29
33
  return (_jsx(View, { style: inHorizontalStack ? styles.outerHorizontal : styles.outer, children: _jsxs(Pressable, { style: ({ pressed }) => [
30
34
  styles.btn,
31
35
  isPrimary ? styles.btnDefault : styles.btnOther,
@@ -34,6 +38,8 @@ export function SnapActionButton({ element, emit, }) {
34
38
  : { backgroundColor: pressed ? colors.mutedHover : colors.muted },
35
39
  pressed && styles.pressed,
36
40
  ], onPress: () => {
41
+ if (runPaginatorAction(stateStore, paginatorAction))
42
+ return;
37
43
  void (async () => {
38
44
  try {
39
45
  await emit("press");
@@ -73,10 +79,10 @@ const styles = StyleSheet.create({
73
79
  gap: 8,
74
80
  },
75
81
  btnDefault: {
76
- paddingVertical: 10,
82
+ paddingVertical: 8,
77
83
  },
78
84
  btnOther: {
79
- paddingVertical: 8,
85
+ paddingVertical: 6,
80
86
  },
81
87
  pressed: { opacity: 0.88 },
82
88
  });
@@ -4,17 +4,27 @@ import { useStateStore } from "@json-render/react-native";
4
4
  import { useSnapPalette } from "../use-snap-palette.js";
5
5
  import { useSnapTheme } from "../theme.js";
6
6
  import { POST_GRID_TAP_KEY, readableTextOnHex } from "@farcaster/snap";
7
+ import { getPaginatorAction, runPaginatorAction, } from "../../ui/paginator-state.js";
7
8
  export function SnapCellGrid({ element, emit, }) {
8
9
  const { props } = element;
9
10
  const on = element.on;
10
11
  const { hex, appearance } = useSnapPalette();
11
12
  const { colors } = useSnapTheme();
12
- const { get, set } = useStateStore();
13
+ const stateStore = useStateStore();
14
+ const { get, set } = stateStore;
15
+ const paginatorAction = getPaginatorAction(on);
13
16
  const cols = Number(props.cols ?? 2);
14
17
  const rows = Number(props.rows ?? 2);
15
18
  const cells = Array.isArray(props.cells) ? props.cells : [];
16
19
  const rowHeight = typeof props.rowHeight === "number" ? props.rowHeight : 28;
17
20
  const squareCells = props.cellAspectRatio === "square";
21
+ const maxWidthKey = typeof props.maxWidth === "string" ? props.maxWidth : undefined;
22
+ const maxWidthMap = {
23
+ sm: 160,
24
+ md: 220,
25
+ lg: undefined,
26
+ };
27
+ const maxWidth = maxWidthKey ? maxWidthMap[maxWidthKey] : undefined;
18
28
  const gap = String(props.gap ?? "sm");
19
29
  const gapMap = { none: 0, sm: 1, md: 2, lg: 4 };
20
30
  const gapPx = gapMap[gap] ?? 1;
@@ -64,8 +74,10 @@ export function SnapCellGrid({ element, emit, }) {
64
74
  else {
65
75
  set(tapPath, wire);
66
76
  }
67
- if (hasPressAction)
77
+ if (hasPressAction &&
78
+ !runPaginatorAction(stateStore, paginatorAction)) {
68
79
  emit("press");
80
+ }
69
81
  };
70
82
  const ringOuter = appearance === "dark" ? "#fff" : "#000";
71
83
  const ringInner = appearance === "dark" ? "#000" : "#fff";
@@ -110,6 +122,8 @@ export function SnapCellGrid({ element, emit, }) {
110
122
  return (_jsx(View, { style: [
111
123
  styles.wrap,
112
124
  {
125
+ maxWidth,
126
+ alignSelf: maxWidth ? "center" : undefined,
113
127
  gap: gapPx,
114
128
  backgroundColor: colors.muted,
115
129
  padding: 4,
@@ -1,6 +1,6 @@
1
- import { jsx as _jsx } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Image } from "expo-image";
3
- import { StyleSheet, View } from "react-native";
3
+ import { StyleSheet, Text, View } from "react-native";
4
4
  import { useSnapStackDirection } from "../stack-direction-context.js";
5
5
  function aspectToRatio(aspect) {
6
6
  const [w, h] = aspect.split(":").map(Number);
@@ -11,14 +11,17 @@ function aspectToRatio(aspect) {
11
11
  export function SnapImage({ element: { props }, }) {
12
12
  const url = String(props.url ?? "");
13
13
  const alt = String(props.alt ?? "");
14
+ const title = props.title ? String(props.title) : "";
15
+ const subtitle = props.subtitle ? String(props.subtitle) : "";
16
+ const hasOverlay = title.length > 0 || subtitle.length > 0;
14
17
  const ratio = aspectToRatio(String(props.aspect ?? "1:1"));
15
18
  const stackDir = useSnapStackDirection();
16
19
  const inHorizontalStack = stackDir === "horizontal";
17
- return (_jsx(View, { style: [
20
+ return (_jsxs(View, { style: [
18
21
  styles.frame,
19
22
  inHorizontalStack ? styles.frameInHorizontalRow : styles.frameFullWidth,
20
23
  { aspectRatio: ratio },
21
- ], children: _jsx(Image, { source: { uri: url }, style: StyleSheet.absoluteFill, contentFit: "cover", accessibilityLabel: alt || undefined }) }));
24
+ ], children: [_jsx(Image, { source: { uri: url }, style: StyleSheet.absoluteFill, contentFit: "cover", accessibilityLabel: alt || undefined }), hasOverlay ? (_jsxs(View, { style: styles.overlay, children: [title ? (_jsx(Text, { numberOfLines: 1, style: styles.title, children: title })) : null, subtitle ? (_jsx(Text, { numberOfLines: 1, style: styles.subtitle, children: subtitle })) : null] })) : null] }));
22
25
  }
23
26
  const styles = StyleSheet.create({
24
27
  frame: {
@@ -33,4 +36,26 @@ const styles = StyleSheet.create({
33
36
  flex: 1,
34
37
  minWidth: 0,
35
38
  },
39
+ overlay: {
40
+ position: "absolute",
41
+ left: 0,
42
+ right: 0,
43
+ bottom: 0,
44
+ paddingHorizontal: 12,
45
+ paddingTop: 24,
46
+ paddingBottom: 10,
47
+ backgroundColor: "rgba(0, 0, 0, 0.48)",
48
+ },
49
+ title: {
50
+ color: "#fff",
51
+ fontSize: 14,
52
+ lineHeight: 18,
53
+ fontWeight: "700",
54
+ },
55
+ subtitle: {
56
+ color: "rgba(255, 255, 255, 0.85)",
57
+ fontSize: 12,
58
+ lineHeight: 16,
59
+ fontWeight: "500",
60
+ },
36
61
  });
@@ -0,0 +1,5 @@
1
+ import type { ComponentRenderProps } from "@json-render/react-native";
2
+ import { type ReactNode } from "react";
3
+ export declare function SnapPaginator({ element: { props }, children, }: ComponentRenderProps<Record<string, unknown>> & {
4
+ children?: ReactNode;
5
+ }): import("react").JSX.Element;