@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
@@ -6,10 +6,50 @@ import {
6
6
  ItemTitle,
7
7
  ItemDescription,
8
8
  ItemActions,
9
+ ItemMedia,
9
10
  } from "@neynar/ui/item";
10
11
  import { cn } from "@neynar/ui/utils";
11
12
  import { useSnapColors } from "../hooks/use-snap-colors";
12
13
  import { useSnapStackDirection } from "../stack-direction-context";
14
+ import { ICON_MAP } from "./icon";
15
+ import { useSnapItemGroupHasBorder } from "./item-layout-context";
16
+
17
+ type ItemMediaConfig =
18
+ | {
19
+ variant: "icon";
20
+ name: string;
21
+ color?: string;
22
+ }
23
+ | {
24
+ variant: "image";
25
+ url: string;
26
+ alt?: string;
27
+ round?: boolean;
28
+ };
29
+
30
+ function parseItemMedia(value: unknown): ItemMediaConfig | undefined {
31
+ if (!value || typeof value !== "object") return undefined;
32
+
33
+ const media = value as Record<string, unknown>;
34
+ if (media.variant === "icon" && typeof media.name === "string") {
35
+ return {
36
+ variant: "icon",
37
+ name: media.name,
38
+ color: typeof media.color === "string" ? media.color : undefined,
39
+ };
40
+ }
41
+
42
+ if (media.variant === "image" && typeof media.url === "string") {
43
+ return {
44
+ variant: "image",
45
+ url: media.url,
46
+ alt: typeof media.alt === "string" ? media.alt : undefined,
47
+ round: typeof media.round === "boolean" ? media.round : undefined,
48
+ };
49
+ }
50
+
51
+ return undefined;
52
+ }
13
53
 
