@farcaster/snap 2.3.0 → 2.4.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.
package/dist/colors.d.ts CHANGED
@@ -34,6 +34,14 @@ export declare function resolveSnapColorHex(color: string | undefined, opts: {
34
34
  accentHex: string;
35
35
  appearance: "light" | "dark";
36
36
  }): string;
37
+ /**
38
+ * Pick a readable text color for a given hex background.
39
+ *
40
+ * Uses WCAG relative luminance with a 0.5 threshold. Returns `rgba(...)` so
41
+ * callers can soften the text against the background — defaults to 0.8 alpha
42
+ * to let a hint of the cell color bleed through.
43
+ */
44
+ export declare function readableTextOnHex(hex: string, alpha?: number): string;
37
45
  /** Light-mode hex for each palette color (emulator / reference client). */
38
46
  export declare const PALETTE_LIGHT_HEX: Record<PaletteColor, string>;
39
47
  /** Dark-mode hex for each palette color (reference). */
package/dist/colors.js CHANGED
@@ -54,6 +54,27 @@ export function resolveSnapColorHex(color, opts) {
54
54
  }
55
55
  return opts.accentHex;
56
56
  }
57
+ /**
58
+ * Pick a readable text color for a given hex background.
59
+ *
60
+ * Uses WCAG relative luminance with a 0.5 threshold. Returns `rgba(...)` so
61
+ * callers can soften the text against the background — defaults to 0.8 alpha
62
+ * to let a hint of the cell color bleed through.
63
+ */
64
+ export function readableTextOnHex(hex, alpha = 0.8) {
65
+ const m = /^#([0-9a-fA-F]{6})$/.exec(hex.trim());
66
+ if (!m)
67
+ return `rgba(0,0,0,${alpha})`;
68
+ const n = Number.parseInt(m[1], 16);
69
+ const toLin = (c) => {
70
+ const s = c / 255;
71
+ return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
72
+ };
73
+ const L = 0.2126 * toLin((n >> 16) & 0xff) +
74
+ 0.7152 * toLin((n >> 8) & 0xff) +
75
+ 0.0722 * toLin(n & 0xff);
76
+ return L >= 0.5 ? `rgba(0,0,0,${alpha})` : `rgba(255,255,255,${alpha})`;
77
+ }
57
78
  /** Light-mode hex for each palette color (emulator / reference client). */
