@farcaster/snap 2.0.0 → 2.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (194) hide show
  1. package/dist/colors.d.ts +4 -4
  2. package/dist/colors.js +20 -20
  3. package/dist/constants.d.ts +17 -1
  4. package/dist/constants.js +19 -1
  5. package/dist/index.d.ts +4 -6
  6. package/dist/index.js +2 -4
  7. package/dist/react/accent-context.d.ts +3 -1
  8. package/dist/react/accent-context.js +7 -4
  9. package/dist/react/catalog-renderer.js +4 -0
  10. package/dist/react/components/action-button.d.ts +2 -1
  11. package/dist/react/components/action-button.js +35 -13
  12. package/dist/react/components/badge.js +8 -8
  13. package/dist/react/components/bar-chart.d.ts +5 -0
  14. package/dist/react/components/bar-chart.js +26 -0
  15. package/dist/react/components/cell-grid.d.ts +5 -0
  16. package/dist/react/components/cell-grid.js +87 -0
  17. package/dist/react/components/icon.js +4 -10
  18. package/dist/react/components/input.js +12 -6
  19. package/dist/react/components/item-group.js +3 -1
  20. package/dist/react/components/item.d.ts +3 -3
  21. package/dist/react/components/item.js +4 -3
  22. package/dist/react/components/progress.js +3 -3
  23. package/dist/react/components/separator.js +3 -1
  24. package/dist/react/components/slider.js +15 -10
  25. package/dist/react/components/switch.js +10 -12
  26. package/dist/react/components/text.js +6 -14
  27. package/dist/react/components/toggle-group.js +20 -6
  28. package/dist/react/hooks/use-snap-colors.d.ts +38 -0
  29. package/dist/react/hooks/use-snap-colors.js +81 -0
  30. package/dist/react/index.d.ts +13 -1
  31. package/dist/react/index.js +9 -188
  32. package/dist/react/snap-view-core.d.ts +11 -0
  33. package/dist/react/snap-view-core.js +227 -0
  34. package/dist/react/v1/snap-view.d.ts +16 -0
  35. package/dist/react/v1/snap-view.js +90 -0
  36. package/dist/react/v2/snap-view.d.ts +23 -0
  37. package/dist/react/v2/snap-view.js +91 -0
  38. package/dist/react-native/catalog-renderer.d.ts +5 -0
  39. package/dist/react-native/catalog-renderer.js +40 -0
  40. package/dist/react-native/components/snap-action-button.d.ts +2 -0
  41. package/dist/react-native/components/snap-action-button.js +69 -0
  42. package/dist/react-native/components/snap-badge.d.ts +2 -0
  43. package/dist/react-native/components/snap-badge.js +41 -0
  44. package/dist/react-native/components/snap-bar-chart.d.ts +2 -0
  45. package/dist/react-native/components/snap-bar-chart.js +39 -0
  46. package/dist/react-native/components/snap-cell-grid.d.ts +2 -0
  47. package/dist/react-native/components/snap-cell-grid.js +94 -0
  48. package/dist/react-native/components/snap-icon.d.ts +5 -0
  49. package/dist/react-native/components/snap-icon.js +56 -0
  50. package/dist/react-native/components/snap-image.d.ts +2 -0
  51. package/dist/react-native/components/snap-image.js +23 -0
  52. package/dist/react-native/components/snap-input.d.ts +2 -0
  53. package/dist/react-native/components/snap-input.js +37 -0
  54. package/dist/react-native/components/snap-item-group.d.ts +5 -0
  55. package/dist/react-native/components/snap-item-group.js +23 -0
  56. package/dist/react-native/components/snap-item.d.ts +5 -0
  57. package/dist/react-native/components/snap-item.js +42 -0
  58. package/dist/react-native/components/snap-progress.d.ts +2 -0
  59. package/dist/react-native/components/snap-progress.js +26 -0
  60. package/dist/react-native/components/snap-separator.d.ts +2 -0
  61. package/dist/react-native/components/snap-separator.js +23 -0
  62. package/dist/react-native/components/snap-slider.d.ts +2 -0
  63. package/dist/react-native/components/snap-slider.js +43 -0
  64. package/dist/react-native/components/snap-stack.d.ts +5 -0
  65. package/dist/react-native/components/snap-stack.js +49 -0
  66. package/dist/react-native/components/snap-switch.d.ts +2 -0
  67. package/dist/react-native/components/snap-switch.js +31 -0
  68. package/dist/react-native/components/snap-text.d.ts +2 -0
  69. package/dist/react-native/components/snap-text.js +35 -0
  70. package/dist/react-native/components/snap-toggle-group.d.ts +2 -0
  71. package/dist/react-native/components/snap-toggle-group.js +99 -0
  72. package/dist/react-native/confetti-overlay.d.ts +1 -0
  73. package/dist/react-native/confetti-overlay.js +106 -0
  74. package/dist/react-native/index.d.ts +28 -0
  75. package/dist/react-native/index.js +15 -0
  76. package/dist/react-native/snap-view-core.d.ts +11 -0
  77. package/dist/react-native/snap-view-core.js +156 -0
  78. package/dist/react-native/theme.d.ts +27 -0
  79. package/dist/react-native/theme.js +43 -0
  80. package/dist/react-native/types.d.ts +42 -0
  81. package/dist/react-native/types.js +1 -0
  82. package/dist/react-native/use-snap-palette.d.ts +13 -0
  83. package/dist/react-native/use-snap-palette.js +48 -0
  84. package/dist/react-native/v1/snap-view.d.ts +24 -0
  85. package/dist/react-native/v1/snap-view.js +96 -0
  86. package/dist/react-native/v2/snap-view.d.ts +33 -0
  87. package/dist/react-native/v2/snap-view.js +114 -0
  88. package/dist/schemas.d.ts +100 -13
  89. package/dist/schemas.js +28 -10
  90. package/dist/server/parseRequest.d.ts +10 -0
  91. package/dist/server/parseRequest.js +48 -7
  92. package/dist/server/verify.d.ts +1 -0
  93. package/dist/server/verify.js +1 -0
  94. package/dist/ui/badge.d.ts +7 -2
  95. package/dist/ui/badge.js +2 -0
  96. package/dist/ui/bar-chart.d.ts +30 -0
  97. package/dist/ui/bar-chart.js +30 -0
  98. package/dist/ui/button.d.ts +4 -6
  99. package/dist/ui/button.js +1 -1
  100. package/dist/ui/catalog.d.ts +90 -16
  101. package/dist/ui/catalog.js +17 -3
  102. package/dist/ui/cell-grid.d.ts +34 -0
  103. package/dist/ui/cell-grid.js +39 -0
  104. package/dist/ui/icon.d.ts +2 -2
  105. package/dist/ui/image.d.ts +1 -2
  106. package/dist/ui/image.js +1 -1
  107. package/dist/ui/index.d.ts +4 -0
  108. package/dist/ui/index.js +2 -0
  109. package/dist/ui/item.d.ts +1 -3
  110. package/dist/ui/item.js +1 -1
  111. package/dist/ui/schema.d.ts +6 -2
  112. package/dist/ui/schema.js +2 -2
  113. package/dist/ui/slider.d.ts +1 -0
  114. package/dist/ui/slider.js +2 -0
  115. package/dist/ui/text.d.ts +2 -4
  116. package/dist/ui/text.js +2 -2
  117. package/dist/validator.d.ts +3 -2
  118. package/dist/validator.js +203 -2
  119. package/llms.txt +199 -0
  120. package/package.json +9 -3
  121. package/src/colors.ts +20 -20
  122. package/src/constants.ts +23 -1
  123. package/src/index.ts +16 -13
  124. package/src/react/accent-context.tsx +13 -6
  125. package/src/react/catalog-renderer.tsx +4 -0
  126. package/src/react/components/action-button.tsx +50 -20
  127. package/src/react/components/badge.tsx +14 -18
  128. package/src/react/components/bar-chart.tsx +69 -0
  129. package/src/react/components/cell-grid.tsx +128 -0
  130. package/src/react/components/icon.tsx +5 -18
  131. package/src/react/components/input.tsx +20 -9
  132. package/src/react/components/item-group.tsx +4 -1
  133. package/src/react/components/item.tsx +13 -10
  134. package/src/react/components/progress.tsx +12 -7
  135. package/src/react/components/separator.tsx +8 -1
  136. package/src/react/components/slider.tsx +28 -15
  137. package/src/react/components/switch.tsx +12 -16
  138. package/src/react/components/text.tsx +14 -23
  139. package/src/react/components/toggle-group.tsx +26 -9
  140. package/src/react/hooks/use-snap-colors.ts +128 -0
  141. package/src/react/index.tsx +49 -265
  142. package/src/react/snap-view-core.tsx +343 -0
  143. package/src/react/v1/snap-view.tsx +176 -0
  144. package/src/react/v2/snap-view.tsx +199 -0
  145. package/src/react-native/catalog-renderer.tsx +41 -0
  146. package/src/react-native/components/snap-action-button.tsx +96 -0
  147. package/src/react-native/components/snap-badge.tsx +60 -0
  148. package/src/react-native/components/snap-bar-chart.tsx +73 -0
  149. package/src/react-native/components/snap-cell-grid.tsx +150 -0
  150. package/src/react-native/components/snap-icon.tsx +102 -0
  151. package/src/react-native/components/snap-image.tsx +37 -0
  152. package/src/react-native/components/snap-input.tsx +58 -0
  153. package/src/react-native/components/snap-item-group.tsx +43 -0
  154. package/src/react-native/components/snap-item.tsx +66 -0
  155. package/src/react-native/components/snap-progress.tsx +40 -0
  156. package/src/react-native/components/snap-separator.tsx +32 -0
  157. package/src/react-native/components/snap-slider.tsx +85 -0
  158. package/src/react-native/components/snap-stack.tsx +66 -0
  159. package/src/react-native/components/snap-switch.tsx +46 -0
  160. package/src/react-native/components/snap-text.tsx +51 -0
  161. package/src/react-native/components/snap-toggle-group.tsx +127 -0
  162. package/src/react-native/confetti-overlay.tsx +134 -0
  163. package/src/react-native/index.tsx +83 -0
  164. package/src/react-native/snap-view-core.tsx +212 -0
  165. package/src/react-native/theme.tsx +85 -0
  166. package/src/react-native/types.ts +38 -0
  167. package/src/react-native/use-snap-palette.ts +64 -0
  168. package/src/react-native/v1/snap-view.tsx +229 -0
  169. package/src/react-native/v2/snap-view.tsx +283 -0
  170. package/src/schemas.ts +68 -17
  171. package/src/server/parseRequest.ts +68 -9
  172. package/src/server/verify.ts +2 -0
  173. package/src/ui/README.md +8 -8
  174. package/src/ui/badge.ts +2 -0
  175. package/src/ui/bar-chart.ts +38 -0
  176. package/src/ui/button.ts +1 -1
  177. package/src/ui/catalog.ts +19 -3
  178. package/src/ui/cell-grid.ts +49 -0
  179. package/src/ui/image.ts +1 -1
  180. package/src/ui/index.ts +6 -0
  181. package/src/ui/item.ts +1 -1
  182. package/src/ui/schema.ts +2 -2
  183. package/src/ui/slider.ts +2 -0
  184. package/src/ui/text.ts +2 -2
  185. package/src/validator.ts +251 -2
  186. package/dist/dataStore.d.ts +0 -12
  187. package/dist/dataStore.js +0 -35
  188. package/dist/middleware.d.ts +0 -3
  189. package/dist/middleware.js +0 -3
  190. package/dist/react/hooks/use-snap-accent.d.ts +0 -13
  191. package/dist/react/hooks/use-snap-accent.js +0 -32
  192. package/src/dataStore.ts +0 -62
  193. package/src/middleware.ts +0 -7
  194. package/src/react/hooks/use-snap-accent.ts +0 -45
