@farcaster/snap 2.3.1 → 2.5.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 (66) hide show
  1. package/dist/button-orientation-utils.d.ts +3 -0
  2. package/dist/button-orientation-utils.js +25 -0
  3. package/dist/constants.d.ts +2 -1
  4. package/dist/constants.js +2 -1
  5. package/dist/react/components/action-button.js +5 -5
  6. package/dist/react/components/cell-grid.js +6 -2
  7. package/dist/react/components/item-group.js +2 -1
  8. package/dist/react/components/item-layout-context.d.ts +2 -0
  9. package/dist/react/components/item-layout-context.js +7 -0
  10. package/dist/react/components/item.js +40 -3
  11. package/dist/react/components/stack.js +46 -37
  12. package/dist/react/components/toggle-group.js +6 -4
  13. package/dist/react/snap-view-core.js +107 -17
  14. package/dist/react-native/components/item-layout-context.d.ts +2 -0
  15. package/dist/react-native/components/item-layout-context.js +6 -0
  16. package/dist/react-native/components/snap-action-button.js +15 -2
  17. package/dist/react-native/components/snap-cell-grid.js +30 -4
  18. package/dist/react-native/components/snap-item-group.js +16 -6
  19. package/dist/react-native/components/snap-item.js +56 -13
  20. package/dist/react-native/components/snap-stack.js +32 -33
  21. package/dist/react-native/components/snap-toggle-group.js +5 -4
  22. package/dist/react-native/fireworks-overlay.d.ts +1 -0
  23. package/dist/react-native/fireworks-overlay.js +125 -0
  24. package/dist/react-native/snap-view-core.js +7 -2
  25. package/dist/schemas.d.ts +1 -0
  26. package/dist/stack-horizontal-utils.d.ts +7 -5
  27. package/dist/stack-horizontal-utils.js +24 -14
  28. package/dist/ui/catalog.d.ts +60 -1
  29. package/dist/ui/catalog.js +3 -3
  30. package/dist/ui/cell-grid.d.ts +4 -0
  31. package/dist/ui/cell-grid.js +3 -4
  32. package/dist/ui/index.d.ts +2 -2
  33. package/dist/ui/index.js +1 -1
  34. package/dist/ui/item.d.ts +112 -1
  35. package/dist/ui/item.js +28 -2
  36. package/dist/ui/stack.d.ts +1 -0
  37. package/dist/ui/stack.js +3 -1
  38. package/dist/validator.js +19 -1
  39. package/llms.txt +3 -1
  40. package/package.json +1 -1
  41. package/src/button-orientation-utils.ts +36 -0
  42. package/src/constants.ts +2 -1
  43. package/src/react/components/action-button.tsx +5 -4
  44. package/src/react/components/cell-grid.tsx +6 -2
  45. package/src/react/components/item-group.tsx +19 -17
  46. package/src/react/components/item-layout-context.tsx +12 -0
  47. package/src/react/components/item.tsx +97 -4
  48. package/src/react/components/stack.tsx +51 -40
  49. package/src/react/components/toggle-group.tsx +6 -4
  50. package/src/react/snap-view-core.tsx +152 -28
  51. package/src/react-native/components/item-layout-context.tsx +10 -0
  52. package/src/react-native/components/snap-action-button.tsx +15 -2
  53. package/src/react-native/components/snap-cell-grid.tsx +36 -4
  54. package/src/react-native/components/snap-item-group.tsx +31 -17
  55. package/src/react-native/components/snap-item.tsx +92 -14
  56. package/src/react-native/components/snap-stack.tsx +37 -36
  57. package/src/react-native/components/snap-toggle-group.tsx +5 -4
  58. package/src/react-native/fireworks-overlay.tsx +176 -0
  59. package/src/react-native/snap-view-core.tsx +6 -1
  60. package/src/stack-horizontal-utils.ts +32 -13
  61. package/src/ui/catalog.ts +5 -4
  62. package/src/ui/cell-grid.ts +5 -5
  63. package/src/ui/index.ts +2 -2
  64. package/src/ui/item.ts +35 -5
  65. package/src/ui/stack.ts +3 -1
  66. package/src/validator.ts +29 -1
