@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 +8 -0
- package/dist/colors.js +21 -0
- package/dist/constants.d.ts +1 -1
- package/dist/constants.js +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/react/components/action-button.js +11 -1
- package/dist/react/components/cell-grid.js +5 -2
- package/dist/react/components/item.js +6 -1
- package/dist/react/components/progress.js +6 -1
- package/dist/react/snap-view-core.js +107 -17
- package/dist/react-native/components/snap-cell-grid.js +5 -3
- package/dist/react-native/fireworks-overlay.d.ts +1 -0
- package/dist/react-native/fireworks-overlay.js +125 -0
- package/dist/react-native/snap-view-core.js +7 -2
- package/dist/schemas.d.ts +1 -0
- package/package.json +1 -1
- package/src/colors.ts +22 -0
- package/src/constants.ts +1 -1
- package/src/index.ts +1 -0
- package/src/react/components/action-button.tsx +14 -1
- package/src/react/components/cell-grid.tsx +5 -2
- package/src/react/components/item.tsx +10 -1
- package/src/react/components/progress.tsx +10 -1
- package/src/react/snap-view-core.tsx +152 -28
- package/src/react-native/components/snap-cell-grid.tsx +5 -3
- package/src/react-native/fireworks-overlay.tsx +176 -0
- package/src/react-native/snap-view-core.tsx +6 -1
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",
|
package/dist/constants.d.ts
CHANGED
|
@@ -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 (
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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.
|
|
87
|
+
height: isCircle ? size : size * 0.5,
|
|
72
88
|
backgroundColor: color,
|
|
73
|
-
borderRadius: 2,
|
|
74
|
-
transform: `
|
|
75
|
-
animation: `confettiFall ${duration}s
|
|
76
|
-
|
|
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
|
-
|
|
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
|
|
79
|
-
const
|
|
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
|
-
|
|
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
package/package.json
CHANGED
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
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
{
|
|
93
|
-
<div
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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>{
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
}
|