@@ -2,32 +2,29 @@
2
2
 
3
3
  import { useId } from "react";
4
4
  import { useStateStore } from "@json-render/react";
5
- import { Label } from "@neynar/ui/label";
6
5
  import { Switch } from "@neynar/ui/switch";
7
- import { useColorMode } from "@neynar/ui/color-mode";
8
- import { cn } from "@neynar/ui/utils";
9
- import { useSnapAccentScopeStyle } from "../hooks/use-snap-accent";
6
+ import { Label } from "@neynar/ui/label";
7
+ import { useSnapColors } from "../hooks/use-snap-colors";
10
8
 
11
9
  export function SnapSwitch({
12
10
  element: { props },
13
11
  }: {
14
12
  element: { props: Record<string, unknown> };
15
13
  }) {
16
- const id = useId();
17
14
  const { get, set } = useStateStore();
18
- const { mode } = useColorMode();
19
- const accentStyle = useSnapAccentScopeStyle();
15
+ const colors = useSnapColors();
20
16
  const name = String(props.name ?? "switch");
21
- const path = `/inputs/${name}`;
22
17
  const label = props.label ? String(props.label) : undefined;
23
- const fallback = Boolean(props.defaultChecked ?? false);
18
+ const path = `/inputs/${name}`;
24
19
  const raw = get(path);
25
- const checked = raw === undefined || raw === null ? fallback : Boolean(raw);
20
+ const checked =
21
+ raw !== undefined ? Boolean(raw) : Boolean(props.defaultChecked);
22
+ const id = useId();
26
23
 
27
24
  return (
28
25
  <div className="flex items-center justify-between gap-3">
29
26
  {label && (
30
- <Label htmlFor={id} className="text-foreground font-normal">
27
+ <Label htmlFor={id} className="font-normal" style={{ color: colors.text }}>
31
28
  {label}
32
29
  </Label>
33
30
  )}
@@ -35,11 +32,10 @@ export function SnapSwitch({
35
32
  id={id}
36
33
  checked={checked}
37
34
  onCheckedChange={(v) => set(path, v)}
38
- style={accentStyle}
39
- className={cn(
40
- mode === "light" &&
41
- "data-unchecked:!bg-border data-unchecked:!border-(--input-border)",
42
- )}
35
+ style={{
36
+ backgroundColor: checked ? colors.accent : colors.muted,
37
+ borderColor: checked ? colors.accent : colors.inputBorder,
38
+ }}
43
39
  />
44
40
  </div>
45
41
  );
@@ -1,17 +1,11 @@
1
1
  "use client";
2
2
 
3
- import { Text, Title } from "@neynar/ui/typography";
3
+ import { Text } from "@neynar/ui/typography";
4
+ import { useSnapColors } from "../hooks/use-snap-colors";
4
5
 
5
6
  const SIZE_MAP = {
6
- lg: { component: "title", textSize: undefined, order: 3 },
7
- md: { component: "text", textSize: "base" as const, order: undefined },
8
- sm: { component: "text", textSize: "sm" as const, order: undefined },
9
- } as const;
10
-
11
- const WEIGHT_MAP = {
12
- bold: "bold",
13
- medium: "medium",
14
- normal: "normal",
7
+ md: { textSize: "base" as const },
8
+ sm: { textSize: "sm" as const },
15
9
  } as const;
16
10
 
17
11
  export function SnapText({
@@ -20,23 +14,20 @@ export function SnapText({
20
14
  element: { props: Record<string, unknown> };
21
15
  }) {
22
16
  const content = String(props.content ?? "");
23
- const size = String(props.size ?? "md") as "lg" | "md" | "sm";
24
- const weight = props.weight ? String(props.weight) as "bold" | "medium" | "normal" : undefined;
17
+ const size = String(props.size ?? "md") as "md" | "sm";
18
+ const weight = props.weight ? String(props.weight) as "bold" | "normal" : undefined;
25
19
  const align = (props.align as "left" | "center" | "right") ?? undefined;
26
20
  const config = SIZE_MAP[size] ?? SIZE_MAP.md;
27
-
28
- const alignClass = align === "center" ? "text-center" : align === "right" ? "text-right" : "";
29
-
30
- if (config.component === "title") {
31
- return (
32
- <Title order={config.order} weight={weight ?? "bold"} className={`flex-1 ${alignClass}`}>
33
- {content}
34
- </Title>
35
- );
36
- }
21
+ const colors = useSnapColors();
37
22
 
38
23
  return (
39
- <Text size={config.textSize} weight={weight} align={align} className="flex-1">
24
+ <Text
25
+ size={config.textSize}
26
+ weight={weight}
27
+ align={align}
28
+ className="flex-1"
29
+ style={{ color: colors.text }}
30
+ >
40
31
  {content}
41
32
  </Text>
42
33
  );
@@ -1,9 +1,10 @@
1
1
  "use client";
2
2
 
3
+ import { useState } from "react";
3
4
  import { useStateStore } from "@json-render/react";
4
5
  import { Label } from "@neynar/ui/label";
5
6
  import { cn } from "@neynar/ui/utils";
6
- import { useSnapAccentScopeStyle } from "../hooks/use-snap-accent";
7
+ import { useSnapColors } from "../hooks/use-snap-colors";
7
8
 
8
9
  export function SnapToggleGroup({
9
10
  element: { props },
@@ -11,7 +12,7 @@ export function SnapToggleGroup({
11
12
  element: { props: Record<string, unknown> };
12
13
  }) {
13
14
  const { get, set } = useStateStore();
14
- const accentStyle = useSnapAccentScopeStyle();
15
+ const colors = useSnapColors();
15
16
  const name = String(props.name ?? "toggle_group");
16
17
  const path = `/inputs/${name}`;
17
18
  const label = props.label ? String(props.label) : undefined;
@@ -50,30 +51,46 @@ export function SnapToggleGroup({
50
51
  };
51
52
 
52
53
  const isVertical = orientation === "vertical";
54
+ const [hoveredIdx, setHoveredIdx] = useState<number | null>(null);
53
55
 
54
56
  return (
55
- <div className="w-full space-y-1.5" style={accentStyle}>
56
- {label && <Label>{label}</Label>}
57
+ <div className="w-full space-y-1.5">
58
+ {label && <Label style={{ color: colors.text }}>{label}</Label>}
57
59
  <div
58
60
  className={cn(
59
- "flex gap-1 rounded-lg bg-border/20 p-1",
61
+ "flex gap-1 rounded-lg p-1",
60
62
  isVertical ? "flex-col" : "flex-row",
61
63
  )}
64
+ style={{ backgroundColor: colors.muted }}
62
65
  >
63
- {options.map((opt) => {
66
+ {options.map((opt, i) => {
64
67
  const isSelected = selected.includes(opt);
68
+ const isHovered = hoveredIdx === i && !isSelected;
65
69
  return (
66
70
  <button
67
71
  key={opt}
68
72
  type="button"
69
73
  onClick={() => toggle(opt)}
74
+ onPointerEnter={() => setHoveredIdx(i)}
75
+ onPointerLeave={() => setHoveredIdx(null)}
70
76
  className={cn(
71
77
  "rounded-md px-3 py-2 text-sm font-medium transition-colors",
72
78
  isVertical ? "w-full" : "flex-1",
73
- isSelected
74
- ? "bg-primary text-primary-foreground"
75
- : "text-foreground hover:bg-border/30",
76
79
  )}
80
+ style={{
81
+ transition: "background-color 0.15s, color 0.15s",
82
+ ...(isSelected
83
+ ? {
84
+ backgroundColor: colors.mode === "dark" ? "rgba(255,255,255,0.10)" : "rgba(0,0,0,0.08)",
85
+ color: colors.text,
86
+ }
87
+ : {
88
+ color: colors.text,
89
+ backgroundColor: isHovered
90
+ ? (colors.mode === "dark" ? "rgba(255,255,255,0.04)" : "rgba(0,0,0,0.04)")
91
+ : (colors.mode === "dark" ? "rgba(255,255,255,0.02)" : "rgba(0,0,0,0.02)"),
92
+ }),
93
+ }}
77
94
  >
78
95
  {opt}
79
96
  </button>
@@ -0,0 +1,128 @@
1
+ "use client";
2
+
3
+ import { useMemo } from "react";
4
+ import { useStateStore } from "@json-render/react";
5
+ import { resolveSnapPaletteHex } from "../lib/resolve-palette-hex";
6
+ import { useSnapPreviewPageAccent, useSnapAppearance } from "../accent-context";
7
+ import type { PaletteColor } from "@farcaster/snap";
8
+ import { PALETTE_DARK_HEX, PALETTE_LIGHT_HEX } from "@farcaster/snap";
9
+
10
+ /** Readable foreground color (black or white) for a given hex background. */
11
+ export function pickForegroundForBg(hex: string): string {
12
+ const h = hex.replace(/^#/, "");
13
+ if (h.length !== 6) return "#ffffff";
14
+ const r = Number.parseInt(h.slice(0, 2), 16);
15
+ const g = Number.parseInt(h.slice(2, 4), 16);
16
+ const b = Number.parseInt(h.slice(4, 6), 16);
17
+ const yiq = (r * 299 + g * 587 + b * 114) / 1000;
18
+ return yiq >= 180 ? "#0a0a0a" : "#ffffff";
19
+ }
20
+
21
+ const NEUTRAL_LIGHT = {
22
+ text: "#111111",
23
+ textMuted: "#6B7280",
24
+ border: "#E5E7EB",
25
+ muted: "rgba(0,0,0,0.06)",
26
+ surface: "#ffffff",
27
+ inputBorder: "#E5E7EB",
28
+ inputBg: "rgba(0,0,0,0.06)",
29
+ } as const;
30
+
31
+ const NEUTRAL_DARK = {
32
+ text: "#FAFAFA",
33
+ textMuted: "#A1A1AA",
34
+ border: "#2D2D44",
35
+ muted: "rgba(255,255,255,0.03)",
36
+ surface: "#23262f",
37
+ inputBorder: "#3F3F46",
38
+ inputBg: "rgba(255,255,255,0.03)",
39
+ } as const;
40
+
41
+ export type SnapColors = {
42
+ /** Resolved accent hex */
43
+ accent: string;
44
+ /** Readable foreground for accent bg (black or white) */
45
+ accentFg: string;
46
+ /** Primary button hover color */
47
+ accentHover: string;
48
+ /** Secondary/outline button hover color */
49
+ outlineHover: string;
50
+ /** Primary text color */
51
+ text: string;
52
+ /** Muted/secondary text color */
53
+ textMuted: string;
54
+ /** Border color */
55
+ border: string;
56
+ /** Muted background (tracks, containers) */
57
+ muted: string;
58
+ /** Surface/card background */
59
+ surface: string;
60
+ /** Input border */
61
+ inputBorder: string;
62
+ /** Input background */
63
+ inputBg: string;
64
+ /** Current color mode */
65
+ mode: "light" | "dark";
66
+ /** Resolve a palette color name to hex */
67
+ paletteHex: (name: string) => string;
68
+ /** Resolve a palette color name to hex, with accent fallback */
69
+ colorHex: (name: string | undefined) => string;
70
+ };
71
+
72
+ function buildSnapColors(
73
+ accentName: string,
74
+ mode: "light" | "dark",
75
+ ): SnapColors {
76
+ const accent = resolveSnapPaletteHex(accentName, mode);
77
+ const accentFg = pickForegroundForBg(accent);
78
+ const neutrals = mode === "dark" ? NEUTRAL_DARK : NEUTRAL_LIGHT;
79
+ const paletteMap = mode === "dark" ? PALETTE_DARK_HEX : PALETTE_LIGHT_HEX;
80
+
81
+ const accentHover =
82
+ mode === "light"
83
+ ? `color-mix(in srgb, ${accent} 82%, #000000)`
84
+ : `color-mix(in srgb, ${accent} 78%, #ffffff)`;
85
+
86
+ const outlineHover = `color-mix(in srgb, ${accent} 14%, ${neutrals.surface})`;
87
+
88
+ const paletteHex = (name: string) => resolveSnapPaletteHex(name, mode);
89
+
90
+ const colorHex = (name: string | undefined) => {
91
+ if (!name || name === "accent") return accent;
92
+ if (Object.hasOwn(paletteMap, name)) return paletteMap[name as PaletteColor];
93
+ return accent;
94
+ };
95
+
96
+ return {
97
+ accent,
98
+ accentFg,
99
+ accentHover,
100
+ outlineHover,
101
+ ...neutrals,
102
+ mode,
103
+ paletteHex,
104
+ colorHex,
105
+ };
106
+ }
107
+
108
+ /**
109
+ * Returns fully resolved color values for snap components.
110
+ * All colors are concrete hex values (or color-mix expressions for hover states)
111
+ * so they can be used as inline styles, independent of host app CSS.
112
+ */
113
+ export function useSnapColors(): SnapColors {
114
+ const { get } = useStateStore();
115
+ const mode = useSnapAppearance();
116
+ const pageAccent = useSnapPreviewPageAccent();
117
+ const fromState = get("/theme/accent");
118
+ const accentRaw =
119
+ (typeof pageAccent === "string" && pageAccent.length > 0
120
+ ? pageAccent
121
+ : fromState) ?? undefined;
122
+ const accentName =
123
+ typeof accentRaw === "string" && accentRaw.length > 0
124
+ ? accentRaw
125
+ : "purple";
126
+
127
+ return useMemo(() => buildSnapColors(accentName, mode), [accentName, mode]);
128
+ }
@@ -1,19 +1,11 @@
1
1
  "use client";
2
2
 
3
3
  import type { Spec } from "@json-render/core";
4
- import { snapJsonRenderCatalog } from "../ui/index.js";
5
- import { SnapCatalogView } from "./catalog-renderer";
6
- import { SnapPreviewAccentProvider } from "./accent-context";
7
- import { resolveSnapPaletteHex } from "./lib/resolve-palette-hex";
8
- import { snapPreviewPrimaryCssProperties } from "./lib/preview-primary-css";
9
- import {
10
- type CSSProperties,
11
- useCallback,
12
- useEffect,
13
- useMemo,
14
- useRef,
15
- useState,
16
- } from "react";
4
+ import type { ReactNode } from "react";
5
+ import type { ValidationResult } from "../validator.js";
6
+ import { SPEC_VERSION_2 } from "../constants";
7
+ import { SnapCardV1 } from "./v1/snap-view";
8
+ import { SnapCardV2 } from "./v2/snap-view";
17
9
 
18
10
  // ─── Public types ──────────────────────────────────────
19
11
 
@@ -35,6 +27,7 @@ export type SnapPage = {
35
27
  export type SnapActionHandlers = {
36
28
  submit: (target: string, inputs: Record<string, JsonValue>) => void;
37
29
  open_url: (target: string) => void;
30
+ open_snap: (target: string) => void;
38
31
  open_mini_app: (target: string) => void;
39
32
  view_cast: (params: { hash: string }) => void;
40
33
  view_profile: (params: { fid: number }) => void;
@@ -50,272 +43,63 @@ export type SnapActionHandlers = {
50
43
  recipientFid?: number;
51
44
  recipientAddress?: string;
52
45
  }) => void;
53
- swap_token: (params: {
54
- sellToken?: string;
55
- buyToken?: string;
56
- }) => void;
46
+ swap_token: (params: { sellToken?: string; buyToken?: string }) => void;
57
47
  };
58
48
 
59
- // ─── Internal helpers ──────────────────────────────────
60
-
61
- function applyStatePaths(
62
- model: Record<string, unknown>,
63
- changes: { path: string; value: unknown }[] | Record<string, unknown>,
64
- ): void {
65
- const entries = Array.isArray(changes)
66
- ? changes.map((c) => [c.path, c.value] as const)
67
- : Object.entries(changes);
68
- for (const [path, value] of entries) {
69
- const trimmed = path.startsWith("/") ? path : `/${path}`;
70
- const parts = trimmed.split("/").filter(Boolean);
71
- if (parts.length < 2) continue;
72
- const [top, ...rest] = parts;
73
- if (top === "inputs") {
74
- if (typeof model.inputs !== "object" || model.inputs === null) {
75
- model.inputs = {};
76
- }
77
- const inputs = model.inputs as Record<string, unknown>;
78
- if (rest.length === 1) {
79
- inputs[rest[0]!] = value;
80
- }
81
- continue;
82
- }
83
- if (top === "theme") {
84
- if (typeof model.theme !== "object" || model.theme === null) {
85
- model.theme = {};
86
- }
87
- const theme = model.theme as Record<string, unknown>;
88
- if (rest.length === 1) {
89
- theme[rest[0]!] = value;
90
- }
91
- }
92
- }
93
- }
94
-
95
- const CONFETTI_COLORS = [
96
- "#8B5CF6",
97
- "#EC4899",
98
- "#3B82F6",
99
- "#10B981",
100
- "#F59E0B",
101
- "#EF4444",
102
- "#06B6D4",
103
- ];
104
-
105
- function ConfettiOverlay() {
106
- const pieces = useMemo(
107
- () =>
108
- Array.from({ length: 80 }, (_, i) => ({
109
- id: i,
110
- left: Math.random() * 100,
111
- delay: Math.random() * 1.2,
112
- duration: 2.5 + Math.random() * 2,
113
- color:
114
- CONFETTI_COLORS[Math.floor(Math.random() * CONFETTI_COLORS.length)],
115
- size: 6 + Math.random() * 8,
116
- rotation: Math.random() * 360,
117
- })),
118
- [],
119
- );
120
-
121
- return (
122
- <div
123
- style={{
124
- position: "absolute",
125
- inset: 0,
126
- overflow: "hidden",
127
- pointerEvents: "none",
128
- zIndex: 20,
129
- }}
130
- >
131
- {pieces.map(({ id, left, delay, duration, color, size, rotation }) => (
132
- <div
133
- key={id}
134
- style={{
135
- position: "absolute",
136
- left: `${left}%`,
137
- top: -20,
138
- width: size,
139
- height: size * 0.6,
140
- backgroundColor: color,
141
- borderRadius: 2,
142
- transform: `rotate(${rotation}deg)`,
143
- animation: `confettiFall ${duration}s ease-in ${delay}s forwards`,
144
- }}
145
- />
146
- ))}
147
- <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(${Math.random() > 0.5 ? "" : "-"}40px)}}`}</style>
148
- </div>
149
- );
150
- }
151
-
152
- const PALETTE = [
153
- "gray",
154
- "blue",
155
- "red",
156
- "amber",
157
- "green",
158
- "teal",
159
- "purple",
160
- "pink",
161
- ] as const;
49
+ // ─── SnapCard ────────────────────────────────────────
162
50
 
163
- // ─── SnapView ──────────────────────────────────────────
164
-
165
- export function SnapView({
51
+ export function SnapCard({
166
52
  snap,
167
53
  handlers,
168
54
  loading = false,
169
55
  appearance = "dark",
56
+ maxWidth = 480,
57
+ showOverflowWarning = false,
58
+ onValidationError,
59
+ validationErrorFallback,
60
+ actionError,
61
+ plain = false,
170
62
  }: {
171
63
  snap: SnapPage;
172
64
  handlers: SnapActionHandlers;
173
65
  loading?: boolean;
174
66
  appearance?: "light" | "dark";
67
+ maxWidth?: number;
68
+ /** When true, extends to 700px and shows a warning overlay below 500px. When false, clips at 500px. Only applies to v2 snaps. */
69
+ showOverflowWarning?: boolean;
70
+ onValidationError?: (result: ValidationResult) => void;
71
+ validationErrorFallback?: ReactNode;
72
+ /** Server-side action error message to display inline. */
73
+ actionError?: string | null;
74
+ /** When true, renders without card frame (no border, background, or padding). */
75
+ plain?: boolean;
175
76
  }) {
176
- const spec = snap.ui;
177
- const initialState = useMemo(
178
- () => spec.state ?? { inputs: {} },
179
- [spec],
180
- );
181
-
182
- const stateRef = useRef<Record<string, unknown>>(initialState);
183
-
184
- useEffect(() => {
185
- stateRef.current = {
186
- inputs: {
187
- ...((initialState.inputs ?? {}) as Record<string, unknown>),
188
- },
189
- theme: {
190
- ...((initialState.theme ?? {}) as Record<string, unknown>),
191
- },
192
- };
193
- }, [initialState]);
194
-
195
- useEffect(() => {
196
- const result = snapJsonRenderCatalog.validate(spec);
197
- if (!result.success) {
198
- // eslint-disable-next-line no-console
199
- console.warn("[SnapView] catalog validation issues:", result.error);
200
- }
201
- }, [spec]);
202
-
203
- const [pageKey, setPageKey] = useState(0);
204
- useEffect(() => {
205
- setPageKey((k) => k + 1);
206
- }, [spec]);
207
-
208
- const showConfetti = snap.effects?.includes("confetti");
209
-
210
- const previewSurfaceStyle = useMemo(() => {
211
- const vars: Record<string, string> = {};
212
- for (const c of PALETTE)
213
- vars[`--snap-color-${c}`] = resolveSnapPaletteHex(c, appearance);
214
- return {
215
- ...snapPreviewPrimaryCssProperties(
216
- snap.theme?.accent ?? "purple",
217
- appearance,
218
- ),
219
- ...vars,
220
- } as CSSProperties;
221
- }, [snap.theme?.accent, appearance]);
222
-
223
- const handleAction = useCallback(
224
- (name: unknown, params: unknown) => {
225
- const inputs = (stateRef.current.inputs ?? {}) as Record<
226
- string,
227
- JsonValue
228
- >;
229
- const p = (params ?? {}) as Record<string, unknown>;
230
- switch (name) {
231
- case "submit":
232
- handlers.submit(String(p.target ?? ""), inputs);
233
- break;
234
- case "open_url":
235
- handlers.open_url(String(p.target ?? ""));
236
- break;
237
- case "open_mini_app":
238
- handlers.open_mini_app(String(p.target ?? ""));
239
- break;
240
- case "view_cast":
241
- handlers.view_cast({ hash: String(p.hash ?? "") });
242
- break;
243
- case "view_profile":
244
- handlers.view_profile({ fid: Number(p.fid ?? 0) });
245
- break;
246
- case "compose_cast":
247
- handlers.compose_cast({
248
- text: p.text ? String(p.text) : undefined,
249
- channelKey: p.channelKey ? String(p.channelKey) : undefined,
250
- embeds: Array.isArray(p.embeds)
251
- ? (p.embeds as string[])
252
- : undefined,
253
- });
254
- break;
255
- case "view_token":
256
- handlers.view_token({ token: String(p.token ?? "") });
257
- break;
258
- case "send_token":
259
- handlers.send_token({
260
- token: String(p.token ?? ""),
261
- amount: p.amount ? String(p.amount) : undefined,
262
- recipientFid: p.recipientFid
263
- ? Number(p.recipientFid)
264
- : undefined,
265
- recipientAddress: p.recipientAddress
266
- ? String(p.recipientAddress)
267
- : undefined,
268
- });
269
- break;
270
- case "swap_token":
271
- handlers.swap_token({
272
- sellToken: p.sellToken ? String(p.sellToken) : undefined,
273
- buyToken: p.buyToken ? String(p.buyToken) : undefined,
274
- });
275
- break;
276
- default:
277
- break;
278
- }
279
- },
280
- [handlers],
281
- );
77
+ if (snap.version === SPEC_VERSION_2) {
78
+ return (
79
+ <SnapCardV2
80
+ snap={snap}
81
+ handlers={handlers}
82
+ loading={loading}
83
+ appearance={appearance}
84
+ maxWidth={maxWidth}
85
+ showOverflowWarning={showOverflowWarning}
86
+ onValidationError={onValidationError}
87
+ validationErrorFallback={validationErrorFallback}
88
+ actionError={actionError}
89
+ plain={plain}
90
+ />
91
+ );
92
+ }
282
93
 
283
94
  return (
284
- <div style={{ position: "relative", width: "100%" }}>
285
- {showConfetti && <ConfettiOverlay />}
286
- {loading && (
287
- <div
288
- style={{
289
- position: "absolute",
290
- inset: 0,
291
- display: "flex",
292
- alignItems: "center",
293
- justifyContent: "center",
294
- zIndex: 10,
295
- fontSize: 14,
296
- color: "var(--text-muted)",
297
- background: "var(--bg-primary, rgba(0,0,0,0.6))",
298
- backdropFilter: "blur(4px)",
299
- }}
300
- >
301
- Loading...
302
- </div>
303
- )}
304
-
305
- <div style={previewSurfaceStyle}>
306
- <SnapPreviewAccentProvider pageAccent={snap.theme?.accent}>
307
- <SnapCatalogView
308
- key={pageKey}
309
- spec={spec}
310
- state={initialState}
311
- loading={false}
312
- onStateChange={(changes) => {
313
- applyStatePaths(stateRef.current, changes);
314
- }}
315
- onAction={handleAction}
316
- />
317
- </SnapPreviewAccentProvider>
318
- </div>
319
- </div>
95
+ <SnapCardV1
96
+ snap={snap}
97
+ handlers={handlers}
98
+ loading={loading}
99
+ appearance={appearance}
100
+ maxWidth={maxWidth}
101
+ actionError={actionError}
102
+ plain={plain}
103
+ />
320
104
  );
321
105
  }