58
79
  export const PALETTE_LIGHT_HEX = {
59
80
  gray: "#6E6A86",
@@ -5,7 +5,7 @@ export declare const SUPPORTED_SPEC_VERSIONS: readonly ["1.0", "2.0"];
5
5
  export type SpecVersion = (typeof SUPPORTED_SPEC_VERSIONS)[number];
6
6
  export declare const SNAP_PAYLOAD_HEADER: "X-Snap-Payload";
7
7
  export declare const MEDIA_TYPE: "application/vnd.farcaster.snap+json";
8
- export declare const EFFECT_VALUES: readonly ["confetti"];
8
+ export declare const EFFECT_VALUES: readonly ["confetti", "fireworks"];
9
9
  export declare const POST_GRID_TAP_KEY: "grid_tap";
10
10
  export declare const GRID_MIN_COLS = 2;
11
11
  export declare const GRID_MAX_COLS = 32;
package/dist/constants.js CHANGED
@@ -7,7 +7,7 @@ export const SUPPORTED_SPEC_VERSIONS = [
7
7
  ];
8
8
  export const SNAP_PAYLOAD_HEADER = "X-Snap-Payload";
9
9
  export const MEDIA_TYPE = "application/vnd.farcaster.snap+json";
10
- export const EFFECT_VALUES = ["confetti"];
10
+ export const EFFECT_VALUES = ["confetti", "fireworks"];
11
11
  // ─── Pixel grid ────────────────────────────────────────
12
12
  export const POST_GRID_TAP_KEY = "grid_tap";
13
13
  export const GRID_MIN_COLS = 2;
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export type { Spec as SnapSpec, UIElement as SnapUIElement, } from "@json-render/core";
2
2
  export { SPEC_VERSION, SPEC_VERSION_1, SPEC_VERSION_2, SUPPORTED_SPEC_VERSIONS, type SpecVersion, SNAP_PAYLOAD_HEADER, MEDIA_TYPE, EFFECT_VALUES, POST_GRID_TAP_KEY, MAX_ELEMENTS, MAX_ROOT_CHILDREN, MAX_CHILDREN, MAX_DEPTH, } from "./constants.js";
3
- export { DEFAULT_THEME_ACCENT, PALETTE_COLOR, PALETTE_COLOR_ACCENT, PALETTE_COLOR_VALUES, PALETTE_LIGHT_HEX, PALETTE_DARK_HEX, isSnapHexColorString, resolveSnapColorHex, type PaletteColor, } from "./colors.js";
3
+ export { DEFAULT_THEME_ACCENT, PALETTE_COLOR, PALETTE_COLOR_ACCENT, PALETTE_COLOR_VALUES, PALETTE_LIGHT_HEX, PALETTE_DARK_HEX, isSnapHexColorString, readableTextOnHex, resolveSnapColorHex, type PaletteColor, } from "./colors.js";
4
4
  export { ACTION_TYPE_GET, ACTION_TYPE_POST, snapResponseSchema, payloadSchema, getPayloadSchema, type SnapAction, type SnapGetAction, type SnapContext, type SnapResponse, type SnapHandlerResult, type SnapElementInput, type SnapSpecInput, type SnapFunction, type SnapPayload, type SnapGetPayload, } from "./schemas.js";
5
5
  export { validateSnapResponse, type ValidationResult } from "./validator.js";
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
1
  export { SPEC_VERSION, SPEC_VERSION_1, SPEC_VERSION_2, SUPPORTED_SPEC_VERSIONS, SNAP_PAYLOAD_HEADER, MEDIA_TYPE, EFFECT_VALUES, POST_GRID_TAP_KEY, MAX_ELEMENTS, MAX_ROOT_CHILDREN, MAX_CHILDREN, MAX_DEPTH, } from "./constants.js";
2
- export { DEFAULT_THEME_ACCENT, PALETTE_COLOR, PALETTE_COLOR_ACCENT, PALETTE_COLOR_VALUES, PALETTE_LIGHT_HEX, PALETTE_DARK_HEX, isSnapHexColorString, resolveSnapColorHex, } from "./colors.js";
2
+ export { DEFAULT_THEME_ACCENT, PALETTE_COLOR, PALETTE_COLOR_ACCENT, PALETTE_COLOR_VALUES, PALETTE_LIGHT_HEX, PALETTE_DARK_HEX, isSnapHexColorString, readableTextOnHex, resolveSnapColorHex, } from "./colors.js";
3
3
  export { ACTION_TYPE_GET, ACTION_TYPE_POST, snapResponseSchema, payloadSchema, getPayloadSchema, } from "./schemas.js";
4
4
  export { validateSnapResponse } from "./validator.js";
@@ -5,6 +5,7 @@ import { ExternalLink } from "lucide-react";
5
5
  import { Button } from "@neynar/ui/button";
6
6
  import { cn } from "@neynar/ui/utils";
7
7
  import { useSnapColors } from "../hooks/use-snap-colors.js";
8
+ import { useSnapStackDirection } from "../stack-direction-context.js";
8
9
  import { ICON_MAP } from "./icon.js";
9
10
  function isExternalLinkAction(on) {
10
11
  if (!on)
@@ -24,6 +25,7 @@ export function SnapActionButton({ element, emit, }) {
24
25
  const [hovered, setHovered] = useState(false);
25
26
  const Icon = iconName ? ICON_MAP[iconName] : undefined;
26
27
  const showExternalIcon = isExternalLinkAction(element.on);
28
+ const inHorizontalStack = useSnapStackDirection() === "horizontal";
27
29
  const style = {
28
30
  cursor: "pointer",
29
31
  ...(isPrimary
@@ -40,5 +42,13 @@ export function SnapActionButton({ element, emit, }) {
40
42
  borderColor: "transparent",
41
43
  }),
42
44
  };
43
- return (_jsx("div", { className: "w-full min-w-0 flex-1", 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 } }))] }) }));
45
+ return (
46
+ /**
47
+ * In a horizontal stack, `flex-1` lets the wrapper share row width with peers.
48
+ * In a vertical stack, `flex-1` would silently grow the button to fill column
49
+ * height (1/N distribution when siblings also flex-grow); stick to `w-full`.
50
+ */
51
+ _jsx("div", { className: inHorizontalStack
52
+ ? "w-full min-w-0 flex-1"
53
+ : "w-full min-w-0", 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 } }))] }) }));
44
54
  }
@@ -2,7 +2,7 @@
2
2
  import { jsx as _jsx } from "react/jsx-runtime";
3
3
  import { useStateStore } from "@json-render/react";
4
4
  import { cn } from "@neynar/ui/utils";
5
- import { POST_GRID_TAP_KEY } from "@farcaster/snap";
5
+ import { POST_GRID_TAP_KEY, readableTextOnHex } from "@farcaster/snap";
6
6
  import { useSnapColors } from "../hooks/use-snap-colors.js";
