@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
@@ -0,0 +1,343 @@
1
+ "use client";
2
+
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";
17
+ import type { JsonValue, SnapActionHandlers, SnapPage } from "./index";
18
+
19
+ // ─── Internal helpers ──────────────────────────────────
20
+
21
+ export function applyStatePaths(
22
+ model: Record<string, unknown>,
23
+ changes: { path: string; value: unknown }[] | Record<string, unknown>,
24
+ ): void {
25
+ const entries = Array.isArray(changes)
26
+ ? changes.map((c) => [c.path, c.value] as const)
27
+ : Object.entries(changes);
28
+ for (const [path, value] of entries) {
29
+ const trimmed = path.startsWith("/") ? path : `/${path}`;
30
+ const parts = trimmed.split("/").filter(Boolean);
31
+ if (parts.length < 2) continue;
32
+ const [top, ...rest] = parts;
33
+ if (top === "inputs") {
34
+ if (typeof model.inputs !== "object" || model.inputs === null) {
35
+ model.inputs = {};
36
+ }
37
+ const inputs = model.inputs as Record<string, unknown>;
38
+ if (rest.length === 1) {
39
+ inputs[rest[0]!] = value;
40
+ }
41
+ continue;
42
+ }
43
+ if (top === "theme") {
44
+ if (typeof model.theme !== "object" || model.theme === null) {
45
+ model.theme = {};
46
+ }
47
+ const theme = model.theme as Record<string, unknown>;
48
+ if (rest.length === 1) {
49
+ theme[rest[0]!] = value;
50
+ }
51
+ }
52
+ }
53
+ }
54
+
55
+ const CONFETTI_COLORS = [
56
+ "#907AA9",
57
+ "#EC4899",
58
+ "#3B82F6",
59
+ "#10B981",
60
+ "#F59E0B",
61
+ "#EF4444",
62
+ "#06B6D4",
63
+ ];
64
+
65
+ function ConfettiOverlay() {
66
+ const pieces = useMemo(
67
+ () =>
68
+ Array.from({ length: 80 }, (_, i) => ({
69
+ id: i,
70
+ left: Math.random() * 100,
71
+ delay: Math.random() * 1.2,
72
+ duration: 2.5 + Math.random() * 2,
73
+ color:
74
+ CONFETTI_COLORS[Math.floor(Math.random() * CONFETTI_COLORS.length)],
75
+ size: 6 + Math.random() * 8,
76
+ rotation: Math.random() * 360,
77
+ })),
78
+ [],
79
+ );
80
+
81
+ return (
82
+ <div
83
+ style={{
84
+ position: "absolute",
85
+ inset: 0,
86
+ overflow: "hidden",
87
+ pointerEvents: "none",
88
+ zIndex: 20,
89
+ }}
90
+ >
91
+ {pieces.map(({ id, left, delay, duration, color, size, rotation }) => (
92
+ <div
93
+ key={id}
94
+ style={{
95
+ position: "absolute",
96
+ left: `${left}%`,
97
+ top: -20,
98
+ width: size,
99
+ height: size * 0.6,
100
+ backgroundColor: color,
101
+ borderRadius: 2,
102
+ transform: `rotate(${rotation}deg)`,
103
+ animation: `confettiFall ${duration}s ease-in ${delay}s forwards`,
104
+ }}
105
+ />
106
+ ))}
107
+ <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(${
108
+ Math.random() > 0.5 ? "" : "-"
109
+ }40px)}}`}</style>
110
+ </div>
111
+ );
112
+ }
113
+
114
+ function SnapLoadingOverlay({
115
+ appearance,
116
+ accentHex,
117
+ active,
118
+ }: {
119
+ appearance: "light" | "dark";
120
+ accentHex: string;
121
+ active: boolean;
122
+ }) {
123
+ const isDark = appearance === "dark";
124
+ const tint = isDark ? "rgba(0, 0, 0, 0.1)" : "rgba(255, 255, 255, 0.2)";
125
+ const trackColor = isDark
126
+ ? "rgba(255, 255, 255, 0.12)"
127
+ : "rgba(15, 23, 42, 0.1)";
128
+
129
+ return (
130
+ <div
131
+ style={{
132
+ position: "absolute",
133
+ inset: 0,
134
+ display: "flex",
135
+ alignItems: "center",
136
+ justifyContent: "center",
137
+ zIndex: 10,
138
+ background: tint,
139
+ backdropFilter: active ? "blur(10px) saturate(1.05)" : "none",
140
+ WebkitBackdropFilter: active
141
+ ? "blur(10px) saturate(1.05)"
142
+ : "none",
143
+ opacity: active ? 1 : 0,
144
+ pointerEvents: active ? "auto" : "none",
145
+ transition: "opacity 0.28s ease, backdrop-filter 0.28s ease",
146
+ }}
147
+ aria-hidden={!active}
148
+ aria-busy={active ? true : undefined}
149
+ aria-live={active ? "polite" : undefined}
150
+ aria-label={active ? "Loading" : undefined}
151
+ >
152
+ <div
153
+ data-snap-loading-spinner
154
+ style={{
155
+ width: 30,
156
+ height: 30,
157
+ borderRadius: "50%",
158
+ border: `2.5px solid ${trackColor}`,
159
+ borderTopColor: accentHex,
160
+ opacity: 0.88,
161
+ animation: "snapViewSpin 0.75s linear infinite",
162
+ flexShrink: 0,
163
+ }}
164
+ />
165
+ <style>{`
166
+ @keyframes snapViewSpin {
167
+ to { transform: rotate(360deg); }
168
+ }
169
+ @media (prefers-reduced-motion: reduce) {
170
+ [data-snap-loading-spinner] {
171
+ animation: none;
172
+ border-top-color: ${accentHex};
173
+ opacity: 0.75;
174
+ }
175
+ }
176
+ `}</style>
177
+ </div>
178
+ );
179
+ }
180
+
181
+ const PALETTE = [
182
+ "gray",
183
+ "blue",
184
+ "red",
185
+ "amber",
186
+ "green",
187
+ "teal",
188
+ "purple",
189
+ "pink",
190
+ ] as const;
191
+
192
+ // ─── SnapViewCore ────────────────────────────────────
193
+ // Shared rendering logic used by both v1 and v2.
194
+
195
+ export function SnapViewCore({
196
+ snap,
197
+ handlers,
198
+ loading = false,
199
+ appearance = "dark",
200
+ }: {
201
+ snap: SnapPage;
202
+ handlers: SnapActionHandlers;
203
+ loading?: boolean;
204
+ appearance?: "light" | "dark";
205
+ }) {
206
+ const spec = snap.ui;
207
+ const initialState = useMemo(() => spec.state ?? { inputs: {} }, [spec]);
208
+
209
+ const stateRef = useRef<Record<string, unknown>>(initialState);
210
+
211
+ useEffect(() => {
212
+ stateRef.current = {
213
+ inputs: {
214
+ ...((initialState.inputs ?? {}) as Record<string, unknown>),
215
+ },
216
+ theme: {
217
+ ...((initialState.theme ?? {}) as Record<string, unknown>),
218
+ },
219
+ };
220
+ }, [initialState]);
221
+
222
+ useEffect(() => {
223
+ const catalogResult = snapJsonRenderCatalog.validate(spec);
224
+ if (!catalogResult.success) {
225
+ // eslint-disable-next-line no-console
226
+ console.warn("[Snap] catalog validation issues:", catalogResult.error);
227
+ }
228
+ }, [spec]);
229
+
230
+ const [pageKey, setPageKey] = useState(0);
231
+ useEffect(() => {
232
+ setPageKey((k) => k + 1);
233
+ }, [spec]);
234
+
235
+ const showConfetti = snap.effects?.includes("confetti");
236
+
237
+ const accentName = snap.theme?.accent ?? "purple";
238
+
239
+ const accentHex = useMemo(
240
+ () => resolveSnapPaletteHex(accentName, appearance),
241
+ [accentName, appearance],
242
+ );
243
+
244
+ const previewSurfaceStyle = useMemo(() => {
245
+ const vars: Record<string, string> = {};
246
+ for (const c of PALETTE)
247
+ vars[`--snap-color-${c}`] = resolveSnapPaletteHex(c, appearance);
248
+ return {
249
+ ...snapPreviewPrimaryCssProperties(accentName, appearance),
250
+ ...vars,
251
+ } as CSSProperties;
252
+ }, [accentName, appearance]);
253
+
254
+ const handleAction = useCallback(
255
+ (name: unknown, params: unknown) => {
256
+ const inputs = (stateRef.current.inputs ?? {}) as Record<
257
+ string,
258
+ JsonValue
259
+ >;
260
+ const p = (params ?? {}) as Record<string, unknown>;
261
+ switch (name) {
262
+ case "submit":
263
+ handlers.submit(String(p.target ?? ""), inputs);
264
+ break;
265
+ case "open_url":
266
+ handlers.open_url(String(p.target ?? ""));
267
+ break;
268
+ case "open_snap":
269
+ handlers.open_snap(String(p.target ?? ""));
270
+ break;
271
+ case "open_mini_app":
272
+ handlers.open_mini_app(String(p.target ?? ""));
273
+ break;
274
+ case "view_cast":
275
+ handlers.view_cast({ hash: String(p.hash ?? "") });
276
+ break;
277
+ case "view_profile":
278
+ handlers.view_profile({ fid: Number(p.fid ?? 0) });
279
+ break;
280
+ case "compose_cast":
281
+ handlers.compose_cast({
282
+ text: p.text ? String(p.text) : undefined,
283
+ channelKey: p.channelKey ? String(p.channelKey) : undefined,
284
+ embeds: Array.isArray(p.embeds)
285
+ ? (p.embeds as string[])
286
+ : undefined,
287
+ });
288
+ break;
289
+ case "view_token":
290
+ handlers.view_token({ token: String(p.token ?? "") });
291
+ break;
292
+ case "send_token":
293
+ handlers.send_token({
294
+ token: String(p.token ?? ""),
295
+ amount: p.amount ? String(p.amount) : undefined,
296
+ recipientFid: p.recipientFid ? Number(p.recipientFid) : undefined,
297
+ recipientAddress: p.recipientAddress
298
+ ? String(p.recipientAddress)
299
+ : undefined,
300
+ });
301
+ break;
302
+ case "swap_token":
303
+ handlers.swap_token({
304
+ sellToken: p.sellToken ? String(p.sellToken) : undefined,
305
+ buyToken: p.buyToken ? String(p.buyToken) : undefined,
306
+ });
307
+ break;
308
+ default:
309
+ break;
310
+ }
311
+ },
312
+ [handlers],
313
+ );
314
+
315
+ return (
316
+ <div style={{ position: "relative", width: "100%" }}>
317
+ {showConfetti && <ConfettiOverlay />}
318
+ <SnapLoadingOverlay
319
+ appearance={appearance}
320
+ accentHex={accentHex}
321
+ active={loading}
322
+ />
323
+
324
+ <div style={previewSurfaceStyle}>
325
+ <SnapPreviewAccentProvider
326
+ pageAccent={snap.theme?.accent}
327
+ appearance={appearance}
328
+ >
329
+ <SnapCatalogView
330
+ key={pageKey}
331
+ spec={spec}
332
+ state={initialState}
333
+ loading={false}
334
+ onStateChange={(changes) => {
335
+ applyStatePaths(stateRef.current, changes);
336
+ }}
337
+ onAction={handleAction}
338
+ />
339
+ </SnapPreviewAccentProvider>
340
+ </div>
341
+ </div>
342
+ );
343
+ }
@@ -0,0 +1,176 @@
1
+ "use client";
2
+
3
+ import { useEffect, useRef, useState } from "react";
4
+ import { SnapViewCore } from "../snap-view-core";
5
+ import type { SnapPage, SnapActionHandlers } from "../index";
6
+
7
+ const SNAP_MAX_HEIGHT = 500;
8
+
9
+ export function SnapViewV1({
10
+ snap,
11
+ handlers,
12
+ loading = false,
13
+ appearance = "dark",
14
+ }: {
15
+ snap: SnapPage;
16
+ handlers: SnapActionHandlers;
17
+ loading?: boolean;
18
+ appearance?: "light" | "dark";
19
+ }) {
20
+ return (
21
+ <SnapViewCore
22
+ snap={snap}
23
+ handlers={handlers}
24
+ loading={loading}
25
+ appearance={appearance}
26
+ />
27
+ );
28
+ }
29
+
30
+ export function SnapCardV1({
31
+ snap,
32
+ handlers,
33
+ loading = false,
34
+ appearance = "dark",
35
+ maxWidth = 480,
36
+ actionError,
37
+ plain = false,
38
+ }: {
39
+ snap: SnapPage;
40
+ handlers: SnapActionHandlers;
41
+ loading?: boolean;
42
+ appearance?: "light" | "dark";
43
+ maxWidth?: number;
44
+ actionError?: string | null;
45
+ plain?: boolean;
46
+ }) {
47
+ const isDark = appearance === "dark";
48
+ const borderColor = isDark ? "rgba(255,255,255,0.1)" : "rgba(0,0,0,0.1)";
49
+ const surfaceBg = isDark ? "rgba(255,255,255,0.05)" : "rgba(0,0,0,0.02)";
50
+ const toggleBg = isDark ? "rgba(255,255,255,0.06)" : "rgba(0,0,0,0.05)";
51
+ const toggleBgHover = isDark
52
+ ? "rgba(255,255,255,0.1)"
53
+ : "rgba(0,0,0,0.08)";
54
+ const toggleText = isDark ? "rgba(255,255,255,0.82)" : "rgba(0,0,0,0.72)";
55
+ const contentRef = useRef<HTMLDivElement>(null);
56
+ const [isExpandable, setIsExpandable] = useState(false);
57
+ const [isExpanded, setIsExpanded] = useState(false);
58
+
59
+ useEffect(() => {
60
+ setIsExpanded(false);
61
+ }, [snap]);
62
+
63
+ useEffect(() => {
64
+ const node = contentRef.current;
65
+ if (!node) return;
66
+
67
+ const measure = () => {
68
+ setIsExpandable(node.scrollHeight > SNAP_MAX_HEIGHT + 1);
69
+ };
70
+
71
+ measure();
72
+
73
+ if (typeof ResizeObserver === "undefined") return;
74
+ const observer = new ResizeObserver(() => {
75
+ measure();
76
+ });
77
+ observer.observe(node);
78
+ return () => observer.disconnect();
79
+ }, [snap, plain]);
80
+
81
+ useEffect(() => {
82
+ if (!isExpandable) {
83
+ setIsExpanded(false);
84
+ }
85
+ }, [isExpandable]);
86
+
87
+ const isClipped = isExpandable && !isExpanded;
88
+
89
+ return (
90
+ <div
91
+ style={{
92
+ position: "relative",
93
+ width: "100%",
94
+ maxWidth,
95
+ overflow: "hidden",
96
+ ...(plain ? {} : {
97
+ borderRadius: 16,
98
+ border: `1px solid ${borderColor}`,
99
+ backgroundColor: surfaceBg,
100
+ }),
101
+ }}
102
+ >
103
+ <div
104
+ style={
105
+ isClipped
106
+ ? {
107
+ maxHeight: SNAP_MAX_HEIGHT,
108
+ overflow: "hidden",
109
+ }
110
+ : undefined
111
+ }
112
+ >
113
+ <div ref={contentRef} style={plain ? undefined : { padding: 16 }}>
114
+ <SnapViewV1
115
+ snap={snap}
116
+ handlers={handlers}
117
+ loading={loading}
118
+ appearance={appearance}
119
+ />
120
+ </div>
121
+ </div>
122
+ {isExpandable ? (
123
+ <div
124
+ style={{
125
+ display: "flex",
126
+ justifyContent: "center",
127
+ padding: plain ? "8px 0 0" : "10px 16px 12px",
128
+ ...(plain
129
+ ? {}
130
+ : { borderTop: `1px solid ${borderColor}` }),
131
+ }}
132
+ >
133
+ <button
134
+ type="button"
135
+ aria-expanded={isExpanded}
136
+ onClick={() => setIsExpanded((value) => !value)}
137
+ style={{
138
+ appearance: "none",
139
+ border: "none",
140
+ borderRadius: 9999,
141
+ backgroundColor: toggleBg,
142
+ color: toggleText,
143
+ padding: "6px 10px",
144
+ fontSize: 13,
145
+ lineHeight: "18px",
146
+ fontWeight: 600,
147
+ cursor: "pointer",
148
+ }}
149
+ onMouseEnter={(event) => {
150
+ event.currentTarget.style.backgroundColor = toggleBgHover;
151
+ }}
152
+ onMouseLeave={(event) => {
153
+ event.currentTarget.style.backgroundColor = toggleBg;
154
+ }}
155
+ >
156
+ {isExpanded ? "Show less" : "Show more"}
157
+ </button>
158
+ </div>
159
+ ) : null}
160
+ {actionError && (
161
+ <div
162
+ style={{
163
+ padding: "8px 12px",
164
+ fontSize: 13,
165
+ color:
166
+ appearance === "dark"
167
+ ? "rgba(255,100,100,0.9)"
168
+ : "rgba(200,0,0,0.8)",
169
+ }}
170
+ >
171
+ {actionError}
172
+ </div>
173
+ )}
174
+ </div>
175
+ );
176
+ }
@@ -0,0 +1,199 @@
1
+ "use client";
2
+
3
+ import { type ReactNode, useEffect, useMemo } from "react";
4
+ import { validateSnapResponse } from "../../validator.js";
5
+ import type { ValidationResult } from "../../validator.js";
6
+ import { SnapViewCore } from "../snap-view-core";
7
+ import type { SnapPage, SnapActionHandlers } from "../index";
8
+
9
+ const SNAP_MAX_HEIGHT = 500;
10
+ const SNAP_WARNING_HEIGHT = 700;
11
+
12
+ // ─── Default validation error fallback ────────────────
13
+
14
+ function SnapValidationFallback({
15
+ appearance,
16
+ message,
17
+ }: {
18
+ appearance: "light" | "dark";
19
+ message?: string;
20
+ }) {
21
+ const isDark = appearance === "dark";
22
+ return (
23
+ <div
24
+ style={{
25
+ width: "100%",
26
+ padding: 16,
27
+ display: "flex",
28
+ alignItems: "center",
29
+ justifyContent: "center",
30
+ color: isDark ? "rgba(255,255,255,0.5)" : "rgba(0,0,0,0.4)",
31
+ fontSize: 14,
32
+ }}
33
+ >
34
+ <span>{message ? `Unable to render snap: ${message}` : "Unable to render snap"}</span>
35
+ </div>
36
+ );
37
+ }
38
+
39
+ // ─── SnapViewV2 ──────────────────────────────────────
40
+
41
+ export function SnapViewV2({
42
+ snap,
43
+ handlers,
44
+ loading = false,
45
+ appearance = "dark",
46
+ onValidationError,
47
+ validationErrorFallback,
48
+ }: {
49
+ snap: SnapPage;
50
+ handlers: SnapActionHandlers;
51
+ loading?: boolean;
52
+ appearance?: "light" | "dark";
53
+ onValidationError?: (result: ValidationResult) => void;
54
+ validationErrorFallback?: ReactNode;
55
+ }) {
56
+ const validation = useMemo(() => validateSnapResponse(snap), [snap]);
57
+ const valid = validation.valid;
58
+ const validationMessage = validation.issues[0]?.message;
59
+
60
+ useEffect(() => {
61
+ if (!valid) {
62
+ if (onValidationError) {
63
+ onValidationError(validation);
64
+ } else {
65
+ // eslint-disable-next-line no-console
66
+ console.warn("[Snap] validation issues:", validation.issues);
67
+ }
68
+ }
69
+ }, [valid, validation, onValidationError]);
70
+
71
+ if (!valid) {
72
+ if (validationErrorFallback === null) return null;
73
+ return <>{validationErrorFallback ?? <SnapValidationFallback appearance={appearance} message={validationMessage} />}</>;
74
+ }
75
+
76
+ return (
77
+ <SnapViewCore
78
+ snap={snap}
79
+ handlers={handlers}
80
+ loading={loading}
81
+ appearance={appearance}
82
+ />
83
+ );
84
+ }
85
+
86
+ // ─── SnapCardV2 ──────────────────────────────────────
87
+
88
+ export function SnapCardV2({
89
+ snap,
90
+ handlers,
91
+ loading = false,
92
+ appearance = "dark",
93
+ maxWidth = 480,
94
+ showOverflowWarning = false,
95
+ onValidationError,
96
+ validationErrorFallback,
97
+ actionError,
98
+ plain = false,
99
+ }: {
100
+ snap: SnapPage;
101
+ handlers: SnapActionHandlers;
102
+ loading?: boolean;
103
+ appearance?: "light" | "dark";
104
+ maxWidth?: number;
105
+ showOverflowWarning?: boolean;
106
+ onValidationError?: (result: ValidationResult) => void;
107
+ validationErrorFallback?: ReactNode;
108
+ actionError?: string | null;
109
+ plain?: boolean;
110
+ }) {
111
+ const maxHeight = showOverflowWarning ? SNAP_WARNING_HEIGHT : SNAP_MAX_HEIGHT;
112
+ const isDark = appearance === "dark";
113
+ const bg = isDark ? "rgba(0,0,0,0.85)" : "rgba(255,255,255,0.9)";
114
+ const borderColor = isDark ? "rgba(255,255,255,0.1)" : "rgba(0,0,0,0.1)";
115
+ const surfaceBg = isDark ? "rgba(255,255,255,0.05)" : "rgba(0,0,0,0.02)";
116
+
117
+ return (
118
+ <>
119
+ <div
120
+ style={{
121
+ position: "relative",
122
+ width: "100%",
123
+ maxWidth,
124
+ maxHeight,
125
+ overflow: "hidden",
126
+ ...(plain ? {} : {
127
+ borderRadius: 16,
128
+ border: `1px solid ${borderColor}`,
129
+ backgroundColor: surfaceBg,
130
+ }),
131
+ }}
132
+ >
133
+ <div style={plain ? undefined : { padding: 16 }}>
134
+ <SnapViewV2
135
+ snap={snap}
136
+ handlers={handlers}
137
+ loading={loading}
138
+ appearance={appearance}
139
+ onValidationError={onValidationError}
140
+ validationErrorFallback={validationErrorFallback}
141
+ />
142
+ </div>
143
+ {showOverflowWarning && (
144
+ <div
145
+ style={{
146
+ position: "absolute",
147
+ top: SNAP_MAX_HEIGHT,
148
+ left: 0,
149
+ right: 0,
150
+ bottom: 0,
151
+ pointerEvents: "none",
152
+ zIndex: 10,
153
+ }}
154
+ >
155
+ <div style={{ borderTop: "1px dashed rgba(255,100,100,0.6)", position: "relative" }}>
156
+ <span
157
+ style={{
158
+ position: "absolute",
159
+ top: -10,
160
+ right: 0,
161
+ fontSize: 10,
162
+ fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
163
+ color: "rgba(255,100,100,0.7)",
164
+ background: bg,
165
+ padding: "1px 4px",
166
+ borderRadius: 3,
167
+ }}
168
+ >
169
+ {SNAP_MAX_HEIGHT}px
170
+ </span>
171
+ </div>
172
+ <div
173
+ style={{
174
+ height: "100%",
175
+ background:
176
+ "repeating-linear-gradient(-45deg, transparent, transparent 8px, rgba(255,100,100,0.06) 8px, rgba(255,100,100,0.06) 16px)",
177
+ }}
178
+ />
179
+ </div>
180
+ )}
181
+ </div>
182
+ {actionError && (
183
+ <div
184
+ style={{
185
+ maxWidth,
186
+ padding: "8px 12px",
187
+ fontSize: 13,
188
+ color:
189
+ appearance === "dark"
190
+ ? "rgba(255,100,100,0.9)"
191
+ : "rgba(200,0,0,0.8)",
192
+ }}
193
+ >
194
+ {actionError}
195
+ </div>
196
+ )}
197
+ </>
198
+ );
199
+ }