@farcaster/snap 2.0.2 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/dist/react/components/cell-grid.d.ts +3 -1
  2. package/dist/react/components/cell-grid.js +8 -4
  3. package/dist/react/index.d.ts +3 -1
  4. package/dist/react/index.js +3 -3
  5. package/dist/react/snap-view-core.d.ts +12 -1
  6. package/dist/react/snap-view-core.js +10 -5
  7. package/dist/react/v1/snap-view.d.ts +7 -2
  8. package/dist/react/v1/snap-view.js +48 -40
  9. package/dist/react/v2/snap-view.d.ts +6 -2
  10. package/dist/react/v2/snap-view.js +98 -33
  11. package/dist/react-native/components/snap-cell-grid.d.ts +1 -1
  12. package/dist/react-native/components/snap-cell-grid.js +10 -4
  13. package/dist/react-native/confetti-overlay.js +33 -36
  14. package/dist/react-native/index.d.ts +3 -1
  15. package/dist/react-native/index.js +3 -3
  16. package/dist/react-native/snap-view-core.d.ts +11 -1
  17. package/dist/react-native/snap-view-core.js +25 -9
  18. package/dist/react-native/v1/snap-view.d.ts +9 -3
  19. package/dist/react-native/v1/snap-view.js +51 -52
  20. package/dist/react-native/v2/snap-view.d.ts +8 -3
  21. package/dist/react-native/v2/snap-view.js +92 -21
  22. package/dist/ui/catalog.js +2 -2
  23. package/dist/validator.js +8 -33
  24. package/llms.txt +26 -3
  25. package/package.json +1 -1
  26. package/src/react/components/cell-grid.tsx +11 -5
  27. package/src/react/index.tsx +5 -0
  28. package/src/react/snap-view-core.tsx +23 -8
  29. package/src/react/v1/snap-view.tsx +84 -55
  30. package/src/react/v2/snap-view.tsx +165 -52
  31. package/src/react-native/components/snap-cell-grid.tsx +11 -4
  32. package/src/react-native/confetti-overlay.tsx +40 -37
  33. package/src/react-native/index.tsx +5 -0
  34. package/src/react-native/snap-view-core.tsx +56 -14
  35. package/src/react-native/v1/snap-view.tsx +71 -47
  36. package/src/react-native/v2/snap-view.tsx +166 -28
  37. package/src/ui/catalog.ts +2 -2
  38. package/src/validator.ts +22 -46
@@ -20,63 +20,60 @@ export function ConfettiOverlay() {
20
20
  color: CONFETTI_COLORS[Math.floor(Math.random() * CONFETTI_COLORS.length)],
21
21
  size: 6 + Math.random() * 8,
22
22
  startRotation: Math.random() * 360,
23
- driftX: (Math.random() > 0.5 ? 1 : -1) * Math.random() * 40,
23
+ // Per-piece swirl: amplitude, frequency (full oscillations), phase.
24
+ swirlAmp: 20 + Math.random() * 40,
25
+ swirlFreq: 1 + Math.random() * 1.5,
26
+ swirlPhase: Math.random() * Math.PI * 2,
24
27
  })),
25
28
  // width captured once on mount; intentional stable dep
26
29
  // eslint-disable-next-line react-hooks/exhaustive-deps
27
30
  []);
28
31
  const anims = useRef(pieces.map(() => ({
29
- translateY: new Animated.Value(-20),
30
- opacity: new Animated.Value(1),
31
- rotate: new Animated.Value(0),
32
- translateX: new Animated.Value(0),
32
+ progress: new Animated.Value(0),
33
33
  }))).current;
34
34
  useEffect(() => {
35
35
  const animations = pieces.map((piece, i) => {
36
36
  const anim = anims[i];
37
- anim.translateY.setValue(-20);
38
- anim.opacity.setValue(1);
39
- anim.rotate.setValue(0);
40
- anim.translateX.setValue(0);
37
+ anim.progress.setValue(0);
41
38
  return Animated.sequence([
42
39
  Animated.delay(piece.delay),
43
- Animated.parallel([
44
- Animated.timing(anim.translateY, {
45
- toValue: height + 20,
46
- duration: piece.duration,
47
- useNativeDriver: true,
48
- }),
49
- Animated.timing(anim.opacity, {
50
- toValue: 0,
51
- duration: piece.duration,
52
- useNativeDriver: true,
53
- }),
54
- Animated.timing(anim.rotate, {
55
- toValue: 720,
56
- duration: piece.duration,
57
- useNativeDriver: true,
58
- }),
59
- Animated.timing(anim.translateX, {
60
- toValue: piece.driftX,
61
- duration: piece.duration,
62
- useNativeDriver: true,
63
- }),
64
- ]),
40
+ Animated.timing(anim.progress, {
41
+ toValue: 1,
42
+ duration: piece.duration,
43
+ useNativeDriver: true,
44
+ }),
65
45
  ]);
66
46
  });
67
47
  const composite = Animated.parallel(animations);
68
48
  composite.start();
69
49
  return () => composite.stop();
70
50
  }, [pieces, anims, height]);