7
7
  export function SnapCellGrid({ element: { props, on }, emit, }) {
8
8
  const { get, set } = useStateStore();
@@ -69,7 +69,9 @@ export function SnapCellGrid({ element: { props, on }, emit, }) {
69
69
  for (let c = 0; c < cols; c++) {
70
70
  const cell = cellMap.get(`${r},${c}`);
71
71
  const selected = interactive && isSelected(r, c);
72
- const bg = cell?.color ? colors.colorHex(cell.color) : emptyCellBg;
72
+ const bgHex = cell?.color ? colors.colorHex(cell.color) : null;
73
+ const bg = bgHex ?? emptyCellBg;
74
+ const textColor = bgHex ? readableTextOnHex(bgHex) : colors.text;
73
75
  cellEls.push(_jsx("div", { role: interactive ? "button" : undefined, tabIndex: interactive ? 0 : undefined, onClick: interactive ? () => handleTap(r, c) : undefined, onKeyDown: interactive
74
76
  ? (e) => {
75
77
  if (e.key === "Enter" || e.key === " ") {
@@ -80,6 +82,7 @@ export function SnapCellGrid({ element: { props, on }, emit, }) {
80
82
  : undefined, className: cn("flex items-center justify-center rounded text-xs font-semibold", interactive ? "cursor-pointer select-none" : "cursor-default"), style: {
81
83
  height: rowHeight,
82
84
  background: bg,
85
+ color: textColor,
83
86
  boxShadow: selected
84
87
  ? `inset 0 0 0 1px ${colors.mode === "dark" ? "#000" : "#fff"}, inset 0 0 0 2px ${colors.mode === "dark" ? "#fff" : "#000"}`
85
88
  : undefined,
@@ -1,10 +1,15 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { Item, ItemContent, ItemTitle, ItemDescription, ItemActions, } from "@neynar/ui/item";
4
+ import { cn } from "@neynar/ui/utils";
4
5
  import { useSnapColors } from "../hooks/use-snap-colors.js";
6
+ import { useSnapStackDirection } from "../stack-direction-context.js";
5
7
  export function SnapItem({ element: { props, children: childIds }, children, }) {
6
8
  const title = String(props.title ?? "");
7
9
  const description = props.description ? String(props.description) : undefined;
8
10
  const colors = useSnapColors();
9
- return (_jsxs(Item, { className: "flex-1 py-1.5 px-2.5", children: [_jsxs(ItemContent, { className: "gap-0.5", children: [_jsx(ItemTitle, { style: { color: colors.text }, children: title }), description && (_jsx(ItemDescription, { className: "mt-0", style: { color: colors.textMuted }, children: description }))] }), childIds && childIds.length > 0 && _jsx(ItemActions, { children: children })] }));
11
+ const inHorizontalStack = useSnapStackDirection() === "horizontal";
12
+ return (_jsxs(Item, { className: cn("py-1.5 px-2.5",
13
+ /** Horizontal: share width with peers. Vertical: don't fill column height. */
14
+ inHorizontalStack && "flex-1"), children: [_jsxs(ItemContent, { className: "gap-0.5", children: [_jsx(ItemTitle, { style: { color: colors.text }, children: title }), description && (_jsx(ItemDescription, { className: "mt-0", style: { color: colors.textMuted }, children: description }))] }), childIds && childIds.length > 0 && _jsx(ItemActions, { children: children })] }));
10
15
  }
@@ -1,11 +1,16 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { cn } from "@neynar/ui/utils";
3
4
  import { useSnapColors } from "../hooks/use-snap-colors.js";
5
+ import { useSnapStackDirection } from "../stack-direction-context.js";
4
6
  export function SnapProgress({ element: { props }, }) {
5
7
  const colors = useSnapColors();
6
8
  const value = Number(props.value ?? 0);
7
9
  const max = Math.max(1, Number(props.max ?? 100));
8
10
  const percent = Math.min(100, Math.max(0, (value / max) * 100));
9
11
  const label = props.label ? String(props.label) : null;
10
- return (_jsxs("div", { className: "flex w-full flex-1 flex-col gap-1", children: [label && (_jsx("span", { className: "text-xs", style: { color: colors.textMuted }, children: label })), _jsx("div", { className: "h-2.5 w-full overflow-hidden rounded-full", style: { backgroundColor: colors.muted }, children: _jsx("div", { className: "h-full rounded-full transition-all", style: { width: `${percent}%`, backgroundColor: colors.accent } }) })] }));
12
+ const inHorizontalStack = useSnapStackDirection() === "horizontal";
13
+ return (_jsxs("div", { className: cn("flex w-full flex-col gap-1",
14
+ /** Horizontal: share width with peers. Vertical: don't fill column height. */
15
+ inHorizontalStack && "flex-1"), children: [label && (_jsx("span", { className: "text-xs", style: { color: colors.textMuted }, children: label })), _jsx("div", { className: "h-2.5 w-full overflow-hidden rounded-full", style: { backgroundColor: colors.muted }, children: _jsx("div", { className: "h-full rounded-full transition-all", style: { width: `${percent}%`, backgroundColor: colors.accent } }) })] }));
11
16
  }
@@ -47,33 +47,119 @@ const CONFETTI_COLORS = [
47
47
  "#EF4444",
48
48
  "#06B6D4",
49
49
  ];
50
+ const FIREWORK_COLORS = [
51
+ "#FFD700",
52
+ "#FF6B6B",
53
+ "#4ECDC4",
54
+ "#C4A7E7",
55
+ "#F6C177",
56
+ "#EBBCBA",
57
+ "#9CCFD8",
58
+ "#fff",
59
+ ];
50
60
  function ConfettiOverlay() {
51
- const pieces = useMemo(() => Array.from({ length: 80 }, (_, i) => ({
52
- id: i,
53
- left: Math.random() * 100,
54
- delay: Math.random() * 1.2,
55
- duration: 2.5 + Math.random() * 2,
56
- color: CONFETTI_COLORS[Math.floor(Math.random() * CONFETTI_COLORS.length)],
57
- size: 6 + Math.random() * 8,
58
- rotation: Math.random() * 360,
59
- })), []);
61
+ const pieces = useMemo(() => Array.from({ length: 80 }, (_, i) => {
62
+ const driftX = (Math.random() - 0.5) * 120;
63
+ return {
64
+ id: i,
65
+ left: Math.random() * 100,
66
+ delay: Math.random() * 1.2,
67
+ duration: 2.8 + Math.random() * 1.8,
68
+ color: CONFETTI_COLORS[Math.floor(Math.random() * CONFETTI_COLORS.length)],
69
+ size: 6 + Math.random() * 8,
70
+ rotation: Math.random() * 360,
71
+ isCircle: Math.random() > 0.6,
72
+ driftX,
73
+ driftMid: -driftX * 0.4,
74
+ };
75
+ }), []);
60
76
  return (_jsxs("div", { style: {
61
77
  position: "absolute",
62
78
  inset: 0,
63
79
  overflow: "hidden",
64
80
  pointerEvents: "none",
65
81
  zIndex: 20,
66
- }, children: [pieces.map(({ id, left, delay, duration, color, size, rotation }) => (_jsx("div", { style: {
82
+ }, children: [pieces.map(({ id, left, delay, duration, color, size, rotation, isCircle, driftX, driftMid }) => (_jsx("div", { style: {
67
83
  position: "absolute",
68
84
  left: `${left}%`,
69
85
  top: -20,
70
86
  width: size,
71
- height: size * 0.6,
87
+ height: isCircle ? size : size * 0.5,
72
88
  backgroundColor: color,
73
- borderRadius: 2,
74
- transform: `rotate(${rotation}deg)`,
75
- animation: `confettiFall ${duration}s ease-in ${delay}s forwards`,
76
- } }, id))), _jsx("style", { children: `@keyframes confettiFall{0%{top:-20px;opacity:1;transform:rotate(0deg) translateX(0)}50%{opacity:1}100%{top:110%;opacity:0;transform:rotate(720deg) translateX(${Math.random() > 0.5 ? "" : "-"}40px)}}` })] }));
89
+ borderRadius: isCircle ? "50%" : 2,
90
+ transform: `rotateZ(${rotation}deg)`,
91
+ animation: `confettiFall ${duration}s cubic-bezier(0.25,0,0.75,1) ${delay}s forwards`,
92
+ "--dx": `${driftX}px`,
93
+ "--dm": `${driftMid}px`,
94
+ } }, id))), _jsx("style", { children: `@keyframes confettiFall{
95
+ 0% {top:-20px;opacity:1;transform:rotateZ(0deg) rotateY(0deg) translateX(0)}
96
+ 20% {transform:rotateZ(144deg) rotateY(60deg) translateX(var(--dm))}
97
+ 40% {transform:rotateZ(288deg) rotateY(120deg) translateX(0)}
98
+ 60% {opacity:1;transform:rotateZ(432deg) rotateY(200deg) translateX(calc(-1 * var(--dm)))}
99
+ 80% {transform:rotateZ(576deg) rotateY(280deg) translateX(var(--dx))}
100
+ 100%{top:110%;opacity:0;transform:rotateZ(720deg) rotateY(360deg) translateX(var(--dx))}
101
+ }` })] }));
102
+ }
103
+ function FireworksOverlay() {
104
+ const bursts = useMemo(() => Array.from({ length: 5 }, (_, b) => ({
105
+ id: b,
106
+ x: 15 + Math.random() * 70,
107
+ y: 10 + Math.random() * 50,
108
+ delay: b * 0.5 + Math.random() * 0.2,
109
+ particles: Array.from({ length: 24 }, (_, p) => {
110
+ const angle = (p / 24) * Math.PI * 2 + (Math.random() - 0.5) * 0.2;
111
+ const dist = 55 + Math.random() * 60;
112
+ return {
113
+ id: p,
114
+ vx: Math.cos(angle) * dist,
115
+ vy: Math.sin(angle) * dist,
116
+ color: FIREWORK_COLORS[Math.floor(Math.random() * FIREWORK_COLORS.length)],
117
+ size: 3 + Math.random() * 3,
118
+ };
119
+ }),
120
+ })), []);
121
+ return (_jsxs("div", { style: {
122
+ position: "absolute",
123
+ inset: 0,
124
+ overflow: "hidden",
125
+ pointerEvents: "none",
126
+ zIndex: 20,
127
+ }, children: [bursts.map(({ id: bid, x, y, delay, particles }) => (_jsxs("div", { children: [_jsx("div", { style: {
128
+ position: "absolute",
129
+ left: `${x}%`,
130
+ top: `${y}%`,
131
+ width: 12,
132
+ height: 12,
133
+ borderRadius: "50%",
134
+ backgroundColor: "#fff",
135
+ transform: "translate(-50%,-50%)",
136
+ animation: `fwFlash 0.4s ease-out ${delay}s both`,
137
+ opacity: 0,
138
+ } }), particles.map(({ id: pid, vx, vy, color, size }) => (_jsx("div", { style: {
139
+ position: "absolute",
140
+ left: `${x}%`,
141
+ top: `${y}%`,
142
+ width: size,
143
+ height: size,
144
+ borderRadius: "50%",
145
+ backgroundColor: color,
146
+ transform: "translate(-50%,-50%)",
147
+ animation: `fwBurst 1s cubic-bezier(0.2,0,0.8,1) ${delay}s forwards`,
148
+ opacity: 0,
149
+ "--vx": `${vx}px`,
150
+ "--vy": `${vy}px`,
151
+ } }, pid)))] }, bid))), _jsx("style", { children: `
152
+ @keyframes fwFlash{
153
+ 0% {opacity:0;transform:translate(-50%,-50%) scale(0)}
154
+ 25% {opacity:1;transform:translate(-50%,-50%) scale(2.5)}
155
+ 100%{opacity:0;transform:translate(-50%,-50%) scale(5)}
156
+ }
157
+ @keyframes fwBurst{
158
+ 0% {opacity:1;transform:translate(-50%,-50%) translate(0,0) scale(1)}
159
+ 65% {opacity:1}
160
+ 100%{opacity:0;transform:translate(-50%,-50%) translate(var(--vx),var(--vy)) scale(0)}
161
+ }
162
+ ` })] }));
77
163
  }
78
164
  export function SnapLoadingOverlay({ appearance, accentHex, active, }) {
79
165
  const isDark = appearance === "dark";
@@ -156,11 +242,15 @@ export function SnapViewCore({ snap, handlers, loading = false, appearance = "da
156
242
  setPageKey((k) => k + 1);
157
243
  }, [spec]);
158
244
  const showConfetti = snap.effects?.includes("confetti") ?? false;
245
+ const showFireworks = snap.effects?.includes("fireworks") ?? false;
159
246
  const [confettiKey, setConfettiKey] = useState(0);
247
+ const [fireworksKey, setFireworksKey] = useState(0);
160
248
  useEffect(() => {
161
249
  if (showConfetti)
162
250
  setConfettiKey((k) => k + 1);
163
- }, [showConfetti, snap]);
251
+ if (showFireworks)
252
+ setFireworksKey((k) => k + 1);
253
+ }, [showConfetti, showFireworks, snap]);
164
254
  const accentName = snap.theme?.accent ?? "purple";
165
255
  const accentHex = useMemo(() => resolveSnapPaletteHex(accentName, appearance), [accentName, appearance]);
166
256
  const previewSurfaceStyle = useMemo(() => {
@@ -226,7 +316,7 @@ export function SnapViewCore({ snap, handlers, loading = false, appearance = "da
226
316
  break;
227
317
  }
228
318
  }, [handlers]);
229
- return (_jsxs("div", { style: { position: "relative", width: "100%" }, children: [showConfetti && _jsx(ConfettiOverlay, {}, confettiKey), 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) => {
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) => {
230
320
  applyStatePaths(stateRef.current, changes);
231
321
  }, onAction: handleAction }, pageKey) }) })] }));