@@ -2,8 +2,14 @@ import type { ComponentRenderProps } from "@json-render/react-native";
2
2
  import { Children, Fragment, type ReactNode } from "react";
3
3
  import { StyleSheet, View } from "react-native";
4
4
  import { useSnapTheme } from "../theme";
5
+ import { SnapItemGroupBorderProvider } from "./item-layout-context";
5
6
 
6
- const GAP_MAP: Record<string, number> = { none: 0, sm: 4, md: 8, lg: 12 };
7
+ const GAP_MAP: Record<string, number> = {
8
+ none: 0,
9
+ sm: 4,
10
+ md: 8,
11
+ lg: 12,
12
+ };
7
13
 
8
14
  export function SnapItemGroup({
9
15
  element: { props },
@@ -16,22 +22,30 @@ export function SnapItemGroup({
16
22
  const items = Children.toArray(children);
17
23
 
18
24
  return (
19
- <View
20
- style={[
21
- styles.group,
22
- border && { borderWidth: 1, borderColor: colors.border, borderRadius: 12 },
23
- { gap },
24
- ]}
25
- >
26
- {items.map((child, i) => (
27
- <Fragment key={i}>
28
- {separator && i > 0 && (
29
- <View style={{ height: 1, backgroundColor: colors.border + "80" }} />
30
- )}
31
- {child}
32
- </Fragment>
33
- ))}
34
- </View>
25
+ <SnapItemGroupBorderProvider value={border}>
26
+ <View
27
+ style={[
28
+ styles.group,
29
+ border && {
30
+ borderWidth: 1,
31
+ borderColor: colors.border,
32
+ borderRadius: 12,
33
+ },
34
+ { gap },
35
+ ]}
36
+ >
37
+ {items.map((child, i) => (
38
+ <Fragment key={i}>
39
+ {separator && i > 0 && (
40
+ <View
41
+ style={{ height: 1, backgroundColor: colors.border + "80" }}
42
+ />
43
+ )}
44
+ {child}
45
+ </Fragment>
46
+ ))}
47
+ </View>
48
+ </SnapItemGroupBorderProvider>
35
49
  );
36
50
  }
37
51
 
@@ -1,33 +1,98 @@
1
1
  import type { ComponentRenderProps } from "@json-render/react-native";
2
+ import { Image } from "expo-image";
2
3
  import type { ReactNode } from "react";
3
4
  import { StyleSheet, Text, View } from "react-native";
4
5
  import { useSnapStackDirection } from "../stack-direction-context";
5
6
  import { useSnapTheme } from "../theme";
7
+ import { useSnapPalette } from "../use-snap-palette";
8
+ import { useSnapItemGroupHasBorder } from "./item-layout-context";
9
+ import { ICON_MAP } from "./snap-icon";
10
+
11
+ type ItemMediaConfig =
12
+ | {
13
+ variant: "icon";
14
+ name: string;
15
+ color?: string;
16
+ }
17
+ | {
18
+ variant: "image";
19
+ url: string;
20
+ alt?: string;
21
+ round?: boolean;
22
+ };
23
+
24
+ function parseItemMedia(value: unknown): ItemMediaConfig | undefined {
25
+ if (!value || typeof value !== "object") return undefined;
26
+
27
+ const media = value as Record<string, unknown>;
28
+ if (media.variant === "icon" && typeof media.name === "string") {
29
+ return {
30
+ variant: "icon",
31
+ name: media.name,
32
+ color: typeof media.color === "string" ? media.color : undefined,
33
+ };
34
+ }
35
+
36
+ if (media.variant === "image" && typeof media.url === "string") {
37
+ return {
38
+ variant: "image",
39
+ url: media.url,
40
+ alt: typeof media.alt === "string" ? media.alt : undefined,
41
+ round: typeof media.round === "boolean" ? media.round : undefined,
42
+ };
43
+ }
44
+
45
+ return undefined;
46
+ }
6
47
 
7
48
  export function SnapItem({
8
49
  element: { props },
9
50
  children,
10
51
  }: ComponentRenderProps<Record<string, unknown>> & { children?: ReactNode }) {
11
52
  const { colors } = useSnapTheme();
53
+ const { accentHex, hex } = useSnapPalette();
12
54
  const title = String(props.title ?? "");
13
- const description = props.description
14
- ? String(props.description)
15
- : undefined;
55
+ const description = props.description ? String(props.description) : undefined;
56
+ const media = parseItemMedia(props.media);
57
+ const inBorderedGroup = useSnapItemGroupHasBorder();
16
58
  /** Match web `Item className="flex-1"`: row peers must share width or title/description collapse. */
17
59
  const rowPeer = useSnapStackDirection() === "horizontal";
60
+ const MediaIcon =
61
+ media?.variant === "icon" ? ICON_MAP[media.name] : undefined;
62
+ const mediaColor =
63
+ media?.variant === "icon" && media.color && media.color !== "accent"
64
+ ? hex(media.color)
65
+ : accentHex;
18
66
 
19
- const containerVariant = { paddingVertical: 6, paddingHorizontal: 10 };
67
+ const containerVariant = {
68
+ paddingVertical: 6,
69
+ paddingHorizontal: inBorderedGroup ? 8 : 0,
70
+ columnGap: 8,
71
+ };
20
72
 
21
73
  return (
22
74
  <View
23
- style={[
24
- styles.container,
25
- containerVariant,
26
- rowPeer && styles.rowPeer,
27
- ]}
75
+ style={[styles.container, containerVariant, rowPeer && styles.rowPeer]}
28
76
  >
77
+ {media?.variant === "icon" && MediaIcon ? (
78
+ <View style={styles.iconMedia}>
79
+ <MediaIcon size={20} color={mediaColor} />
80
+ </View>
81
+ ) : null}
82
+ {media?.variant === "image" ? (
83
+ <View style={[styles.imageMedia, media.round && styles.roundImage]}>
84
+ <Image
85
+ source={{ uri: media.url }}
86
+ style={StyleSheet.absoluteFill}
87
+ contentFit="cover"
88
+ accessibilityLabel={media.alt || undefined}
89
+ />
90
+ </View>
91
+ ) : null}
29
92
  <View style={styles.content}>
30
- {title ? <Text style={[styles.title, { color: colors.text }]}>{title}</Text> : null}
93
+ {title ? (
94
+ <Text style={[styles.title, { color: colors.text }]}>{title}</Text>
95
+ ) : null}
31
96
  {description ? (
32
97
  <Text style={[styles.description, { color: colors.textSecondary }]}>
33
98
  {description}
@@ -55,19 +120,32 @@ const styles = StyleSheet.create({
55
120
  content: {
56
121
  flex: 1,
57
122
  },
123
+ iconMedia: {
124
+ alignItems: "center",
125
+ justifyContent: "center",
126
+ },
127
+ imageMedia: {
128
+ width: 40,
129
+ height: 40,
130
+ borderRadius: 6,
131
+ overflow: "hidden",
132
+ backgroundColor: "#f3f4f6",
133
+ },
134
+ roundImage: {
135
+ borderRadius: 9999,
136
+ },
58
137
  title: {
59
138
  fontSize: 15,
60
139
  lineHeight: 20,
61
140
  fontWeight: "500",
62
141
  },
63
142
  description: {
64
- fontSize: 13,
65
- lineHeight: 18,
66
- marginTop: 1,
143
+ fontSize: 12,
144
+ lineHeight: 16,
67
145
  },
68
146
  actions: {
69
147
  marginLeft: "auto",
70
- paddingLeft: 12,
148
+ paddingLeft: 8,
71
149
  flexDirection: "row",
72
150
  alignItems: "center",
73
151
  flexShrink: 0,
@@ -2,9 +2,10 @@ import type { ComponentRenderProps } from "@json-render/react-native";
2
2
  import { Children, type ReactNode } from "react";
3
3
  import { StyleSheet, View } from "react-native";
4
4
  import {
5
+ childrenShouldUseHorizontalButtonLayout,
6
+ childrenAreAllButtons,
5
7
  countRenderableChildren,
6
8
  defaultHorizontalGapSize,
7
- horizontalChildrenAreAllButtons,
8
9
  } from "../../stack-horizontal-utils.js";
9
10
  import {
10
11
  SnapStackDirectionProvider,
@@ -13,7 +14,7 @@ import {
13
14
 
14
15
  const VGAP: Record<string, number> = {
15
16
  none: 0,
16
- sm: 8,
17
+ sm: 4,
17
18
  md: 16,
18
19
  lg: 24,
19
20
  };
@@ -33,7 +34,7 @@ const JUSTIFY: Record<string, "flex-start" | "center" | "flex-end" | "space-betw
33
34
  around: "space-around",
34
35
  };
35
36
 
36
- /** Equal-width cells for explicit `columns` and all-button horizontal rows. */
37
+ /** Equal-width cells for explicit `equalWidth` / `columns` props. */
37
38
  function wrapEqualColumnCells(children: ReactNode): ReactNode {
38
39
  const cells = Children.toArray(children).filter(
39
40
  (c) => c != null && c !== false,
@@ -50,17 +51,21 @@ export function SnapStack({
50
51
  children,
51
52
  }: ComponentRenderProps<Record<string, unknown>> & { children?: ReactNode }) {
52
53
  const parentDirection = useSnapStackDirection();
53
- const direction = String(props.direction ?? "vertical");
54
+ const buttonContentUsesHorizontal =
55
+ childrenShouldUseHorizontalButtonLayout(children);
56
+ const direction =
57
+ buttonContentUsesHorizontal === undefined
58
+ ? String(props.direction ?? "vertical")
59
+ : buttonContentUsesHorizontal
60
+ ? "horizontal"
61
+ : "vertical";
54
62
  const rawGap = props.gap;
55
63
  const isHorizontal = direction === "horizontal";
56
64
  const gapMap = isHorizontal ? HGAP : VGAP;
57
- const buttonRowGrid =
58
- isHorizontal && horizontalChildrenAreAllButtons(children);
59
- const buttonRowCount = buttonRowGrid
60
- ? countRenderableChildren(children)
61
- : 0;
65
+ const allChildrenAreButtons = childrenAreAllButtons(children);
62
66
 
63
67
  const columnsRaw = props.columns;
68
+ const equalWidth = props.equalWidth === true;
64
69
  const columns =
65
70
  typeof columnsRaw === "number" &&
66
71
  columnsRaw >= 2 &&
@@ -69,49 +74,44 @@ export function SnapStack({
69
74
  ? columnsRaw
70
75
  : undefined;
71
76
 
72
- // Horizontal default depends on column count: 2→lg, 3→md, 4+→sm. Vertical stays md.
73
- // Count comes from explicit `columns`, then button-row inference, else direct children
74
- // count (any horizontal stack is N columns wide regardless of child types).
75
- const horizontalColumnCount = isHorizontal
76
- ? (columns ??
77
- (buttonRowGrid ? buttonRowCount : undefined) ??
78
- countRenderableChildren(children))
77
+ const equalWidthColumnCount =
78
+ columns ?? (equalWidth ? countRenderableChildren(children) : undefined);
79
+ const explicitEqualWidth =
80
+ isHorizontal &&
81
+ equalWidthColumnCount !== undefined &&
82
+ equalWidthColumnCount >= 1 &&
83
+ equalWidthColumnCount <= 6;
84
+
85
+ // Button-only stacks always default to sm; mixed horizontal stacks scale by child count.
86
+ // Vertical non-button stacks default to md.
87
+ const horizontalChildCount = isHorizontal
88
+ ? (explicitEqualWidth
89
+ ? equalWidthColumnCount
90
+ : countRenderableChildren(children))
79
91
  : undefined;
80
92
  const gap =
81
93
  typeof rawGap === "number"
82
94
  ? rawGap
83
95
  : typeof rawGap === "string" && rawGap in gapMap
84
96
  ? gapMap[rawGap]!
85
- : isHorizontal
86
- ? gapMap[defaultHorizontalGapSize(horizontalColumnCount)]!
97
+ : allChildrenAreButtons
98
+ ? gapMap.sm!
99
+ : isHorizontal
100
+ ? gapMap[defaultHorizontalGapSize(horizontalChildCount)]!
87
101
  : VGAP.md!;
88
- const explicitColumnGrid =
89
- isHorizontal && columns !== undefined && !buttonRowGrid;
90
-
91
102
  const justify =
92
103
  props.justify &&
93
- (!isHorizontal || (!buttonRowGrid && !explicitColumnGrid))
104
+ (!isHorizontal || !explicitEqualWidth)
94
105
  ? JUSTIFY[String(props.justify)]
95
106
  : undefined;
96
107
 
97
108
  const isRowChild = parentDirection === "horizontal";
98
109
 
99
110
  const packedHorizontal =
100
- isHorizontal &&
101
- ((buttonRowGrid &&
102
- buttonRowCount >= 1 &&
103
- buttonRowCount <= 6) ||
104
- explicitColumnGrid);
111
+ isHorizontal && explicitEqualWidth;
105
112
 
106
113
  let horizontalBody: ReactNode = children;
107
- if (
108
- isHorizontal &&
109
- buttonRowGrid &&
110
- buttonRowCount >= 1 &&
111
- buttonRowCount <= 6
112
- ) {
113
- horizontalBody = wrapEqualColumnCells(children);
114
- } else if (isHorizontal && explicitColumnGrid && columns !== undefined) {
114
+ if (isHorizontal && explicitEqualWidth) {
115
115
  horizontalBody = wrapEqualColumnCells(children);
116
116
  }
117
117
 
@@ -163,7 +163,7 @@ const styles = StyleSheet.create({
163
163
  width: "100%",
164
164
  minWidth: 0,
165
165
  },
166
- /** Single row for packed equal-width cells (button grids & explicit columns). */
166
+ /** Single row for packed equal-width cells from explicit equal-width layout. */
167
167
  horizontalPacked: {
168
168
  flexDirection: "row",
169
169
  flexWrap: "nowrap",
@@ -176,5 +176,6 @@ const styles = StyleSheet.create({
176
176
  flexShrink: 1,
177
177
  flexBasis: 0,
178
178
  minWidth: 0,
179
+ alignSelf: "stretch",
179
180
  },
180
181
  });
@@ -1,6 +1,7 @@
1
1
  import type { ComponentRenderProps } from "@json-render/react-native";
2
2
  import { useStateStore } from "@json-render/react-native";
3
3
  import { Pressable, StyleSheet, Text, View } from "react-native";
4
+ import { shouldUseHorizontalButtonContent } from "../../button-orientation-utils.js";
4
5
  import { useSnapTheme } from "../theme";
5
6
 
6
7
  export function SnapToggleGroup({
@@ -12,7 +13,6 @@ export function SnapToggleGroup({
12
13
  const path = `/inputs/${name}`;
13
14
  const label = props.label ? String(props.label) : undefined;
14
15
  const isMultiple = Boolean(props.multiple);
15
- const orientation = String(props.orientation ?? "horizontal");
16
16
  const options = Array.isArray(props.options)
17
17
  ? (props.options as string[])
18
18
  : [];
@@ -38,7 +38,7 @@ export function SnapToggleGroup({
38
38
  return [];
39
39
  })();
40
40
 
41
- const isVertical = orientation === "vertical";
41
+ const isVertical = !shouldUseHorizontalButtonContent(options);
42
42
 
43
43
  const handlePress = (opt: string) => {
44
44
  if (isMultiple) {
@@ -65,7 +65,7 @@ export function SnapToggleGroup({
65
65
  const isSelected = selected.includes(opt);
66
66
  return (
67
67
  <Pressable
68
- key={index}
68
+ key={`${opt}-${index}`}
69
69
  style={({ pressed }) => [
70
70
  styles.option,
71
71
  {
@@ -117,7 +117,8 @@ const styles = StyleSheet.create({
117
117
  justifyContent: "center",
118
118
  },
119
119
  optionHorizontal: {
120
- flex: 1,
120
+ flexGrow: 1,
121
+ flexShrink: 1,
121
122
  },
122
123
  optionText: {
123
124
  fontSize: 13,
@@ -0,0 +1,176 @@
1
+ import { useEffect, useMemo, useRef, useState } from "react";
2
+ import { Animated, StyleSheet, View, useWindowDimensions } from "react-native";
3
+
4
+ const FIREWORK_COLORS = [
5
+ "#FFD700",
6
+ "#FF6B6B",
7
+ "#4ECDC4",
8
+ "#C4A7E7",
9
+ "#F6C177",
10
+ "#EBBCBA",
11
+ "#9CCFD8",
12
+ "#fff",
13
+ ];
14
+
15
+ const BURST_COUNT = 5;
16
+ const PARTICLE_COUNT = 24;
17
+
18
+ type BurstData = {
19
+ id: number;
20
+ x: number;
21
+ y: number;
22
+ particles: Array<{
23
+ id: number;
24
+ vx: number;
25
+ vy: number;
26
+ color: string;
27
+ size: number;
28
+ }>;
29
+ };
30
+
31
+ function FireworkBurst({ burst }: { burst: BurstData }) {
32
+ const flashAnim = useRef(new Animated.Value(0)).current;
33
+ const burstAnim = useRef(new Animated.Value(0)).current;
34
+
35
+ useEffect(() => {
36
+ const composite = Animated.parallel([
37
+ Animated.timing(flashAnim, {
38
+ toValue: 1,
39
+ duration: 400,
40
+ useNativeDriver: true,
41
+ }),
42
+ Animated.timing(burstAnim, {
43
+ toValue: 1,
44
+ duration: 1000,
45
+ useNativeDriver: true,
46
+ }),
47
+ ]);
48
+ composite.start();
49
+ return () => composite.stop();
50
+ }, [flashAnim, burstAnim]);
51
+
52
+ const flashOpacity = flashAnim.interpolate({
53
+ inputRange: [0, 0.25, 1],
54
+ outputRange: [0, 1, 0],
55
+ });
56
+ const flashScale = flashAnim.interpolate({
57
+ inputRange: [0, 0.25, 1],
58
+ outputRange: [0, 2.5, 5],
59
+ });
60
+
61
+ return (
62
+ <>
63
+ <Animated.View
64
+ style={[
65
+ styles.flash,
66
+ {
67
+ left: burst.x - 6,
68
+ top: burst.y - 6,
69
+ opacity: flashOpacity,
70
+ transform: [{ scale: flashScale }],
71
+ },
72
+ ]}
73
+ />
74
+ {burst.particles.map((p) => {
75
+ const opacity = burstAnim.interpolate({
76
+ inputRange: [0, 0.65, 1],
77
+ outputRange: [1, 1, 0],
78
+ });
79
+ const translateX = burstAnim.interpolate({
80
+ inputRange: [0, 1],
81
+ outputRange: [0, p.vx],
82
+ });
83
+ const translateY = burstAnim.interpolate({
84
+ inputRange: [0, 1],
85
+ outputRange: [0, p.vy],
86
+ });
87
+ const scale = burstAnim.interpolate({
88
+ inputRange: [0, 1],
89
+ outputRange: [1, 0],
90
+ });
91
+ return (
92
+ <Animated.View
93
+ key={p.id}
94
+ style={[
95
+ styles.particle,
96
+ {
97
+ left: burst.x - p.size / 2,
98
+ top: burst.y - p.size / 2,
99
+ width: p.size,
100
+ height: p.size,
101
+ backgroundColor: p.color,
102
+ opacity,
103
+ transform: [{ translateX }, { translateY }, { scale }],
104
+ },
105
+ ]}
106
+ />
107
+ );
108
+ })}
109
+ </>
110
+ );
111
+ }
112
+
113
+ export function FireworksOverlay() {
114
+ const { width, height } = useWindowDimensions();
115
+
116
+ const bursts = useMemo<(BurstData & { delay: number })[]>(
117
+ () =>
118
+ Array.from({ length: BURST_COUNT }, (_, b) => ({
119
+ id: b,
120
+ x: (0.15 + Math.random() * 0.7) * width,
121
+ y: (0.1 + Math.random() * 0.5) * height,
122
+ delay: b * 500 + Math.random() * 200,
123
+ particles: Array.from({ length: PARTICLE_COUNT }, (_, p) => {
124
+ const angle =
125
+ (p / PARTICLE_COUNT) * Math.PI * 2 + (Math.random() - 0.5) * 0.2;
126
+ const dist = 55 + Math.random() * 60;
127
+ return {
128
+ id: p,
129
+ vx: Math.cos(angle) * dist,
130
+ vy: Math.sin(angle) * dist,
131
+ color:
132
+ FIREWORK_COLORS[
133
+ Math.floor(Math.random() * FIREWORK_COLORS.length)
134
+ ]!,
135
+ size: 3 + Math.random() * 3,
136
+ };
137
+ }),
138
+ })),
139
+ // stable on mount
140
+ // eslint-disable-next-line react-hooks/exhaustive-deps
141
+ [],
142
+ );
143
+
144
+ const [mountedBursts, setMountedBursts] = useState<number[]>([]);
145
+
146
+ useEffect(() => {
147
+ const timers = bursts.map((burst, b) =>
148
+ setTimeout(() => {
149
+ setMountedBursts((prev) => [...prev, b]);
150
+ }, burst.delay),
151
+ );
152
+ return () => timers.forEach(clearTimeout);
153
+ }, [bursts]);
154
+
155
+ return (
156
+ <View style={StyleSheet.absoluteFill} pointerEvents="none">
157
+ {mountedBursts.map((b) => (
158
+ <FireworkBurst key={b} burst={bursts[b]!} />
159
+ ))}
160
+ </View>
161
+ );
162
+ }
163
+
164
+ const styles = StyleSheet.create({
165
+ flash: {
166
+ position: "absolute",
167
+ width: 12,
168
+ height: 12,
169
+ borderRadius: 6,
170
+ backgroundColor: "#fff",
171
+ },
172
+ particle: {
173
+ position: "absolute",
174
+ borderRadius: 999,
175
+ },
176
+ });
@@ -2,6 +2,7 @@ import type { Spec } from "@json-render/core";
2
2
  import { snapJsonRenderCatalog } from "@farcaster/snap/ui";
3
3
  import { SnapCatalogView } from "./catalog-renderer";
4
4
  import { ConfettiOverlay } from "./confetti-overlay";
5
+ import { FireworksOverlay } from "./fireworks-overlay";
5
6
  import { useSnapTheme } from "./theme";
6
7
  import {
7
8
  type ReactNode,
@@ -128,10 +129,13 @@ export function SnapViewCoreInner({
128
129
  }, [spec]);
129
130
 
130
131
  const showConfetti = snap.effects?.includes("confetti") ?? false;
132
+ const showFireworks = snap.effects?.includes("fireworks") ?? false;
131
133
  const [confettiKey, setConfettiKey] = useState(0);
134
+ const [fireworksKey, setFireworksKey] = useState(0);
132
135
  useEffect(() => {
133
136
  if (showConfetti) setConfettiKey((k) => k + 1);
134
- }, [showConfetti, snap]);
137
+ if (showFireworks) setFireworksKey((k) => k + 1);
138
+ }, [showConfetti, showFireworks, snap]);
135
139
 
136
140
  const handlersRef = useRef(handlers);
137
141
  handlersRef.current = handlers;
@@ -213,6 +217,7 @@ export function SnapViewCoreInner({
213
217
  onAction={handleAction}
214
218
  />
215
219
  {showConfetti && <ConfettiOverlay key={confettiKey} />}
220
+ {showFireworks && <FireworksOverlay key={fireworksKey} />}
216
221
  </View>
217
222
  );
218
223
  }
@@ -1,5 +1,7 @@
1
1
  import { Children, isValidElement, type ReactNode } from "react";
2
2
 
3
+ import { shouldUseHorizontalButtonContent } from "./button-orientation-utils.js";
4
+
3
5
  /**
4
6
  * True when every rendered child comes from a catalog `button` element.
5
7
  * json-render passes `{ element: { type, props, ... } }` into each catalog component.
@@ -10,32 +12,49 @@ function isRenderableChild(c: ReactNode): boolean {
10
12
  return true;
11
13
  }
12
14
 
13
- export function horizontalChildrenAreAllButtons(children: ReactNode): boolean {
15
+ export function childrenAreAllButtons(children: ReactNode): boolean {
16
+ return getButtonChildLabels(children) !== undefined;
17
+ }
18
+
19
+ export function getButtonChildLabels(children: ReactNode): string[] | undefined {
14
20
  const items = Children.toArray(children).filter(isRenderableChild);
15
- if (items.length === 0) return false;
21
+ if (items.length === 0) return undefined;
22
+ const labels: string[] = [];
16
23
  for (const child of items) {
17
- if (!isValidElement(child)) return false;
18
- const typ = (child.props as { element?: { type?: unknown } }).element?.type;
19
- if (typ !== "button") return false;
24
+ if (!isValidElement(child)) return undefined;
25
+ const element = (
26
+ child.props as {
27
+ element?: { type?: unknown; props?: Record<string, unknown> };
28
+ }
29
+ ).element;
30
+ if (element?.type !== "button") return undefined;
31
+ labels.push(String(element.props?.label ?? ""));
20
32
  }
21
- return true;
33
+ return labels;
34
+ }
35
+
36
+ export function childrenShouldUseHorizontalButtonLayout(
37
+ children: ReactNode,
38
+ ): boolean | undefined {
39
+ const labels = getButtonChildLabels(children);
40
+ return labels ? shouldUseHorizontalButtonContent(labels) : undefined;
22
41
  }
23
42
 
24
- /** Direct snap catalog children under a stack (used for all-button grid column count). */
43
+ /** Direct snap catalog children under a stack (used for horizontal gap defaults). */
25
44
  export function countRenderableChildren(children: ReactNode): number {
26
45
  return Children.toArray(children).filter(isRenderableChild).length;
27
46
  }
28
47
 
29
48
  /**
30
- * Default horizontal stack gap as a t-shirt size, chosen by column count:
31
- * 2 cols → lg, 3 cols → md, 4+ cols → sm. Unknown count falls back to md.
49
+ * Default horizontal stack gap as a t-shirt size, chosen by direct child count:
50
+ * 2 children → lg, 3 children → md, 4+ children → sm. Unknown count falls back to md.
32
51
  * Tighter gaps for denser layouts; authors can always override via the `gap` prop.
33
52
  */
34
53
  export function defaultHorizontalGapSize(
35
- columnCount: number | undefined,
54
+ childCount: number | undefined,
36
55
  ): "sm" | "md" | "lg" {
37
- if (columnCount === undefined) return "md";
38
- if (columnCount <= 2) return "lg";
39
- if (columnCount === 3) return "md";
56
+ if (childCount === undefined) return "md";
57
+ if (childCount <= 2) return "lg";
58
+ if (childCount === 3) return "md";
40
59
  return "sm";
41
60
  }