51
+ // Sample the sine curve at fixed progress points to build an interpolation
52
+ // that drives horizontal swirl on the native driver.
53
+ const SAMPLE_COUNT = 21;
54
+ const samplePoints = Array.from({ length: SAMPLE_COUNT }, (_, k) => k / (SAMPLE_COUNT - 1));
71
55
  return (_jsx(View, { style: [StyleSheet.absoluteFill, styles.container], pointerEvents: "none", children: pieces.map((piece, i) => {
72
56
  const anim = anims[i];
73
- const rotate = anim.rotate.interpolate({
74
- inputRange: [0, 720],
57
+ const translateY = anim.progress.interpolate({
58
+ inputRange: [0, 1],
59
+ outputRange: [-20, height + 20],
60
+ });
61
+ const rotate = anim.progress.interpolate({
62
+ inputRange: [0, 1],
75
63
  outputRange: [
76
64
  `${piece.startRotation}deg`,
77
65
  `${piece.startRotation + 720}deg`,
78
66
  ],
79
67
  });
68
+ const opacity = anim.progress.interpolate({
69
+ inputRange: [0, 0.5, 1],
70
+ outputRange: [1, 1, 0],
71
+ });
72
+ const translateX = anim.progress.interpolate({
73
+ inputRange: samplePoints,
74
+ outputRange: samplePoints.map((t) => Math.sin(t * piece.swirlFreq * Math.PI * 2 + piece.swirlPhase) *
75
+ piece.swirlAmp),
76
+ });
80
77
  return (_jsx(Animated.View, { style: [
81
78
  styles.piece,
82
79
  {
@@ -84,10 +81,10 @@ export function ConfettiOverlay() {
84
81
  width: piece.size,
85
82
  height: piece.size * 0.6,
86
83
  backgroundColor: piece.color,
87
- opacity: anim.opacity,
84
+ opacity,
88
85
  transform: [
89
- { translateY: anim.translateY },
90
- { translateX: anim.translateX },
86
+ { translateY },
87
+ { translateX },
91
88
  { rotate },
92
89
  ],
93
90
  },
@@ -7,7 +7,7 @@ import { hexToRgba } from "./use-snap-palette.js";
7
7
  export type { JsonValue, SnapPage, SnapActionHandlers } from "./types.js";
8
8
  export { useSnapTheme, hexToRgba };
9
9
  export type { SnapNativeColors };
10
- export declare function SnapCard({ snap, handlers, loading, appearance, colors, borderRadius, showOverflowWarning, onValidationError, validationErrorFallback, actionError, plain, }: {
10
+ export declare function SnapCard({ snap, handlers, loading, appearance, colors, borderRadius, showOverflowWarning, onValidationError, validationErrorFallback, actionError, plain, loadingOverlay, }: {
11
11
  snap: SnapPage;
12
12
  handlers: SnapActionHandlers;
13
13
  loading?: boolean;
@@ -25,4 +25,6 @@ export declare function SnapCard({ snap, handlers, loading, appearance, colors,
25
25
  actionError?: string | null;
26
26
  /** When true, renders without card frame (no border, background, or padding). */
27
27
  plain?: boolean;
28
+ /** Custom content rendered while `loading` is true. Pass `null` to render nothing. */
29
+ loadingOverlay?: ReactNode;
28
30
  }): import("react").JSX.Element;
@@ -7,9 +7,9 @@ import { SnapCardV2 } from "./v2/snap-view.js";
7
7
  // ─── Re-exports ───────────────────────────────────────
8
8
  export { useSnapTheme, hexToRgba };
9
9
  // ─── SnapCard (version-switching) ─────────────────────
10
- export function SnapCard({ snap, handlers, loading = false, appearance = "dark", colors, borderRadius = 16, showOverflowWarning = false, onValidationError, validationErrorFallback, actionError, plain = false, }) {
10
+ export function SnapCard({ snap, handlers, loading = false, appearance = "dark", colors, borderRadius = 16, showOverflowWarning = false, onValidationError, validationErrorFallback, actionError, plain = false, loadingOverlay, }) {
11
11
  if (snap.version === SPEC_VERSION_2) {
12
- return (_jsx(SnapCardV2, { snap: snap, handlers: handlers, loading: loading, appearance: appearance, colors: colors, borderRadius: borderRadius, showOverflowWarning: showOverflowWarning, onValidationError: onValidationError, validationErrorFallback: validationErrorFallback, actionError: actionError, plain: plain }));
12
+ return (_jsx(SnapCardV2, { snap: snap, handlers: handlers, loading: loading, appearance: appearance, colors: colors, borderRadius: borderRadius, showOverflowWarning: showOverflowWarning, onValidationError: onValidationError, validationErrorFallback: validationErrorFallback, actionError: actionError, plain: plain, loadingOverlay: loadingOverlay }));
13
13
  }
14
- return (_jsx(SnapCardV1, { snap: snap, handlers: handlers, loading: loading, appearance: appearance, colors: colors, borderRadius: borderRadius, actionError: actionError, plain: plain }));
14
+ return (_jsx(SnapCardV1, { snap: snap, handlers: handlers, loading: loading, appearance: appearance, colors: colors, borderRadius: borderRadius, actionError: actionError, plain: plain, loadingOverlay: loadingOverlay }));
15
15
  }
@@ -1,11 +1,21 @@
1
+ import { type ReactNode } from "react";
1
2
  import type { SnapPage, SnapActionHandlers } from "./types.js";
2
3
  export declare function applyStatePaths(model: Record<string, unknown>, changes: {
3
4
  path: string;
4
5
  value: unknown;
5
6
  }[] | Record<string, unknown>): void;
6
7
  export declare function resolveAccentHex(accent: string | undefined, appearance: "light" | "dark"): string;
7
- export declare function SnapViewCoreInner({ snap, handlers, loading, }: {
8
+ export declare function SnapViewCoreInner({ snap, handlers, loading, loadingOverlay, }: {
8
9
  snap: SnapPage;
9
10
  handlers: SnapActionHandlers;
10
11
  loading?: boolean;
12
+ /**
13
+ * Custom content rendered while `loading` is true. When `undefined` (default)
14
+ * the built-in ActivityIndicator overlay is used. Pass `null` to render nothing.
15
+ */
16
+ loadingOverlay?: ReactNode;
17
+ }): import("react").JSX.Element;
18
+ export declare function SnapLoadingOverlay({ appearance, accentHex, }: {
19
+ appearance: "light" | "dark";
20
+ accentHex: string;
11
21
  }): import("react").JSX.Element;
@@ -1,8 +1,9 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { snapJsonRenderCatalog } from "@farcaster/snap/ui";
3
3
  import { SnapCatalogView } from "./catalog-renderer.js";
4
+ import { ConfettiOverlay } from "./confetti-overlay.js";
4
5
  import { useSnapTheme } from "./theme.js";
5
- import { useCallback, useEffect, useMemo, useRef, useState } from "react";
6
+ import { useCallback, useEffect, useMemo, useRef, useState, } from "react";
6
7
  import { ActivityIndicator, StyleSheet, View } from "react-native";
7
8
  import { DEFAULT_THEME_ACCENT, PALETTE_LIGHT_HEX, PALETTE_DARK_HEX, } from "@farcaster/snap";
8
9
  // ─── Shared helpers ──────────────────────────────────
@@ -45,7 +46,7 @@ export function resolveAccentHex(accent, appearance) {
45
46
  return map[name];
46
47
  }
47
48
  // ─── Core rendering component (no validation) ────────
48
- export function SnapViewCoreInner({ snap, handlers, loading = false, }) {
49
+ export function SnapViewCoreInner({ snap, handlers, loading = false, loadingOverlay, }) {
49
50
  const { mode } = useSnapTheme();
50
51
  const spec = snap.ui;
51
52
  const accentHex = resolveAccentHex(snap.theme?.accent, mode);
@@ -79,6 +80,12 @@ export function SnapViewCoreInner({ snap, handlers, loading = false, }) {
79
80
  useEffect(() => {
80
81
  setPageKey((k) => k + 1);
81
82
  }, [spec]);
83
+ const showConfetti = snap.effects?.includes("confetti") ?? false;
84
+ const [confettiKey, setConfettiKey] = useState(0);
85
+ useEffect(() => {
86
+ if (showConfetti)
87
+ setConfettiKey((k) => k + 1);
88
+ }, [showConfetti, snap]);
82
89
  const handlersRef = useRef(handlers);
83
90
  handlersRef.current = handlers;
84
91
  const handleAction = useCallback((name, params) => {
@@ -134,14 +141,23 @@ export function SnapViewCoreInner({ snap, handlers, loading = false, }) {
134
141
  break;
135
142
  }
136
143
  }, []);
137
- return (_jsxs(View, { style: styles.container, children: [loading ? (_jsx(View, { style: [
138
- styles.overlay,
139
- {
140
- backgroundColor: mode === "dark" ? "rgba(0,0,0,0.1)" : "rgba(255,255,255,0.2)",
141
- },
142
- ], children: _jsx(ActivityIndicator, { size: "large", color: accentHex }) })) : null, _jsx(SnapCatalogView, { spec: spec, state: initialState, loading: false, onStateChange: (changes) => {
144
+ return (_jsxs(View, { style: styles.container, children: [loading
145
+ ? loadingOverlay === undefined
146
+ ? (_jsx(SnapLoadingOverlay, { appearance: mode, accentHex: accentHex }))
147
+ : loadingOverlay
148
+ : null, _jsx(SnapCatalogView, { spec: spec, state: initialState, loading: false, onStateChange: (changes) => {
143
149
  applyStatePaths(stateRef.current, changes);
144
- }, onAction: handleAction }, pageKey)] }));
150
+ }, onAction: handleAction }, pageKey), showConfetti && _jsx(ConfettiOverlay, {}, confettiKey)] }));
151
+ }
152
+ export function SnapLoadingOverlay({ appearance, accentHex, }) {
153
+ return (_jsx(View, { style: [
154
+ styles.overlay,
155
+ {
156
+ backgroundColor: appearance === "dark"
157
+ ? "rgba(0,0,0,0.1)"
158
+ : "rgba(255,255,255,0.2)",
159
+ },
160
+ ], children: _jsx(ActivityIndicator, { size: "large", color: accentHex }) }));
145
161
  }
146
162
  const styles = StyleSheet.create({
147
163
  container: {
@@ -1,18 +1,22 @@
1
+ import { type ReactNode } from "react";
1
2
  import { type SnapNativeColors } from "../theme.js";
2
3
  import type { SnapPage, SnapActionHandlers } from "../types.js";
3
- export declare function SnapViewV1Inner({ snap, handlers, loading, }: {
4
+ export declare function SnapViewV1Inner({ snap, handlers, loading, loadingOverlay, }: {
4
5
  snap: SnapPage;
5
6
  handlers: SnapActionHandlers;
6
7
  loading?: boolean;
8
+ loadingOverlay?: ReactNode;
7
9
  }): import("react").JSX.Element;
8
- export declare function SnapViewV1({ snap, handlers, loading, appearance, colors, }: {
10
+ export declare function SnapViewV1({ snap, handlers, loading, appearance, colors, loadingOverlay, }: {
9
11
  snap: SnapPage;
10
12
  handlers: SnapActionHandlers;
11
13
  loading?: boolean;
12
14
  appearance?: "light" | "dark";
13
15
  colors?: Partial<SnapNativeColors>;
16
+ /** Custom content rendered while `loading` is true. Pass `null` to render nothing. */
17
+ loadingOverlay?: ReactNode;
14
18
  }): import("react").JSX.Element;
15
- export declare function SnapCardV1({ snap, handlers, loading, appearance, colors, borderRadius, actionError, plain, }: {
19
+ export declare function SnapCardV1({ snap, handlers, loading, appearance, colors, borderRadius, actionError, plain, loadingOverlay, }: {
16
20
  snap: SnapPage;
17
21
  handlers: SnapActionHandlers;
18
22
  loading?: boolean;
@@ -21,4 +25,6 @@ export declare function SnapCardV1({ snap, handlers, loading, appearance, colors
21
25
  borderRadius?: number;
22
26
  actionError?: string | null;
23
27
  plain?: boolean;
28
+ /** Custom content rendered while `loading` is true. Pass `null` to render nothing. */
29
+ loadingOverlay?: ReactNode;
24
30
  }): import("react").JSX.Element;
@@ -2,18 +2,19 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
2
2
  import { useEffect, useState } from "react";
3
3
  import { View, Text, StyleSheet, Pressable } from "react-native";
4
4
  import { SnapThemeProvider, useSnapTheme } from "../theme.js";
5
- import { SnapViewCoreInner } from "../snap-view-core.js";
5
+ import { SnapLoadingOverlay, SnapViewCoreInner, resolveAccentHex, } from "../snap-view-core.js";
6
6
  const SNAP_MAX_HEIGHT = 500;
7
7
  // ─── SnapViewV1 (no validation) ──────────────────────
8
- export function SnapViewV1Inner({ snap, handlers, loading = false, }) {
9
- return (_jsx(SnapViewCoreInner, { snap: snap, handlers: handlers, loading: loading }));
8
+ export function SnapViewV1Inner({ snap, handlers, loading = false, loadingOverlay, }) {
9
+ return (_jsx(SnapViewCoreInner, { snap: snap, handlers: handlers, loading: loading, loadingOverlay: loadingOverlay }));
10
10
  }
11
- export function SnapViewV1({ snap, handlers, loading = false, appearance = "dark", colors, }) {
12
- return (_jsx(SnapThemeProvider, { appearance: appearance, colors: colors, children: _jsx(SnapViewV1Inner, { snap: snap, handlers: handlers, loading: loading }) }));
11
+ export function SnapViewV1({ snap, handlers, loading = false, appearance = "dark", colors, loadingOverlay, }) {
12
+ return (_jsx(SnapThemeProvider, { appearance: appearance, colors: colors, children: _jsx(SnapViewV1Inner, { snap: snap, handlers: handlers, loading: loading, loadingOverlay: loadingOverlay }) }));
13
13
  }
14
14
  // ─── SnapCardV1 (card frame with expandable clipping) ──
15
- function SnapCardV1Inner({ snap, handlers, loading = false, borderRadius, actionError, appearance, plain, }) {
16
- const { colors } = useSnapTheme();
15
+ function SnapCardV1Inner({ snap, handlers, loading = false, borderRadius, actionError, appearance, plain, loadingOverlay, }) {
16
+ const { colors, mode } = useSnapTheme();
17
+ const accentHex = resolveAccentHex(snap.theme?.accent, mode);
17
18
  const [contentHeight, setContentHeight] = useState(0);
18
19
  const [isExpanded, setIsExpanded] = useState(false);
19
20
  useEffect(() => {
@@ -22,35 +23,36 @@ function SnapCardV1Inner({ snap, handlers, loading = false, borderRadius, action
22
23
  }, [snap]);
23
24
  const isExpandable = contentHeight > SNAP_MAX_HEIGHT + 1;
24
25
  const isClipped = isExpandable && !isExpanded;
25
- return (_jsxs(_Fragment, { children: [_jsx(View, { style: cardStyles.frameRing, children: _jsxs(View, { style: [
26
- plain ? undefined : cardStyles.card,
27
- plain ? undefined : {
28
- borderRadius,
29
- borderColor: colors.border,
30
- backgroundColor: colors.surface,
31
- },
32
- ], children: [_jsx(View, { style: isClipped ? { maxHeight: SNAP_MAX_HEIGHT, overflow: "hidden" } : undefined, children: _jsx(View, { collapsable: false, onLayout: (event) => {
33
- const nextHeight = Math.round(event.nativeEvent.layout.height);
34
- setContentHeight((currentHeight) => isClipped
35
- ? Math.max(currentHeight, nextHeight)
36
- : currentHeight === nextHeight
37
- ? currentHeight
38
- : nextHeight);
39
- }, style: plain ? undefined : cardStyles.body, children: _jsx(SnapViewV1Inner, { snap: snap, handlers: handlers, loading: loading }) }) }), isExpandable ? (_jsx(View, { style: [
40
- cardStyles.expandRow,
41
- plain
42
- ? cardStyles.expandRowPlain
43
- : { borderTopColor: colors.border },
44
- ], children: _jsx(Pressable, { style: ({ pressed }) => [
45
- cardStyles.expandButton,
46
- {
47
- backgroundColor: pressed
48
- ? colors.mutedHover
49
- : colors.muted,
50
- },
51
- ], onPress: () => {
52
- setIsExpanded((value) => !value);
53
- }, children: _jsx(Text, { style: [cardStyles.expandButtonText, { color: colors.text }], children: isExpanded ? "Show less" : "Show more" }) }) })) : null] }) }), actionError && (_jsx(Text, { style: [
26
+ const isDark = mode === "dark";
27
+ const pillBg = isDark ? "rgba(40,40,40,0.92)" : "rgba(255,255,255,0.92)";
28
+ const pillBgPressed = isDark ? "rgba(60,60,60,0.95)" : "rgba(240,240,240,0.95)";
29
+ return (_jsxs(_Fragment, { children: [_jsxs(View, { style: cardStyles.frameRing, children: [_jsxs(View, { style: [
30
+ plain ? undefined : cardStyles.card,
31
+ plain ? undefined : {
32
+ borderRadius,
33
+ borderColor: colors.border,
34
+ backgroundColor: colors.surface,
35
+ },
36
+ ], children: [_jsx(View, { style: isClipped ? { maxHeight: SNAP_MAX_HEIGHT, overflow: "hidden" } : undefined, children: _jsx(View, { collapsable: false, onLayout: (event) => {
37
+ const nextHeight = Math.round(event.nativeEvent.layout.height);
38
+ setContentHeight((currentHeight) => isClipped
39
+ ? Math.max(currentHeight, nextHeight)
40
+ : currentHeight === nextHeight
41
+ ? currentHeight
42
+ : nextHeight);
43
+ }, style: plain ? undefined : cardStyles.body, children: _jsx(SnapViewV1Inner, { snap: snap, handlers: handlers, loading: loading, loadingOverlay: null }) }) }), loading
44
+ ? loadingOverlay === undefined
45
+ ? _jsx(SnapLoadingOverlay, { appearance: mode, accentHex: accentHex })
46
+ : loadingOverlay
47
+ : null] }), isExpandable ? (_jsx(View, { pointerEvents: "box-none", style: cardStyles.expandFloat, children: _jsx(Pressable, { style: ({ pressed }) => [
48
+ cardStyles.expandButton,
49
+ {
50
+ backgroundColor: pressed ? pillBgPressed : pillBg,
51
+ borderColor: colors.border,
52
+ },
53
+ ], onPress: () => {
54
+ setIsExpanded((value) => !value);
55
+ }, children: _jsx(Text, { style: [cardStyles.expandButtonText, { color: colors.text }], children: isExpanded ? "Show less" : "Show more" }) }) })) : null] }), actionError && (_jsx(Text, { style: [
54
56
  cardStyles.actionError,
55
57
  {
56
58
  color: appearance === "dark"
@@ -59,37 +61,34 @@ function SnapCardV1Inner({ snap, handlers, loading = false, borderRadius, action
59
61
  },
60
62
  ], children: actionError }))] }));
61
63
  }
62
- export function SnapCardV1({ snap, handlers, loading = false, appearance = "dark", colors, borderRadius = 16, actionError, plain = false, }) {
63
- return (_jsx(SnapThemeProvider, { appearance: appearance, colors: colors, children: _jsx(SnapCardV1Inner, { snap: snap, handlers: handlers, loading: loading, borderRadius: borderRadius, actionError: actionError, appearance: appearance, plain: plain }) }));
64
+ export function SnapCardV1({ snap, handlers, loading = false, appearance = "dark", colors, borderRadius = 16, actionError, plain = false, loadingOverlay, }) {
65
+ return (_jsx(SnapThemeProvider, { appearance: appearance, colors: colors, children: _jsx(SnapCardV1Inner, { snap: snap, handlers: handlers, loading: loading, borderRadius: borderRadius, actionError: actionError, appearance: appearance, plain: plain, loadingOverlay: loadingOverlay }) }));
64
66
  }
65
67
  const cardStyles = StyleSheet.create({
66
68
  frameRing: { alignSelf: "stretch" },
67
69
  card: { overflow: "hidden", borderWidth: 1, minHeight: 120 },
68
70
  body: { paddingHorizontal: 16, paddingVertical: 16 },
69
- expandRow: {
71
+ expandFloat: {
72
+ position: "absolute",
73
+ left: 0,
74
+ right: 0,
75
+ bottom: -14,
76
+ height: 28,
70
77
  alignItems: "center",
71
- paddingHorizontal: 16,
72
- paddingTop: 10,
73
- paddingBottom: 12,
74
- borderTopWidth: StyleSheet.hairlineWidth,
75
- },
76
- expandRowPlain: {
77
- paddingHorizontal: 0,
78
- paddingTop: 8,
79
- paddingBottom: 0,
80
- borderTopWidth: 0,
78
+ justifyContent: "center",
81
79
  },
82
80
  expandButton: {
83
81
  minWidth: 92,
84
82
  alignItems: "center",
85
83
  justifyContent: "center",
86
84
  borderRadius: 9999,
85
+ borderWidth: 1,
87
86
  paddingHorizontal: 10,
88
- paddingVertical: 6,
87
+ paddingVertical: 4,
89
88
  },
90
89
  expandButtonText: {
91
- fontSize: 13,
92
- lineHeight: 18,
90
+ fontSize: 12,
91
+ lineHeight: 16,
93
92
  fontWeight: "600",
94
93
  },
95
94
  actionError: { paddingHorizontal: 12, paddingVertical: 8, fontSize: 13 },
@@ -2,14 +2,15 @@ import type { ReactNode } from "react";
2
2
  import { type SnapNativeColors } from "../theme.js";
3
3
  import { type ValidationResult } from "@farcaster/snap";
4
4
  import type { SnapPage, SnapActionHandlers } from "../types.js";
5
- export declare function SnapViewV2Inner({ snap, handlers, loading, onValidationError, validationErrorFallback, }: {
5
+ export declare function SnapViewV2Inner({ snap, handlers, loading, onValidationError, validationErrorFallback, loadingOverlay, }: {
6
6
  snap: SnapPage;
7
7
  handlers: SnapActionHandlers;
8
8
  loading?: boolean;
9
9
  onValidationError?: (result: ValidationResult) => void;
10
10
  validationErrorFallback?: ReactNode;
11
+ loadingOverlay?: ReactNode;
11
12
  }): import("react").JSX.Element;
12
- export declare function SnapViewV2({ snap, handlers, loading, appearance, colors, onValidationError, validationErrorFallback, }: {
13
+ export declare function SnapViewV2({ snap, handlers, loading, appearance, colors, onValidationError, validationErrorFallback, loadingOverlay, }: {
13
14
  snap: SnapPage;
14
15
  handlers: SnapActionHandlers;
15
16
  loading?: boolean;
@@ -17,8 +18,10 @@ export declare function SnapViewV2({ snap, handlers, loading, appearance, colors
17
18
  colors?: Partial<SnapNativeColors>;
18
19
  onValidationError?: (result: ValidationResult) => void;
19
20
  validationErrorFallback?: ReactNode;
21
+ /** Custom content rendered while `loading` is true. Pass `null` to render nothing. */
22
+ loadingOverlay?: ReactNode;
20
23
  }): import("react").JSX.Element;
21
- export declare function SnapCardV2({ snap, handlers, loading, appearance, colors, borderRadius, showOverflowWarning, onValidationError, validationErrorFallback, actionError, plain, }: {
24
+ export declare function SnapCardV2({ snap, handlers, loading, appearance, colors, borderRadius, showOverflowWarning, onValidationError, validationErrorFallback, actionError, plain, loadingOverlay, }: {
22
25
  snap: SnapPage;
23
26
  handlers: SnapActionHandlers;
24
27
  loading?: boolean;
@@ -30,4 +33,6 @@ export declare function SnapCardV2({ snap, handlers, loading, appearance, colors
30
33
  validationErrorFallback?: ReactNode;
31
34
  actionError?: string | null;
32
35
  plain?: boolean;
36
+ /** Custom content rendered while `loading` is true. Pass `null` to render nothing. */
37
+ loadingOverlay?: ReactNode;
33
38
  }): import("react").JSX.Element;
@@ -1,8 +1,8 @@
1
1
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useEffect, useMemo, useState } from "react";
3
- import { Platform, StyleSheet, Text, View } from "react-native";
3
+ import { Platform, Pressable, StyleSheet, Text, View } from "react-native";
4
4
  import { SnapThemeProvider, useSnapTheme } from "../theme.js";
5
- import { SnapViewCoreInner } from "../snap-view-core.js";
5
+ import { SnapLoadingOverlay, SnapViewCoreInner, resolveAccentHex, } from "../snap-view-core.js";
6
6
  import { validateSnapResponse, } from "@farcaster/snap";
7
7
  // ─── Constants ───────────────────────────────────────
8
8
  const SNAP_MAX_HEIGHT = 500;
@@ -24,7 +24,7 @@ const fallbackStyles = StyleSheet.create({
24
24
  },
25
25
  });
26
26
  // ─── SnapViewV2 (with validation) ────────────────────
27
- export function SnapViewV2Inner({ snap, handlers, loading = false, onValidationError, validationErrorFallback, }) {
27
+ export function SnapViewV2Inner({ snap, handlers, loading = false, onValidationError, validationErrorFallback, loadingOverlay, }) {
28
28
  const validation = useMemo(() => validateSnapResponse(snap), [snap]);
29
29
  const valid = validation.valid;
30
30
  const validationMessage = validation.issues[0]?.message;
@@ -44,29 +44,73 @@ export function SnapViewV2Inner({ snap, handlers, loading = false, onValidationE
44
44
  return null;
45
45
  return (_jsx(_Fragment, { children: validationErrorFallback ?? _jsx(SnapValidationFallback, { message: validationMessage }) }));
46
46
  }
47
- return (_jsx(SnapViewCoreInner, { snap: snap, handlers: handlers, loading: loading }));
47
+ return (_jsx(SnapViewCoreInner, { snap: snap, handlers: handlers, loading: loading, loadingOverlay: loadingOverlay }));
48
48
  }
49
- export function SnapViewV2({ snap, handlers, loading = false, appearance = "dark", colors, onValidationError, validationErrorFallback, }) {
50
- return (_jsx(SnapThemeProvider, { appearance: appearance, colors: colors, children: _jsx(SnapViewV2Inner, { snap: snap, handlers: handlers, loading: loading, onValidationError: onValidationError, validationErrorFallback: validationErrorFallback }) }));
49
+ export function SnapViewV2({ snap, handlers, loading = false, appearance = "dark", colors, onValidationError, validationErrorFallback, loadingOverlay, }) {
50
+ return (_jsx(SnapThemeProvider, { appearance: appearance, colors: colors, children: _jsx(SnapViewV2Inner, { snap: snap, handlers: handlers, loading: loading, onValidationError: onValidationError, validationErrorFallback: validationErrorFallback, loadingOverlay: loadingOverlay }) }));
51
51
  }
52
52
  // ─── SnapCardV2 (card frame + height limits) ─────────
53
- function SnapCardV2Inner({ snap, handlers, loading, borderRadius, showOverflowWarning, onValidationError, validationErrorFallback, actionError, appearance, plain, }) {
54
- const { colors } = useSnapTheme();
53
+ function SnapCardV2Inner({ snap, handlers, loading, borderRadius, showOverflowWarning, onValidationError, validationErrorFallback, actionError, appearance, plain, loadingOverlay, }) {
54
+ const { colors, mode } = useSnapTheme();
55
+ const accentHex = resolveAccentHex(snap.theme?.accent, mode);
55
56
  const [contentHeight, setContentHeight] = useState(0);
56
- const content = (_jsx(SnapViewV2Inner, { snap: snap, handlers: handlers, loading: loading, onValidationError: onValidationError, validationErrorFallback: validationErrorFallback }));
57
+ const [isExpanded, setIsExpanded] = useState(false);
58
+ useEffect(() => {
59
+ setIsExpanded(false);
60
+ setContentHeight(0);
61
+ }, [snap]);
62
+ const isExpandable = !showOverflowWarning && contentHeight > SNAP_MAX_HEIGHT + 1;
63
+ const isClipped = isExpandable && !isExpanded;
64
+ const content = (_jsx(SnapViewV2Inner, { snap: snap, handlers: handlers, loading: loading, onValidationError: onValidationError, validationErrorFallback: validationErrorFallback, loadingOverlay: null }));
57
65
  if (plain) {
58
- return content;
66
+ return (_jsxs(_Fragment, { children: [_jsx(View, { style: isClipped ? { maxHeight: SNAP_MAX_HEIGHT, overflow: "hidden" } : undefined, children: _jsx(View, { collapsable: false, onLayout: (e) => {
67
+ const nextHeight = Math.round(e.nativeEvent.layout.height);
68
+ setContentHeight((current) => isClipped
69
+ ? Math.max(current, nextHeight)
70
+ : current === nextHeight
71
+ ? current
72
+ : nextHeight);
73
+ }, children: content }) }), loading
74
+ ? loadingOverlay === undefined
75
+ ? _jsx(SnapLoadingOverlay, { appearance: mode, accentHex: accentHex })
76
+ : loadingOverlay
77
+ : null, isExpandable ? (_jsx(View, { style: [cardStyles.expandRow, cardStyles.expandRowPlain], children: _jsx(Pressable, { style: ({ pressed }) => [
78
+ cardStyles.expandButton,
79
+ {
80
+ backgroundColor: pressed ? colors.mutedHover : colors.muted,
81
+ },
82
+ ], onPress: () => setIsExpanded((value) => !value), children: _jsx(Text, { style: [cardStyles.expandButtonText, { color: colors.text }], children: isExpanded ? "Show less" : "Show more" }) }) })) : null] }));
59
83
  }
60
84
  const overflowAmount = showOverflowWarning ? contentHeight - SNAP_MAX_HEIGHT : 0;
61
- return (_jsxs(_Fragment, { children: [_jsxs(View, { style: {
62
- borderRadius,
63
- borderWidth: 1,
64
- borderColor: colors.border,
65
- backgroundColor: colors.surface,
66
- maxHeight: showOverflowWarning ? undefined : SNAP_MAX_HEIGHT,
67
- overflow: "hidden",
68
- minHeight: 120,
69
- }, children: [_jsx(View, { collapsable: false, onLayout: (e) => setContentHeight(Math.round(e.nativeEvent.layout.height)), style: { paddingHorizontal: 16, paddingVertical: 16 }, children: content }), showOverflowWarning && contentHeight > SNAP_MAX_HEIGHT && (_jsxs(View, { style: { position: "absolute", top: SNAP_MAX_HEIGHT, left: 0, right: 0, height: overflowAmount, zIndex: 10, pointerEvents: "none" }, children: [_jsx(View, { style: { height: 1, borderTopWidth: 1, borderStyle: "dashed", borderColor: "rgba(255,100,100,0.6)" } }), _jsx(View, { style: { position: "absolute", top: -10, right: 4, backgroundColor: "rgba(0,0,0,0.7)", paddingHorizontal: 4, paddingVertical: 1, borderRadius: 3 }, children: _jsxs(Text, { style: { fontSize: 10, color: "rgba(255,100,100,0.7)", fontFamily: Platform.select({ ios: "Menlo", default: "monospace" }) }, children: [SNAP_MAX_HEIGHT, "px"] }) }), _jsx(View, { style: { flex: 1, backgroundColor: "rgba(255,50,50,0.15)" } })] }))] }), actionError && (_jsx(Text, { style: {
85
+ const isDark = mode === "dark";
86
+ const pillBg = isDark ? "rgba(40,40,40,0.92)" : "rgba(255,255,255,0.92)";
87
+ const pillBgPressed = isDark ? "rgba(60,60,60,0.95)" : "rgba(240,240,240,0.95)";
88
+ return (_jsxs(_Fragment, { children: [_jsxs(View, { style: { position: "relative" }, children: [_jsxs(View, { style: {
89
+ borderRadius,
90
+ borderWidth: 1,
91
+ borderColor: colors.border,
92
+ backgroundColor: colors.surface,
93
+ maxHeight: showOverflowWarning ? undefined : isClipped ? SNAP_MAX_HEIGHT : undefined,
94
+ overflow: "hidden",
95
+ minHeight: 120,
96
+ }, children: [_jsx(View, { collapsable: false, onLayout: (e) => {
97
+ const nextHeight = Math.round(e.nativeEvent.layout.height);
98
+ setContentHeight((current) => isClipped
99
+ ? Math.max(current, nextHeight)
100
+ : current === nextHeight
101
+ ? current
102
+ : nextHeight);
103
+ }, style: { paddingHorizontal: 16, paddingVertical: 16 }, children: content }), showOverflowWarning && contentHeight > SNAP_MAX_HEIGHT && (_jsxs(View, { style: { position: "absolute", top: SNAP_MAX_HEIGHT, left: 0, right: 0, height: overflowAmount, zIndex: 10, pointerEvents: "none" }, children: [_jsx(View, { style: { height: 1, borderTopWidth: 1, borderStyle: "dashed", borderColor: "rgba(255,100,100,0.6)" } }), _jsx(View, { style: { position: "absolute", top: -10, right: 4, backgroundColor: "rgba(0,0,0,0.7)", paddingHorizontal: 4, paddingVertical: 1, borderRadius: 3 }, children: _jsxs(Text, { style: { fontSize: 10, color: "rgba(255,100,100,0.7)", fontFamily: Platform.select({ ios: "Menlo", default: "monospace" }) }, children: [SNAP_MAX_HEIGHT, "px"] }) }), _jsx(View, { style: { flex: 1, backgroundColor: "rgba(255,50,50,0.15)" } })] })), loading
104
+ ? loadingOverlay === undefined
105
+ ? _jsx(SnapLoadingOverlay, { appearance: mode, accentHex: accentHex })
106
+ : loadingOverlay
107
+ : null] }), isExpandable ? (_jsx(View, { pointerEvents: "box-none", style: cardStyles.expandFloat, children: _jsx(Pressable, { style: ({ pressed }) => [
108
+ cardStyles.expandButton,
109
+ {
110
+ backgroundColor: pressed ? pillBgPressed : pillBg,
111
+ borderColor: colors.border,
112
+ },
113
+ ], onPress: () => setIsExpanded((value) => !value), children: _jsx(Text, { style: [cardStyles.expandButtonText, { color: colors.text }], children: isExpanded ? "Show less" : "Show more" }) }) })) : null] }), actionError && (_jsx(Text, { style: {
70
114
  paddingHorizontal: 12,
71
115
  paddingVertical: 8,
72
116
  fontSize: 13,
@@ -75,14 +119,41 @@ function SnapCardV2Inner({ snap, handlers, loading, borderRadius, showOverflowWa
75
119
  : "rgba(200,0,0,0.8)",
76
120
  }, children: actionError }))] }));
77
121
  }
78
- export function SnapCardV2({ snap, handlers, loading = false, appearance = "dark", colors, borderRadius = 16, showOverflowWarning = false, onValidationError, validationErrorFallback, actionError, plain = false, }) {
79
- return (_jsx(SnapThemeProvider, { appearance: appearance, colors: colors, children: _jsx(SnapCardV2Inner, { snap: snap, handlers: handlers, loading: loading, borderRadius: borderRadius, showOverflowWarning: showOverflowWarning, onValidationError: onValidationError, validationErrorFallback: validationErrorFallback, actionError: actionError, appearance: appearance, plain: plain }) }));
122
+ export function SnapCardV2({ snap, handlers, loading = false, appearance = "dark", colors, borderRadius = 16, showOverflowWarning = false, onValidationError, validationErrorFallback, actionError, plain = false, loadingOverlay, }) {
123
+ return (_jsx(SnapThemeProvider, { appearance: appearance, colors: colors, children: _jsx(SnapCardV2Inner, { snap: snap, handlers: handlers, loading: loading, borderRadius: borderRadius, showOverflowWarning: showOverflowWarning, onValidationError: onValidationError, validationErrorFallback: validationErrorFallback, actionError: actionError, appearance: appearance, plain: plain, loadingOverlay: loadingOverlay }) }));
80
124
  }
81
125
  const cardStyles = StyleSheet.create({
82
126
  frameRing: { alignSelf: "stretch" },
83
127
  card: { borderWidth: 1, minHeight: 120, overflow: "hidden" },
84
128
  body: { paddingHorizontal: 16, paddingVertical: 16 },
85
129
  actionError: { paddingHorizontal: 12, paddingVertical: 8, fontSize: 13 },
130
+ expandFloat: {
131
+ position: "absolute",
132
+ left: 0,
133
+ right: 0,
134
+ bottom: -14,
135
+ height: 28,
136
+ alignItems: "center",
137
+ justifyContent: "center",
138
+ },
139
+ expandRowPlain: {
140
+ paddingTop: 8,
141
+ alignItems: "center",
142
+ },
143
+ expandButton: {
144
+ minWidth: 92,
145
+ alignItems: "center",
146
+ justifyContent: "center",
147
+ borderRadius: 9999,
148
+ borderWidth: 1,
149
+ paddingHorizontal: 10,
150
+ paddingVertical: 4,
151
+ },
152
+ expandButtonText: {
153
+ fontSize: 12,
154
+ lineHeight: 16,
155
+ fontWeight: "600",
156
+ },
86
157
  warningOverlay: {
87
158
  position: "absolute",
88
159
  top: SNAP_MAX_HEIGHT,
@@ -50,7 +50,7 @@ export const snapJsonRenderCatalog = defineCatalog(snapJsonRenderSchema, {
50
50
  },
51
51
  item: {
52
52
  props: itemProps,
53
- description: "Content row with title and optional description. Children render in the actions slot (right side) — use badge, button, or text elements.",
53
+ description: "Content row with title and optional description. Children render in the actions slot (right side) — badge, button, and icon elements are all valid. The item itself is not interactive, so avoid navigation-style icons (`chevron-right`, `arrow-right`, `external-link`) that imply the row navigates.",
54
54
  },
55
55
  item_group: {
56
56
  props: itemGroupProps,
@@ -90,7 +90,7 @@ export const snapJsonRenderCatalog = defineCatalog(snapJsonRenderSchema, {
90
90
  },
91
91
  cell_grid: {
92
92
  props: cellGridProps,
93
- description: "Cell grid — sparse colored cells on a rows×cols grid. Optional gap and selection mode (taps write to inputs[name]).",
93
+ description: "Cell grid — sparse colored cells on a rows×cols grid. Two interaction modes: leave select 'off' and bind on.press to fire an action per cell press (inputs[name] is the pressed 'row,col' before the action runs); or set select 'single'/'multiple' for press-to-select with a visual ring (no auto-fire — pair with a separate submit button). on.press is ignored when select is on.",
94
94
  },
95
95
  },
96
96
  actions: {