232
322
  }
@@ -3,7 +3,7 @@ import { StyleSheet, Text, View, Pressable } from "react-native";
3
3
  import { useStateStore } from "@json-render/react-native";
4
4
  import { useSnapPalette } from "../use-snap-palette.js";
5
5
  import { useSnapTheme } from "../theme.js";
6
- import { POST_GRID_TAP_KEY } from "@farcaster/snap";
6
+ import { POST_GRID_TAP_KEY, readableTextOnHex } from "@farcaster/snap";
7
7
  export function SnapCellGrid({ element, emit, }) {
8
8
  const { props } = element;
9
9
  const on = element.on;
@@ -75,8 +75,10 @@ export function SnapCellGrid({ element, emit, }) {
75
75
  for (let c = 0; c < cols; c++) {
76
76
  const cell = cellMap.get(`${r},${c}`);
77
77
  const selected = interactive && isSelected(r, c);
78
- const bg = cell?.color ? hex(cell.color) : emptyCellBg;
79
- const cellContent = cell?.content ? (_jsx(Text, { style: [styles.cellText, { color: colors.textPrimary }], children: cell.content })) : null;
78
+ const bgHex = cell?.color ? hex(cell.color) : null;
79
+ const bg = bgHex ?? emptyCellBg;
80
+ const textColor = bgHex ? readableTextOnHex(bgHex) : colors.text;
81
+ const cellContent = cell?.content ? (_jsx(Text, { style: [styles.cellText, { color: textColor }], children: cell.content })) : null;
80
82
  // Two-tone ring: outer View with contrasting border, inner View with inverse border
81
83
  const cellView = selected ? (_jsx(View, { style: [styles.cell, { height: rowHeight, borderWidth: 1, borderColor: ringOuter, borderRadius: 4 }], children: _jsx(View, { style: [
82
84
  styles.innerCell,
@@ -0,0 +1 @@
1
+ export declare function FireworksOverlay(): import("react").JSX.Element;
@@ -0,0 +1,125 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useMemo, useRef, useState } from "react";
3
+ import { Animated, StyleSheet, View, useWindowDimensions } from "react-native";
4
+ const FIREWORK_COLORS = [
5
+ "#FFD700",
6
+ "#FF6B6B",
7
+ "#4ECDC4",
8
+ "#C4A7E7",
9
+ "#F6C177",
10
+ "#EBBCBA",
11
+ "#9CCFD8",
12
+ "#fff",
13
+ ];
14
+ const BURST_COUNT = 5;
15
+ const PARTICLE_COUNT = 24;
16
+ function FireworkBurst({ burst }) {
17
+ const flashAnim = useRef(new Animated.Value(0)).current;
18
+ const burstAnim = useRef(new Animated.Value(0)).current;
19
+ useEffect(() => {
20
+ const composite = Animated.parallel([
21
+ Animated.timing(flashAnim, {
22
+ toValue: 1,
23
+ duration: 400,
24
+ useNativeDriver: true,
25
+ }),
26
+ Animated.timing(burstAnim, {
27
+ toValue: 1,
28
+ duration: 1000,
29
+ useNativeDriver: true,
30
+ }),
31
+ ]);
32
+ composite.start();
33
+ return () => composite.stop();
34
+ }, [flashAnim, burstAnim]);
35
+ const flashOpacity = flashAnim.interpolate({
36
+ inputRange: [0, 0.25, 1],
37
+ outputRange: [0, 1, 0],
38
+ });
39
+ const flashScale = flashAnim.interpolate({
40
+ inputRange: [0, 0.25, 1],
41
+ outputRange: [0, 2.5, 5],
42
+ });
43
+ return (_jsxs(_Fragment, { children: [_jsx(Animated.View, { style: [
44
+ styles.flash,
45
+ {
46
+ left: burst.x - 6,
47
+ top: burst.y - 6,
48
+ opacity: flashOpacity,
49
+ transform: [{ scale: flashScale }],
50
+ },
51
+ ] }), burst.particles.map((p) => {
52
+ const opacity = burstAnim.interpolate({
53
+ inputRange: [0, 0.65, 1],
54
+ outputRange: [1, 1, 0],
55
+ });
56
+ const translateX = burstAnim.interpolate({
57
+ inputRange: [0, 1],
58
+ outputRange: [0, p.vx],
59
+ });
60
+ const translateY = burstAnim.interpolate({
61
+ inputRange: [0, 1],
62
+ outputRange: [0, p.vy],
63
+ });
64
+ const scale = burstAnim.interpolate({
65
+ inputRange: [0, 1],
66
+ outputRange: [1, 0],
67
+ });
68
+ return (_jsx(Animated.View, { style: [
69
+ styles.particle,
70
+ {
71
+ left: burst.x - p.size / 2,
72
+ top: burst.y - p.size / 2,
73
+ width: p.size,
74
+ height: p.size,
75
+ backgroundColor: p.color,
76
+ opacity,
77
+ transform: [{ translateX }, { translateY }, { scale }],
78
+ },
79
+ ] }, p.id));
80
+ })] }));
81
+ }
82
+ export function FireworksOverlay() {
83
+ const { width, height } = useWindowDimensions();
84
+ const bursts = useMemo(() => Array.from({ length: BURST_COUNT }, (_, b) => ({
85
+ id: b,
86
+ x: (0.15 + Math.random() * 0.7) * width,
87
+ y: (0.1 + Math.random() * 0.5) * height,
88
+ delay: b * 500 + Math.random() * 200,
89
+ particles: Array.from({ length: PARTICLE_COUNT }, (_, p) => {
90
+ const angle = (p / PARTICLE_COUNT) * Math.PI * 2 + (Math.random() - 0.5) * 0.2;
91
+ const dist = 55 + Math.random() * 60;
92
+ return {
93
+ id: p,
94
+ vx: Math.cos(angle) * dist,
95
+ vy: Math.sin(angle) * dist,
96
+ color: FIREWORK_COLORS[Math.floor(Math.random() * FIREWORK_COLORS.length)],
97
+ size: 3 + Math.random() * 3,
98
+ };
99
+ }),
100
+ })),
101
+ // stable on mount
102
+ // eslint-disable-next-line react-hooks/exhaustive-deps
103
+ []);
104
+ const [mountedBursts, setMountedBursts] = useState([]);
105
+ useEffect(() => {
106
+ const timers = bursts.map((burst, b) => setTimeout(() => {
107
+ setMountedBursts((prev) => [...prev, b]);
108
+ }, burst.delay));
109
+ return () => timers.forEach(clearTimeout);
110
+ }, [bursts]);
111
+ return (_jsx(View, { style: StyleSheet.absoluteFill, pointerEvents: "none", children: mountedBursts.map((b) => (_jsx(FireworkBurst, { burst: bursts[b] }, b))) }));
112
+ }
113
+ const styles = StyleSheet.create({
114
+ flash: {
115
+ position: "absolute",
116
+ width: 12,
117
+ height: 12,
118
+ borderRadius: 6,
119
+ backgroundColor: "#fff",
120
+ },
121
+ particle: {
122
+ position: "absolute",
123
+ borderRadius: 999,
124
+ },
125
+ });
@@ -2,6 +2,7 @@ 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
4
  import { ConfettiOverlay } from "./confetti-overlay.js";
5
+ import { FireworksOverlay } from "./fireworks-overlay.js";
5
6
  import { useSnapTheme } from "./theme.js";
6
7
  import { useCallback, useEffect, useMemo, useRef, useState, } from "react";
7
8
  import { ActivityIndicator, StyleSheet, View } from "react-native";
@@ -81,11 +82,15 @@ export function SnapViewCoreInner({ snap, handlers, loading = false, loadingOver
81
82
  setPageKey((k) => k + 1);
82
83
  }, [spec]);
83
84
  const showConfetti = snap.effects?.includes("confetti") ?? false;
85
+ const showFireworks = snap.effects?.includes("fireworks") ?? false;
84
86
  const [confettiKey, setConfettiKey] = useState(0);
87
+ const [fireworksKey, setFireworksKey] = useState(0);
85
88
  useEffect(() => {
86
89
  if (showConfetti)
87
90
  setConfettiKey((k) => k + 1);
88
- }, [showConfetti, snap]);
91
+ if (showFireworks)
92
+ setFireworksKey((k) => k + 1);
93
+ }, [showConfetti, showFireworks, snap]);
89
94
  const handlersRef = useRef(handlers);
90
95
  handlersRef.current = handlers;
91
96
  const handleAction = useCallback((name, params) => {
@@ -147,7 +152,7 @@ export function SnapViewCoreInner({ snap, handlers, loading = false, loadingOver
147
152
  : loadingOverlay
148
153
  : null, _jsx(SnapCatalogView, { spec: spec, state: initialState, loading: false, onStateChange: (changes) => {
149
154
  applyStatePaths(stateRef.current, changes);
150
- }, onAction: handleAction }, pageKey), showConfetti && _jsx(ConfettiOverlay, {}, confettiKey)] }));
155
+ }, onAction: handleAction }, pageKey), showConfetti && _jsx(ConfettiOverlay, {}, confettiKey), showFireworks && _jsx(FireworksOverlay, {}, fireworksKey)] }));
151
156
  }