14
54
  export function SnapItem({
15
55
  element: { props, children: childIds },
@@ -20,26 +60,79 @@ export function SnapItem({
20
60
  }) {
21
61
  const title = String(props.title ?? "");
22
62
  const description = props.description ? String(props.description) : undefined;
63
+ const media = parseItemMedia(props.media);
23
64
  const colors = useSnapColors();
65
+ const inBorderedGroup = useSnapItemGroupHasBorder();
24
66
  const inHorizontalStack = useSnapStackDirection() === "horizontal";
67
+ const MediaIcon =
68
+ media?.variant === "icon" ? ICON_MAP[media.name] : undefined;
25
69
 
26
70
  return (
27
71
  <Item
28
72
  className={cn(
29
- "py-1.5 px-2.5",
73
+ "gap-2 py-1.5",
74
+ inBorderedGroup ? "px-2" : "px-0",
30
75
  /** Horizontal: share width with peers. Vertical: don't fill column height. */
31
76
  inHorizontalStack && "flex-1",
32
77
  )}
78
+ style={{
79
+ columnGap: 8,
80
+ paddingInline: inBorderedGroup ? 8 : 0,
81
+ }}
33
82
  >
34
- <ItemContent className="gap-0.5">
83
+ {media?.variant === "icon" && MediaIcon && (
84
+ <ItemMedia
85
+ variant="icon"
86
+ className="self-center translate-y-0"
87
+ style={{ alignSelf: "center", transform: "none" }}
88
+ >
89
+ <MediaIcon
90
+ size={20}
91
+ style={{ color: colors.colorHex(media.color) }}
92
+ />
93
+ </ItemMedia>
94
+ )}
95
+ {media?.variant === "image" && (
96
+ <ItemMedia
97
+ variant="image"
98
+ className="self-center translate-y-0"
99
+ style={{
100
+ alignSelf: "center",
101
+ borderRadius: media.round ? "9999px" : undefined,
102
+ transform: "none",
103
+ }}
104
+ >
105
+ {/* eslint-disable-next-line @next/next/no-img-element */}
106
+ <img
107
+ src={media.url}
108
+ alt={media.alt ?? ""}
109
+ className="size-full object-cover"
110
+ />
111
+ </ItemMedia>
112
+ )}
113
+ <ItemContent className="gap-0">
35
114
  <ItemTitle style={{ color: colors.text }}>{title}</ItemTitle>
36
115
  {description && (
37
- <ItemDescription className="mt-0" style={{ color: colors.textMuted }}>
116
+ <ItemDescription
117
+ className="mt-0 text-xs leading-snug"
118
+ style={{
119
+ color: colors.textMuted,
120
+ fontSize: 12,
121
+ lineHeight: "16px",
122
+ }}
123
+ >
38
124
  {description}
39
125
  </ItemDescription>
40
126
  )}
41
127
  </ItemContent>
42
- {childIds && childIds.length > 0 && <ItemActions>{children}</ItemActions>}
128
+ {childIds && childIds.length > 0 && (
129
+ <ItemActions
130
+ className="gap-1.5 self-center"
131
+ style={{ alignSelf: "center", columnGap: 6 }}
132
+ >
133
+ {children}
134
+ </ItemActions>
135
+ )}
43
136
  </Item>
44
137
  );
45
138
  }
@@ -1,11 +1,12 @@
1
1
  "use client";
2
2
 
3
- import type { ReactNode } from "react";
3
+ import type { CSSProperties, ReactNode } from "react";
4
4
  import { cn } from "@neynar/ui/utils";
5
5
  import {
6
+ childrenShouldUseHorizontalButtonLayout,
7
+ childrenAreAllButtons,
6
8
  countRenderableChildren,
7
9
  defaultHorizontalGapSize,
8
- horizontalChildrenAreAllButtons,
9
10
  } from "../../stack-horizontal-utils.js";
10
11
  import {
11
12
  SnapStackDirectionProvider,
@@ -14,7 +15,7 @@ import {
14
15
 
15
16
  const VGAP: Record<string, string> = {
16
17
  none: "gap-0",
17
- sm: "gap-2",
18
+ sm: "gap-1",
18
19
  md: "gap-4",
19
20
  lg: "gap-6",
20
21
  };
@@ -34,14 +35,14 @@ const JUSTIFY_FLEX: Record<string, string> = {
34
35
  around: "justify-around",
35
36
  };
36
37
 
37
- /** Equal columns for explicit `columns` prop and for all-button horizontal rows. */
38
+ /** Equal-width cell count for explicit `equalWidth` / `columns` props. */
38
39
  const COLUMN_GRID_CLASS: Record<number, string> = {
39
- 1: "grid grid-cols-1 auto-rows-auto items-stretch [&>*]:min-w-0",
40
- 2: "grid grid-cols-2 auto-rows-auto items-stretch [&>*]:min-w-0",
41
- 3: "grid grid-cols-3 auto-rows-auto items-stretch [&>*]:min-w-0",
42
- 4: "grid grid-cols-4 auto-rows-auto items-stretch [&>*]:min-w-0",
43
- 5: "grid grid-cols-5 auto-rows-auto items-stretch [&>*]:min-w-0",
44
- 6: "grid grid-cols-6 auto-rows-auto items-stretch [&>*]:min-w-0",
40
+ 1: "auto-rows-auto items-stretch [&>*]:min-w-0 [&>*]:w-full",
41
+ 2: "auto-rows-auto items-stretch [&>*]:min-w-0 [&>*]:w-full",
42
+ 3: "auto-rows-auto items-stretch [&>*]:min-w-0 [&>*]:w-full",
43
+ 4: "auto-rows-auto items-stretch [&>*]:min-w-0 [&>*]:w-full",
44
+ 5: "auto-rows-auto items-stretch [&>*]:min-w-0 [&>*]:w-full",
45
+ 6: "auto-rows-auto items-stretch [&>*]:min-w-0 [&>*]:w-full",
45
46
  };
46
47
 
47
48
  export function SnapStack({
@@ -52,17 +53,21 @@ export function SnapStack({
52
53
  children?: ReactNode;
53
54
  }) {
54
55
  const parentDirection = useSnapStackDirection();
55
- const direction = String(props.direction ?? "vertical");
56
+ const buttonContentUsesHorizontal =
57
+ childrenShouldUseHorizontalButtonLayout(children);
58
+ const direction =
59
+ buttonContentUsesHorizontal === undefined
60
+ ? String(props.direction ?? "vertical")
61
+ : buttonContentUsesHorizontal
62
+ ? "horizontal"
63
+ : "vertical";
56
64
  const isHorizontal = direction === "horizontal";
57
65
  const justifyKey = props.justify ? String(props.justify) : undefined;
58
66
  const justifyFlex = justifyKey ? JUSTIFY_FLEX[justifyKey] : undefined;
59
- const buttonRowGrid =
60
- isHorizontal && horizontalChildrenAreAllButtons(children);
61
- const buttonRowCount = buttonRowGrid
62
- ? countRenderableChildren(children)
63
- : 0;
67
+ const allChildrenAreButtons = childrenAreAllButtons(children);
64
68
 
65
69
  const columnsRaw = props.columns;
70
+ const equalWidth = props.equalWidth === true;
66
71
  const columns =
67
72
  typeof columnsRaw === "number" &&
68
73
  columnsRaw >= 2 &&
@@ -71,29 +76,36 @@ export function SnapStack({
71
76
  ? columnsRaw
72
77
  : undefined;
73
78
 
74
- // Horizontal default depends on column count: 2→lg, 3→md, 4+→sm. Vertical stays md.
75
- // Count comes from explicit `columns`, then button-row inference, else direct children
76
- // count (any horizontal stack is N columns wide regardless of child types).
77
- const horizontalColumnCount = isHorizontal
78
- ? (columns ??
79
- (buttonRowGrid ? buttonRowCount : undefined) ??
80
- countRenderableChildren(children))
79
+ const equalWidthColumnCount =
80
+ columns ?? (equalWidth ? countRenderableChildren(children) : undefined);
81
+ const explicitEqualWidth =
82
+ isHorizontal &&
83
+ equalWidthColumnCount !== undefined &&
84
+ equalWidthColumnCount >= 1 &&
85
+ equalWidthColumnCount <= 6;
86
+
87
+ // Button-only stacks always default to sm; mixed horizontal stacks scale by child count.
88
+ // Vertical non-button stacks default to md.
89
+ const horizontalChildCount = isHorizontal
90
+ ? (explicitEqualWidth
91
+ ? equalWidthColumnCount
92
+ : countRenderableChildren(children))
81
93
  : undefined;
82
94
  const explicitGap =
83
95
  typeof props.gap === "string" && props.gap in (isHorizontal ? HGAP : VGAP);
84
96
  const gapKey = explicitGap
85
97
  ? String(props.gap)
86
- : isHorizontal
87
- ? defaultHorizontalGapSize(horizontalColumnCount)
98
+ : allChildrenAreButtons
99
+ ? "sm"
100
+ : isHorizontal
101
+ ? defaultHorizontalGapSize(horizontalChildCount)
88
102
  : "md";
89
103
  const gap = isHorizontal
90
104
  ? (HGAP[gapKey] ?? HGAP.md!)
91
105
  : (VGAP[gapKey] ?? VGAP.md!);
92
- const explicitColumnGrid =
93
- isHorizontal && columns !== undefined && !buttonRowGrid;
94
106
  const columnGridClass =
95
- explicitColumnGrid && columns !== undefined
96
- ? COLUMN_GRID_CLASS[columns]
107
+ explicitEqualWidth && equalWidthColumnCount !== undefined
108
+ ? COLUMN_GRID_CLASS[equalWidthColumnCount]
97
109
  : undefined;
98
110
 
99
111
  /**
@@ -108,11 +120,18 @@ export function SnapStack({
108
120
 
109
121
  const justifyBlockGrid =
110
122
  justifyFlex &&
111
- (!isHorizontal || (!buttonRowGrid && !explicitColumnGrid));
123
+ (!isHorizontal || !explicitEqualWidth);
112
124
 
113
125
  /** Single flex row (nowrap): peers stay side-by-side and shrink via min-w-0 / flex-1 on nested stacks. */
114
126
  const horizontalFlexClasses =
115
127
  "flex min-w-0 flex-row flex-nowrap items-stretch [&>*]:min-w-0";
128
+ const equalWidthStyle: CSSProperties | undefined =
129
+ explicitEqualWidth && equalWidthColumnCount !== undefined
130
+ ? {
131
+ display: "grid",
132
+ gridTemplateColumns: `repeat(${equalWidthColumnCount}, minmax(0, 1fr))`,
133
+ }
134
+ : undefined;
116
135
 
117
136
  return (
118
137
  <SnapStackDirectionProvider
@@ -122,20 +141,12 @@ export function SnapStack({
122
141
  className={cn(
123
142
  rootWidthClass,
124
143
  isHorizontal
125
- ? buttonRowGrid &&
126
- buttonRowCount >= 1 &&
127
- buttonRowCount <= 6 &&
128
- COLUMN_GRID_CLASS[buttonRowCount]
129
- ? cn(
130
- COLUMN_GRID_CLASS[buttonRowCount]!,
131
- gap,
132
- "[&>*]:w-full",
133
- )
134
- : explicitColumnGrid && columnGridClass
144
+ ? explicitEqualWidth && columnGridClass
135
145
  ? cn(columnGridClass, gap)
136
146
  : cn(horizontalFlexClasses, gap, justifyBlockGrid ? justifyFlex : undefined)
137
147
  : cn("flex min-w-0 w-full flex-col", gap, justifyFlex),
138
148
  )}
149
+ style={equalWidthStyle}
139
150
  >
140
151
  {children}
141
152
  </div>
@@ -4,6 +4,7 @@ import { useState } from "react";
4
4
  import { useStateStore } from "@json-render/react";
5
5
  import { Label } from "@neynar/ui/label";
6
6
  import { cn } from "@neynar/ui/utils";
7
+ import { shouldUseHorizontalButtonContent } from "../../button-orientation-utils.js";
7
8
  import { useSnapColors } from "../hooks/use-snap-colors";
8
9
 
9
10
  export function SnapToggleGroup({
@@ -17,7 +18,6 @@ export function SnapToggleGroup({
17
18
  const path = `/inputs/${name}`;
18
19
  const label = props.label ? String(props.label) : undefined;
19
20
  const isMultiple = Boolean(props.multiple);
20
- const orientation = String(props.orientation ?? "horizontal");
21
21
  const options = Array.isArray(props.options)
22
22
  ? (props.options as string[])
23
23
  : [];
@@ -50,7 +50,7 @@ export function SnapToggleGroup({
50
50
  }
51
51
  };
52
52
 
53
- const isVertical = orientation === "vertical";
53
+ const isVertical = !shouldUseHorizontalButtonContent(options);
54
54
  const [hoveredIdx, setHoveredIdx] = useState<number | null>(null);
55
55
 
56
56
  return (
@@ -68,14 +68,16 @@ export function SnapToggleGroup({
68
68
  const isHovered = hoveredIdx === i && !isSelected;
69
69
  return (
70
70
  <button
71
- key={opt}
71
+ key={`${opt}-${i}`}
72
72
  type="button"
73
73
  onClick={() => toggle(opt)}
74
74
  onPointerEnter={() => setHoveredIdx(i)}
75
75
  onPointerLeave={() => setHoveredIdx(null)}
76
76
  className={cn(
77
77
  "rounded-md px-3 py-2 text-sm font-medium transition-colors",
78
- isVertical ? "w-full" : "flex-1",
78
+ isVertical
79
+ ? "w-full"
80
+ : "flex-auto whitespace-nowrap",
79
81
  )}
80
82
  style={{
81
83
  transition: "background-color 0.15s, color 0.15s",
@@ -63,18 +63,106 @@ const CONFETTI_COLORS = [
63
63
  "#06B6D4",
64
64
  ];
65
65
 
66
+ const FIREWORK_COLORS = [
67
+ "#FFD700",
68
+ "#FF6B6B",
69
+ "#4ECDC4",
70
+ "#C4A7E7",
71
+ "#F6C177",
72
+ "#EBBCBA",
73
+ "#9CCFD8",
74
+ "#fff",
75
+ ];
76
+
66
77
  function ConfettiOverlay() {
67
78
  const pieces = useMemo(
68
79
  () =>
69
- Array.from({ length: 80 }, (_, i) => ({
70
- id: i,
71
- left: Math.random() * 100,
72
- delay: Math.random() * 1.2,
73
- duration: 2.5 + Math.random() * 2,
74
- color:
75
- CONFETTI_COLORS[Math.floor(Math.random() * CONFETTI_COLORS.length)],
76
- size: 6 + Math.random() * 8,
77
- rotation: Math.random() * 360,
80
+ Array.from({ length: 80 }, (_, i) => {
81
+ const driftX = (Math.random() - 0.5) * 120;
82
+ return {
83
+ id: i,
84
+ left: Math.random() * 100,
85
+ delay: Math.random() * 1.2,
86
+ duration: 2.8 + Math.random() * 1.8,
87
+ color:
88
+ CONFETTI_COLORS[Math.floor(Math.random() * CONFETTI_COLORS.length)],
89
+ size: 6 + Math.random() * 8,
90
+ rotation: Math.random() * 360,
91
+ isCircle: Math.random() > 0.6,
92
+ driftX,
93
+ driftMid: -driftX * 0.4,
94
+ };
95
+ }),
96
+ [],
97
+ );
98
+
99
+ return (
100
+ <div
101
+ style={{
102
+ position: "absolute",
103
+ inset: 0,
104
+ overflow: "hidden",
105
+ pointerEvents: "none",
106
+ zIndex: 20,
107
+ }}
108
+ >
109
+ {pieces.map(
110
+ ({ id, left, delay, duration, color, size, rotation, isCircle, driftX, driftMid }) => (
111
+ <div
112
+ key={id}
113
+ style={
114
+ {
115
+ position: "absolute",
116
+ left: `${left}%`,
117
+ top: -20,
118
+ width: size,
119
+ height: isCircle ? size : size * 0.5,
120
+ backgroundColor: color,
121
+ borderRadius: isCircle ? "50%" : 2,
122
+ transform: `rotateZ(${rotation}deg)`,
123
+ animation: `confettiFall ${duration}s cubic-bezier(0.25,0,0.75,1) ${delay}s forwards`,
124
+ "--dx": `${driftX}px`,
125
+ "--dm": `${driftMid}px`,
126
+ } as CSSProperties
127
+ }
128
+ />
129
+ ),
130
+ )}
131
+ <style>{`@keyframes confettiFall{
132
+ 0% {top:-20px;opacity:1;transform:rotateZ(0deg) rotateY(0deg) translateX(0)}
133
+ 20% {transform:rotateZ(144deg) rotateY(60deg) translateX(var(--dm))}
134
+ 40% {transform:rotateZ(288deg) rotateY(120deg) translateX(0)}
135
+ 60% {opacity:1;transform:rotateZ(432deg) rotateY(200deg) translateX(calc(-1 * var(--dm)))}
136
+ 80% {transform:rotateZ(576deg) rotateY(280deg) translateX(var(--dx))}
137
+ 100%{top:110%;opacity:0;transform:rotateZ(720deg) rotateY(360deg) translateX(var(--dx))}
138
+ }`}</style>
139
+ </div>
140
+ );
141
+ }
142
+
143
+ function FireworksOverlay() {
144
+ const bursts = useMemo(
145
+ () =>
146
+ Array.from({ length: 5 }, (_, b) => ({
147
+ id: b,
148
+ x: 15 + Math.random() * 70,
149
+ y: 10 + Math.random() * 50,
150
+ delay: b * 0.5 + Math.random() * 0.2,
151
+ particles: Array.from({ length: 24 }, (_, p) => {
152
+ const angle =
153
+ (p / 24) * Math.PI * 2 + (Math.random() - 0.5) * 0.2;
154
+ const dist = 55 + Math.random() * 60;
155
+ return {
156
+ id: p,
157
+ vx: Math.cos(angle) * dist,
158
+ vy: Math.sin(angle) * dist,
159
+ color:
160
+ FIREWORK_COLORS[
161
+ Math.floor(Math.random() * FIREWORK_COLORS.length)
162
+ ],
163
+ size: 3 + Math.random() * 3,
164
+ };
165
+ }),
78
166
  })),
79
167
  [],
80
168
  );
@@ -89,25 +177,57 @@ function ConfettiOverlay() {
89
177
  zIndex: 20,
90
178
  }}
91
179
  >
92
- {pieces.map(({ id, left, delay, duration, color, size, rotation }) => (
93
- <div
94
- key={id}
95
- style={{
96
- position: "absolute",
97
- left: `${left}%`,
98
- top: -20,
99
- width: size,
100
- height: size * 0.6,
101
- backgroundColor: color,
102
- borderRadius: 2,
103
- transform: `rotate(${rotation}deg)`,
104
- animation: `confettiFall ${duration}s ease-in ${delay}s forwards`,
105
- }}
106
- />
180
+ {bursts.map(({ id: bid, x, y, delay, particles }) => (
181
+ <div key={bid}>
182
+ <div
183
+ style={{
184
+ position: "absolute",
185
+ left: `${x}%`,
186
+ top: `${y}%`,
187
+ width: 12,
188
+ height: 12,
189
+ borderRadius: "50%",
190
+ backgroundColor: "#fff",
191
+ transform: "translate(-50%,-50%)",
192
+ animation: `fwFlash 0.4s ease-out ${delay}s both`,
193
+ opacity: 0,
194
+ }}
195
+ />
196
+ {particles.map(({ id: pid, vx, vy, color, size }) => (
197
+ <div
198
+ key={pid}
199
+ style={
200
+ {
201
+ position: "absolute",
202
+ left: `${x}%`,
203
+ top: `${y}%`,
204
+ width: size,
205
+ height: size,
206
+ borderRadius: "50%",
207
+ backgroundColor: color,
208
+ transform: "translate(-50%,-50%)",
209
+ animation: `fwBurst 1s cubic-bezier(0.2,0,0.8,1) ${delay}s forwards`,
210
+ opacity: 0,
211
+ "--vx": `${vx}px`,
212
+ "--vy": `${vy}px`,
213
+ } as CSSProperties
214
+ }
215
+ />
216
+ ))}
217
+ </div>
107
218
  ))}
108
- <style>{`@keyframes confettiFall{0%{top:-20px;opacity:1;transform:rotate(0deg) translateX(0)}50%{opacity:1}100%{top:110%;opacity:0;transform:rotate(720deg) translateX(${
109
- Math.random() > 0.5 ? "" : "-"
110
- }40px)}}`}</style>
219
+ <style>{`
220
+ @keyframes fwFlash{
221
+ 0% {opacity:0;transform:translate(-50%,-50%) scale(0)}
222
+ 25% {opacity:1;transform:translate(-50%,-50%) scale(2.5)}
223
+ 100%{opacity:0;transform:translate(-50%,-50%) scale(5)}
224
+ }
225
+ @keyframes fwBurst{
226
+ 0% {opacity:1;transform:translate(-50%,-50%) translate(0,0) scale(1)}
227
+ 65% {opacity:1}
228
+ 100%{opacity:0;transform:translate(-50%,-50%) translate(var(--vx),var(--vy)) scale(0)}
229
+ }
230
+ `}</style>
111
231
  </div>
112
232
  );
113
233
  }
@@ -240,10 +360,13 @@ export function SnapViewCore({
240
360
  }, [spec]);
241
361
 
242
362
  const showConfetti = snap.effects?.includes("confetti") ?? false;
363
+ const showFireworks = snap.effects?.includes("fireworks") ?? false;
243
364
  const [confettiKey, setConfettiKey] = useState(0);
365
+ const [fireworksKey, setFireworksKey] = useState(0);
244
366
  useEffect(() => {
245
367
  if (showConfetti) setConfettiKey((k) => k + 1);
246
- }, [showConfetti, snap]);
368
+ if (showFireworks) setFireworksKey((k) => k + 1);
369
+ }, [showConfetti, showFireworks, snap]);
247
370
 
248
371
  const accentName = snap.theme?.accent ?? "purple";
249
372
 
@@ -326,6 +449,7 @@ export function SnapViewCore({
326
449
  return (
327
450
  <div style={{ position: "relative", width: "100%" }}>
328
451
  {showConfetti && <ConfettiOverlay key={confettiKey} />}
452
+ {showFireworks && <FireworksOverlay key={fireworksKey} />}
329
453
  {loadingOverlay === undefined ? (
330
454
  <SnapLoadingOverlay
331
455
  appearance={appearance}
@@ -0,0 +1,10 @@
1
+ import { createContext, useContext } from "react";
2
+
3
+ const SnapItemGroupBorderContext = createContext(false);
4
+
5
+ export const SnapItemGroupBorderProvider =
6
+ SnapItemGroupBorderContext.Provider;
7
+
8
+ export function useSnapItemGroupHasBorder() {
9
+ return useContext(SnapItemGroupBorderContext);
10
+ }
@@ -5,6 +5,7 @@ import { Pressable, StyleSheet, Text, View } from "react-native";
5
5
  import { ExternalLink } from "lucide-react-native";
6
6
  import { useSnapPalette } from "../use-snap-palette";
7
7
  import { useSnapTheme } from "../theme";
8
+ import { useSnapStackDirection } from "../stack-direction-context";
8
9
  import { ICON_MAP } from "./snap-icon";
9
10
 
10
11
  function isExternalLinkAction(
@@ -32,12 +33,13 @@ export function SnapActionButton({
32
33
 
33
34
  const textColor = isPrimary ? "#fff" : colors.text;
34
35
  const iconColor = isPrimary ? "#fff" : colors.text;
36
+ const inHorizontalStack = useSnapStackDirection() === "horizontal";
35
37
 
36
38
  const on = (element as unknown as { on?: Record<string, unknown> }).on;
37
39
  const showExternalIcon = isExternalLinkAction(on);
38
40
 
39
41
  return (
40
- <View style={styles.outer}>
42
+ <View style={inHorizontalStack ? styles.outerHorizontal : styles.outer}>
41
43
  <Pressable
42
44
  style={({ pressed }) => [
43
45
  styles.btn,
@@ -77,7 +79,18 @@ export function SnapActionButton({
77
79
  }
78
80
 
79
81
  const styles = StyleSheet.create({
80
- outer: { minWidth: 0 },
82
+ outer: {
83
+ width: "100%",
84
+ minWidth: 0,
85
+ alignSelf: "stretch",
86
+ },
87
+ outerHorizontal: {
88
+ flexGrow: 1,
89
+ flexShrink: 1,
90
+ flexBasis: "auto",
91
+ minWidth: 0,
92
+ alignSelf: "stretch",
93
+ },
81
94
  btn: {
82
95
  paddingHorizontal: 16,
83
96
  borderRadius: 10,
@@ -18,6 +18,7 @@ export function SnapCellGrid({
18
18
  const rows = Number(props.rows ?? 2);
19
19
  const cells = Array.isArray(props.cells) ? props.cells : [];
20
20
  const rowHeight = typeof props.rowHeight === "number" ? props.rowHeight : 28;
21
+ const squareCells = props.cellAspectRatio === "square";
21
22
  const gap = String(props.gap ?? "sm");
22
23
  const gapMap: Record<string, number> = { none: 0, sm: 1, md: 2, lg: 4 };
23
24
  const gapPx = gapMap[gap] ?? 1;
@@ -101,18 +102,35 @@ export function SnapCellGrid({
101
102
 
102
103
  // Two-tone ring: outer View with contrasting border, inner View with inverse border
103
104
  const cellView = selected ? (
104
- <View style={[styles.cell, { height: rowHeight, borderWidth: 1, borderColor: ringOuter, borderRadius: 4 }]}>
105
+ <View
106
+ style={[
107
+ styles.cell,
108
+ squareCells ? styles.squareCell : { height: rowHeight },
109
+ { borderWidth: 1, borderColor: ringOuter, borderRadius: 4 },
110
+ ]}
111
+ >
105
112
  <View
106
113
  style={[
107
114
  styles.innerCell,
108
- { backgroundColor: bg, borderWidth: 1, borderColor: ringInner, borderRadius: 3 },
115
+ {
116
+ backgroundColor: bg,
117
+ borderWidth: 1,
118
+ borderColor: ringInner,
119
+ borderRadius: 3,
120
+ },
109
121
  ]}
110
122
  >
111
123
  {cellContent}
112
124
  </View>
113
125
  </View>
114
126
  ) : (
115
- <View style={[styles.cell, { height: rowHeight, backgroundColor: bg }]}>
127
+ <View
128
+ style={[
129
+ styles.cell,
130
+ squareCells ? styles.squareCell : { height: rowHeight },
131
+ { backgroundColor: bg },
132
+ ]}
133
+ >
116
134
  {cellContent}
117
135
  </View>
118
136
  );
@@ -141,7 +159,17 @@ export function SnapCellGrid({
141
159
  }
142
160
 
143
161
  return (
144
- <View style={[styles.wrap, { gap: gapPx, backgroundColor: colors.muted, padding: 4, borderRadius: 8 }]}>
162
+ <View
163
+ style={[
164
+ styles.wrap,
165
+ {
166
+ gap: gapPx,
167
+ backgroundColor: colors.muted,
168
+ padding: 4,
169
+ borderRadius: 8,
170
+ },
171
+ ]}
172
+ >
145
173
  {rowEls}
146
174
  </View>
147
175
  );
@@ -156,6 +184,10 @@ const styles = StyleSheet.create({
156
184
  alignItems: "center",
157
185
  justifyContent: "center",
158
186
  },
187
+ squareCell: {
188
+ aspectRatio: 1,
189
+ width: "100%",
190
+ },
159
191
  innerCell: {
160
192
  width: "100%",
161
193
  height: "100%",