152
157
  export function SnapLoadingOverlay({ appearance, accentHex, }) {
153
158
  return (_jsx(View, { style: [
package/dist/schemas.d.ts CHANGED
@@ -30,6 +30,7 @@ export declare const snapResponseSchema: z.ZodObject<{
30
30
  }, z.core.$strict>>>;
31
31
  effects: z.ZodOptional<z.ZodArray<z.ZodEnum<{
32
32
  confetti: "confetti";
33
+ fireworks: "fireworks";
33
34
  }>>>;
34
35
  ui: z.ZodCustom<Spec, Spec>;
35
36
  }, z.core.$strict>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@farcaster/snap",
3
- "version": "2.3.0",
3
+ "version": "2.4.0",
4
4
  "description": "Farcaster Snaps 🫰",
5
5
  "repository": {
6
6
  "type": "git",
package/src/colors.ts CHANGED
@@ -65,6 +65,28 @@ export function resolveSnapColorHex(
65
65
  return opts.accentHex;
66
66
  }
67
67
 
68
+ /**
69
+ * Pick a readable text color for a given hex background.
70
+ *
71
+ * Uses WCAG relative luminance with a 0.5 threshold. Returns `rgba(...)` so
72
+ * callers can soften the text against the background — defaults to 0.8 alpha
73
+ * to let a hint of the cell color bleed through.
74
+ */
75
+ export function readableTextOnHex(hex: string, alpha = 0.8): string {
76
+ const m = /^#([0-9a-fA-F]{6})$/.exec(hex.trim());
77
+ if (!m) return `rgba(0,0,0,${alpha})`;
78
+ const n = Number.parseInt(m[1], 16);
79
+ const toLin = (c: number) => {
80
+ const s = c / 255;
81
+ return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
82
+ };
83
+ const L =
84
+ 0.2126 * toLin((n >> 16) & 0xff) +
85
+ 0.7152 * toLin((n >> 8) & 0xff) +
86
+ 0.0722 * toLin(n & 0xff);
87
+ return L >= 0.5 ? `rgba(0,0,0,${alpha})` : `rgba(255,255,255,${alpha})`;
88
+ }
89
+
68
90
  /** Light-mode hex for each palette color (emulator / reference client). */
69
91
  export const PALETTE_LIGHT_HEX: Record<PaletteColor, string> = {
70
92
  gray: "#6E6A86",
package/src/constants.ts CHANGED
@@ -11,7 +11,7 @@ export const SNAP_PAYLOAD_HEADER = "X-Snap-Payload" as const;
11
11
 
12
12
  export const MEDIA_TYPE = "application/vnd.farcaster.snap+json" as const;
13
13
 
14
- export const EFFECT_VALUES = ["confetti"] as const;
14
+ export const EFFECT_VALUES = ["confetti", "fireworks"] as const;
15
15
 
16
16
  // ─── Pixel grid ────────────────────────────────────────
17
17
  export const POST_GRID_TAP_KEY = "grid_tap" as const;
package/src/index.ts CHANGED
@@ -25,6 +25,7 @@ export {
25
25
  PALETTE_LIGHT_HEX,
26
26
  PALETTE_DARK_HEX,
27
27
  isSnapHexColorString,
28
+ readableTextOnHex,
28
29
  resolveSnapColorHex,
29
30
  type PaletteColor,
30
31
  } from "./colors";
@@ -5,6 +5,7 @@ import { ExternalLink } from "lucide-react";
5
5
  import { Button } from "@neynar/ui/button";
6
6
  import { cn } from "@neynar/ui/utils";
7
7
  import { useSnapColors } from "../hooks/use-snap-colors";
8
+ import { useSnapStackDirection } from "../stack-direction-context";
8
9
  import { ICON_MAP } from "./icon";
9
10
 
10
11
  function isExternalLinkAction(
@@ -38,6 +39,7 @@ export function SnapActionButton({
38
39
 
39
40
  const Icon = iconName ? ICON_MAP[iconName] : undefined;
40
41
  const showExternalIcon = isExternalLinkAction(element.on);
42
+ const inHorizontalStack = useSnapStackDirection() === "horizontal";
41
43
 
42
44
  const style = {
43
45
  cursor: "pointer" as const,
@@ -57,7 +59,18 @@ export function SnapActionButton({
57
59
  };
58
60
 
59
61
  return (
60
- <div className="w-full min-w-0 flex-1">
62
+ /**
63
+ * In a horizontal stack, `flex-1` lets the wrapper share row width with peers.
64
+ * In a vertical stack, `flex-1` would silently grow the button to fill column
65
+ * height (1/N distribution when siblings also flex-grow); stick to `w-full`.
66
+ */
67
+ <div
68
+ className={
69
+ inHorizontalStack
70
+ ? "w-full min-w-0 flex-1"
71
+ : "w-full min-w-0"
72
+ }
73
+ >
61
74
  <Button
62
75
  type="button"
63
76
  variant={isPrimary ? "default" : "secondary"}
@@ -3,7 +3,7 @@
3
3
  import type { ReactNode } from "react";
4
4
  import { useStateStore } from "@json-render/react";
5
5
  import { cn } from "@neynar/ui/utils";
6
- import { POST_GRID_TAP_KEY } from "@farcaster/snap";
6
+ import { POST_GRID_TAP_KEY, readableTextOnHex } from "@farcaster/snap";
7
7
  import { useSnapColors } from "../hooks/use-snap-colors";
8
8
 
9
9
  export function SnapCellGrid({
@@ -85,7 +85,9 @@ export function SnapCellGrid({
85
85
  for (let c = 0; c < cols; c++) {
86
86
  const cell = cellMap.get(`${r},${c}`);
87
87
  const selected = interactive && isSelected(r, c);
88
- const bg = cell?.color ? colors.colorHex(cell.color) : emptyCellBg;
88
+ const bgHex = cell?.color ? colors.colorHex(cell.color) : null;
89
+ const bg = bgHex ?? emptyCellBg;
90
+ const textColor = bgHex ? readableTextOnHex(bgHex) : colors.text;
89
91
 
90
92
  cellEls.push(
91
93
  <div
@@ -110,6 +112,7 @@ export function SnapCellGrid({
110
112
  style={{
111
113
  height: rowHeight,
112
114
  background: bg,
115
+ color: textColor,
113
116
  boxShadow: selected
114
117
  ? `inset 0 0 0 1px ${colors.mode === "dark" ? "#000" : "#fff"}, inset 0 0 0 2px ${colors.mode === "dark" ? "#fff" : "#000"}`
115
118
  : undefined,
@@ -7,7 +7,9 @@ import {
7
7
  ItemDescription,
8
8
  ItemActions,
9
9
  } from "@neynar/ui/item";
10
+ import { cn } from "@neynar/ui/utils";
10
11
  import { useSnapColors } from "../hooks/use-snap-colors";
12
+ import { useSnapStackDirection } from "../stack-direction-context";
11
13
 
12
14
  export function SnapItem({
13
15
  element: { props, children: childIds },
@@ -19,9 +21,16 @@ export function SnapItem({
19
21
  const title = String(props.title ?? "");
20
22
  const description = props.description ? String(props.description) : undefined;
21
23
  const colors = useSnapColors();
24
+ const inHorizontalStack = useSnapStackDirection() === "horizontal";
22
25
 
23
26
  return (
24
- <Item className="flex-1 py-1.5 px-2.5">
27
+ <Item
28
+ className={cn(
29
+ "py-1.5 px-2.5",
30
+ /** Horizontal: share width with peers. Vertical: don't fill column height. */
31
+ inHorizontalStack && "flex-1",
32
+ )}
33
+ >
25
34
  <ItemContent className="gap-0.5">
26
35
  <ItemTitle style={{ color: colors.text }}>{title}</ItemTitle>
27
36
  {description && (
@@ -1,6 +1,8 @@
1
1
  "use client";
2
2
 
3
+ import { cn } from "@neynar/ui/utils";
3
4
  import { useSnapColors } from "../hooks/use-snap-colors";
5
+ import { useSnapStackDirection } from "../stack-direction-context";
4
6
 
5
7
  export function SnapProgress({
6
8
  element: { props },
@@ -12,9 +14,16 @@ export function SnapProgress({
12
14
  const max = Math.max(1, Number(props.max ?? 100));
13
15
  const percent = Math.min(100, Math.max(0, (value / max) * 100));
14
16
  const label = props.label ? String(props.label) : null;
17
+ const inHorizontalStack = useSnapStackDirection() === "horizontal";
15
18
 
16
19
  return (
17
- <div className="flex w-full flex-1 flex-col gap-1">
20
+ <div
21
+ className={cn(
22
+ "flex w-full flex-col gap-1",
23
+ /** Horizontal: share width with peers. Vertical: don't fill column height. */
24
+ inHorizontalStack && "flex-1",
25
+ )}
26
+ >
18
27
  {label && (
19
28
  <span className="text-xs" style={{ color: colors.textMuted }}>
20
29
  {label}
@@ -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}
@@ -3,7 +3,7 @@ import { StyleSheet, Text, View, Pressable } from "react-native";
3
3
  import { useStateStore } from "@json-render/react-native";
4
4
  import { useSnapPalette } from "../use-snap-palette";
5
5
  import { useSnapTheme } from "../theme";
6
- import { POST_GRID_TAP_KEY } from "@farcaster/snap";
6
+ import { POST_GRID_TAP_KEY, readableTextOnHex } from "@farcaster/snap";
7
7
 
8
8
  export function SnapCellGrid({
9
9
  element,
@@ -89,10 +89,12 @@ export function SnapCellGrid({
89
89
  for (let c = 0; c < cols; c++) {
90
90
  const cell = cellMap.get(`${r},${c}`);
91
91
  const selected = interactive && isSelected(r, c);
92
- const bg = cell?.color ? hex(cell.color) : emptyCellBg;
92
+ const bgHex = cell?.color ? hex(cell.color) : null;
93
+ const bg = bgHex ?? emptyCellBg;
94
+ const textColor = bgHex ? readableTextOnHex(bgHex) : colors.text;
93
95
 
94
96
  const cellContent = cell?.content ? (
95
- <Text style={[styles.cellText, { color: colors.textPrimary }]}>
97
+ <Text style={[styles.cellText, { color: textColor }]}>
96
98
  {cell.content}
97
99
  </Text>
98
100
  ) : null;
@@ -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
  }