@farcaster/snap-hono 1.1.4 → 1.1.6

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/index.d.ts CHANGED
@@ -12,10 +12,10 @@ export type SnapHandlerOptions = {
12
12
  */
13
13
  skipJFSVerification?: boolean;
14
14
  /**
15
- * Visible message in the HTML page served on GET when the client does not request snap JSON.
16
- * @default "This is a Farcaster Snap server."
15
+ * Raw HTML string for the browser fallback page. When set, takes precedence
16
+ * over the default branded fallback.
17
17
  */
18
- fallbackText?: string;
18
+ fallbackHtml?: string;
19
19
  };
20
20
  /**
21
21
  * Register GET and POST snap handlers on `app` at `options.path` (default `/`).
package/dist/index.js CHANGED
@@ -2,6 +2,7 @@ import { cors } from "hono/cors";
2
2
  import { MEDIA_TYPE } from "@farcaster/snap";
3
3
  import { parseRequest } from "@farcaster/snap/server";
4
4
  import { payloadToResponse, snapHeaders } from "./payloadToResponse.js";
5
+ import { renderSnapPage } from "./renderSnapPage.js";
5
6
  /**
6
7
  * Register GET and POST snap handlers on `app` at `options.path` (default `/`).
7
8
  *
@@ -15,14 +16,13 @@ import { payloadToResponse, snapHeaders } from "./payloadToResponse.js";
15
16
  */
16
17
  export function registerSnapHandler(app, snapFn, options = {}) {
17
18
  const path = options.path ?? "/";
18
- const fallbackText = options.fallbackText ??
19
- "This is a Farcaster Snap server. See <a href='https://snap.farcaster.xyz'>snap.farcaster.xyz</a> for more info.";
20
19
  app.use(path, cors({ origin: "*" }));
21
20
  app.get(path, async (c) => {
22
21
  const resourcePath = resourcePathFromRequest(c.req.url);
23
22
  const accept = c.req.header("Accept");
24
23
  if (!clientWantsSnapResponse(accept)) {
25
- return new Response(fallbackHtmlDocument(fallbackText), {
24
+ const fallbackHtml = options.fallbackHtml ?? (await getFallbackHtml(c.req.raw, snapFn));
25
+ return new Response(fallbackHtml, {
26
26
  status: 200,
27
27
  headers: snapHeaders(resourcePath, "text/html", [
28
28
  MEDIA_TYPE,
@@ -75,25 +75,65 @@ function resourcePathFromRequest(url) {
75
75
  const u = new URL(url);
76
76
  return u.pathname + u.search;
77
77
  }
78
- function escapeHtml(text) {
79
- return text
80
- .replace(/&/g, "&amp;")
81
- .replace(/</g, "&lt;")
82
- .replace(/>/g, "&gt;")
83
- .replace(/"/g, "&quot;")
84
- .replace(/'/g, "&#39;");
78
+ async function getFallbackHtml(request, snapFn) {
79
+ try {
80
+ const snap = await snapFn({
81
+ action: { type: "get" },
82
+ request,
83
+ });
84
+ return renderSnapPage(snap, snapOriginFromRequest(request));
85
+ }
86
+ catch {
87
+ return brandedFallbackHtml(snapOriginFromRequest(request));
88
+ }
89
+ }
90
+ const FARCASTER_ICON_SVG = `<svg aria-hidden="true" focusable="false" viewBox="0 0 520 457" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M519.801 0V61.6809H458.172V123.31H477.054V123.331H519.801V456.795H416.57L416.507 456.49L363.832 207.03C358.81 183.251 345.667 161.736 326.827 146.434C307.988 131.133 284.255 122.71 260.006 122.71H259.8C235.551 122.71 211.818 131.133 192.979 146.434C174.139 161.736 160.996 183.259 155.974 207.03L103.239 456.795H0V123.323H42.7471V123.31H61.6262V61.6809H0V0H519.801Z" fill="currentColor"/></svg>`;
91
+ function snapOriginFromRequest(request) {
92
+ const fromEnv = process.env.SNAP_PUBLIC_BASE_URL?.trim();
93
+ if (fromEnv)
94
+ return fromEnv.replace(/\/$/, "");
95
+ const proto = request.headers.get("x-forwarded-proto")?.trim() || "https";
96
+ const host = request.headers.get("x-forwarded-host")?.trim() ||
97
+ request.headers.get("host")?.trim();
98
+ if (host)
99
+ return `${proto}://${host}`.replace(/\/$/, "");
100
+ return "https://snap.farcaster.xyz";
85
101
  }
86
- function fallbackHtmlDocument(message) {
87
- const body = escapeHtml(message);
102
+ function brandedFallbackHtml(snapOrigin) {
103
+ const snapUrl = encodeURIComponent(snapOrigin + "/");
104
+ const testUrl = `https://farcaster.xyz/~/developers/snaps?url=${snapUrl}`;
88
105
  return `<!DOCTYPE html>
89
106
  <html lang="en">
90
107
  <head>
91
108
  <meta charset="utf-8">
92
109
  <meta name="viewport" content="width=device-width, initial-scale=1">
93
110
  <title>Farcaster Snap</title>
111
+ <style>
112
+ *{margin:0;padding:0;box-sizing:border-box}
113
+ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#0A0A0A;color:#FAFAFA;min-height:100vh;display:flex;align-items:center;justify-content:center}
114
+ .c{text-align:center;max-width:400px;padding:48px 32px}
115
+ .logo{color:#8B5CF6;margin-bottom:24px}
116
+ .logo svg{width:48px;height:42px}
117
+ h1{font-size:24px;font-weight:700;margin-bottom:8px}
118
+ p{color:#A1A1AA;font-size:15px;line-height:1.5;margin-bottom:32px}
119
+ .btns{display:flex;flex-direction:column;gap:12px}
120
+ a{display:flex;align-items:center;justify-content:center;gap:8px;padding:12px 24px;border-radius:12px;font-size:15px;font-weight:600;text-decoration:none;transition:opacity .15s}
121
+ a:hover{opacity:.85}
122
+ .p{background:#8B5CF6;color:#fff}
123
+ .s{background:#1A1A2E;color:#FAFAFA;border:1px solid #2D2D44}
124
+ .s svg{width:20px;height:18px}
125
+ </style>
94
126
  </head>
95
127
  <body>
96
- <p>${body}</p>
128
+ <div class="c">
129
+ <div class="logo">${FARCASTER_ICON_SVG}</div>
130
+ <h1>Farcaster Snap</h1>
131
+ <p>This is a Farcaster Snap &mdash; an interactive embed that lives in the feed.</p>
132
+ <div class="btns">
133
+ <a href="${testUrl}" class="p">Test this snap</a>
134
+ <a href="https://farcaster.xyz" class="s">${FARCASTER_ICON_SVG} Sign up for Farcaster</a>
135
+ </div>
136
+ </div>
97
137
  </body>
98
138
  </html>`;
99
139
  }
@@ -0,0 +1,2 @@
1
+ import type { SnapResponse } from "@farcaster/snap";
2
+ export declare function renderSnapPage(snap: SnapResponse, snapOrigin: string): string;
@@ -0,0 +1,299 @@
1
+ const PALETTE = {
2
+ gray: "#8F8F8F",
3
+ blue: "#006BFF",
4
+ red: "#FC0036",
5
+ amber: "#FFAE00",
6
+ green: "#28A948",
7
+ teal: "#00AC96",
8
+ purple: "#8B5CF6",
9
+ pink: "#F32782",
10
+ };
11
+ const FC_ICON = `<svg viewBox="0 0 520 457" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M519.801 0V61.6809H458.172V123.31H477.054V123.331H519.801V456.795H416.57L416.507 456.49L363.832 207.03C358.81 183.251 345.667 161.736 326.827 146.434C307.988 131.133 284.255 122.71 260.006 122.71H259.8C235.551 122.71 211.818 131.133 192.979 146.434C174.139 161.736 160.996 183.259 155.974 207.03L103.239 456.795H0V123.323H42.7471V123.31H61.6262V61.6809H0V0H519.801Z" fill="currentColor"/></svg>`;
12
+ function esc(s) {
13
+ return s
14
+ .replace(/&/g, "&amp;")
15
+ .replace(/</g, "&lt;")
16
+ .replace(/>/g, "&gt;")
17
+ .replace(/"/g, "&quot;");
18
+ }
19
+ function accentHex(accent) {
20
+ return PALETTE[accent ?? "purple"] ?? PALETTE.purple;
21
+ }
22
+ function colorHex(color, accent) {
23
+ if (!color || color === "accent")
24
+ return accent;
25
+ return PALETTE[color] ?? accent;
26
+ }
27
+ // ─── Element renderers ──────────────────────────────────
28
+ function renderElement(el, accent) {
29
+ const type = el.type;
30
+ switch (type) {
31
+ case "text":
32
+ return renderText(el, accent);
33
+ case "image":
34
+ return renderImage(el);
35
+ case "grid":
36
+ return renderGrid(el);
37
+ case "progress":
38
+ return renderProgress(el, accent);
39
+ case "bar_chart":
40
+ return renderBarChart(el, accent);
41
+ case "list":
42
+ return renderList(el);
43
+ case "button_group":
44
+ return renderButtonGroup(el, accent);
45
+ case "slider":
46
+ return renderSlider(el, accent);
47
+ case "text_input":
48
+ return renderTextInput(el);
49
+ case "toggle":
50
+ return renderToggle(el, accent);
51
+ case "group":
52
+ return renderGroup(el, accent);
53
+ case "divider":
54
+ return `<hr style="border:none;border-top:1px solid #E5E7EB;margin:4px 0">`;
55
+ case "spacer": {
56
+ const sizes = {
57
+ small: "8px",
58
+ medium: "16px",
59
+ large: "24px",
60
+ };
61
+ return `<div style="height:${sizes[el.size ?? "medium"] ?? "16px"}"></div>`;
62
+ }
63
+ default:
64
+ return "";
65
+ }
66
+ }
67
+ function renderText(el, _accent) {
68
+ const style = el.style;
69
+ const content = esc(el.content);
70
+ const align = el.align ?? "left";
71
+ const styles = {
72
+ title: "font-size:20px;font-weight:700;color:#111",
73
+ body: "font-size:15px;line-height:1.5;color:#374151",
74
+ caption: "font-size:13px;color:#9CA3AF",
75
+ label: "font-size:13px;font-weight:600;color:#6B7280;text-transform:uppercase;letter-spacing:0.5px",
76
+ };
77
+ return `<div style="${styles[style] ?? styles.body};text-align:${align}">${content}</div>`;
78
+ }
79
+ function renderImage(el) {
80
+ const url = esc(el.url);
81
+ const aspect = el.aspect ?? "16:9";
82
+ const [w, h] = aspect.split(":").map(Number);
83
+ const ratio = w && h ? `${w}/${h}` : "16/9";
84
+ return `<div style="aspect-ratio:${ratio};border-radius:8px;overflow:hidden;background:#F3F4F6"><img src="${url}" alt="${esc(el.alt ?? "")}" style="width:100%;height:100%;object-fit:cover"></div>`;
85
+ }
86
+ function renderGrid(el) {
87
+ const cols = el.cols;
88
+ const rows = el.rows;
89
+ const cells = el.cells;
90
+ const cellSize = el.cellSize ?? "auto";
91
+ const gap = el.gap ?? "small";
92
+ const gapPx = {
93
+ none: "0",
94
+ small: "2px",
95
+ medium: "4px",
96
+ };
97
+ const cellMap = new Map();
98
+ for (const c of cells)
99
+ cellMap.set(`${c.row},${c.col}`, c);
100
+ let cellsHtml = "";
101
+ for (let r = 0; r < rows; r++) {
102
+ for (let c = 0; c < cols; c++) {
103
+ const cell = cellMap.get(`${r},${c}`);
104
+ const bg = cell?.color ?? "transparent";
105
+ const content = cell?.content ? esc(cell.content) : "";
106
+ const sq = cellSize === "square" ? "aspect-ratio:1;" : "";
107
+ cellsHtml += `<div style="${sq}background:${bg};display:flex;align-items:center;justify-content:center;font-size:11px;color:#fff;border-radius:2px">${content}</div>`;
108
+ }
109
+ }
110
+ return `<div style="display:grid;grid-template-columns:repeat(${cols},1fr);gap:${gapPx[gap] ?? "2px"}">${cellsHtml}</div>`;
111
+ }
112
+ function renderProgress(el, accent) {
113
+ const value = el.value;
114
+ const max = el.max;
115
+ const label = el.label;
116
+ const color = colorHex(el.color, accent);
117
+ const pct = max > 0 ? Math.min(100, (value / max) * 100) : 0;
118
+ const labelHtml = label
119
+ ? `<div style="font-size:13px;color:#6B7280;margin-bottom:4px">${esc(label)}</div>`
120
+ : "";
121
+ return `<div>${labelHtml}<div style="height:8px;background:#E5E7EB;border-radius:4px;overflow:hidden"><div style="height:100%;width:${pct}%;background:${color};border-radius:4px"></div></div></div>`;
122
+ }
123
+ function renderBarChart(el, accent) {
124
+ const bars = el.bars;
125
+ const max = el.max ?? Math.max(...bars.map((b) => b.value), 1);
126
+ const defaultColor = colorHex(el.color, accent);
127
+ let html = `<div style="display:flex;align-items:flex-end;gap:12px;height:120px">`;
128
+ for (const bar of bars) {
129
+ const color = bar.color ? PALETTE[bar.color] ?? defaultColor : defaultColor;
130
+ const pct = max > 0 ? (bar.value / max) * 100 : 0;
131
+ html += `<div style="flex:1;display:flex;flex-direction:column;align-items:center;height:100%;justify-content:flex-end">`;
132
+ html += `<div style="font-size:11px;color:#6B7280;margin-bottom:4px">${bar.value}</div>`;
133
+ html += `<div style="width:100%;height:${pct}%;background:${color};border-radius:4px 4px 0 0;min-height:4px"></div>`;
134
+ html += `<div style="font-size:11px;color:#9CA3AF;margin-top:4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100%">${esc(bar.label)}</div>`;
135
+ html += `</div>`;
136
+ }
137
+ html += `</div>`;
138
+ return html;
139
+ }
140
+ function renderList(el) {
141
+ const style = el.style ?? "ordered";
142
+ const items = el.items;
143
+ let html = "";
144
+ for (let i = 0; i < items.length; i++) {
145
+ const item = items[i];
146
+ const prefix = style === "ordered"
147
+ ? `<span style="color:#9CA3AF;min-width:20px">${i + 1}.</span>`
148
+ : style === "unordered"
149
+ ? `<span style="color:#9CA3AF;min-width:20px">&bull;</span>`
150
+ : "";
151
+ const trailing = item.trailing
152
+ ? `<span style="color:#9CA3AF;font-size:13px;white-space:nowrap">${esc(item.trailing)}</span>`
153
+ : "";
154
+ html += `<div style="display:flex;align-items:center;gap:8px;padding:6px 0">${prefix}<span style="flex:1;font-size:14px;color:#374151">${esc(item.content)}</span>${trailing}</div>`;
155
+ }
156
+ return `<div>${html}</div>`;
157
+ }
158
+ function renderButtonGroup(el, accent) {
159
+ const options = el.options;
160
+ const layout = el.style ?? "row";
161
+ const dir = layout === "stack" ? "column" : "row";
162
+ let html = `<div style="display:flex;flex-direction:${dir};gap:8px">`;
163
+ for (const opt of options) {
164
+ html += `<button onclick="showModal()" style="flex:1;padding:10px 12px;border-radius:8px;border:1px solid #E5E7EB;background:#fff;font-size:14px;color:#374151;cursor:pointer;font-family:inherit">${esc(opt)}</button>`;
165
+ }
166
+ html += `</div>`;
167
+ return html;
168
+ }
169
+ function renderSlider(el, accent) {
170
+ const label = el.label;
171
+ const min = el.min;
172
+ const max = el.max;
173
+ const value = el.value ?? (min + max) / 2;
174
+ const minLabel = el.minLabel;
175
+ const maxLabel = el.maxLabel;
176
+ const labelHtml = label
177
+ ? `<div style="font-size:13px;color:#6B7280;margin-bottom:4px">${esc(label)}</div>`
178
+ : "";
179
+ const minL = minLabel
180
+ ? `<span style="font-size:11px;color:#9CA3AF">${esc(minLabel)}</span>`
181
+ : "";
182
+ const maxL = maxLabel
183
+ ? `<span style="font-size:11px;color:#9CA3AF">${esc(maxLabel)}</span>`
184
+ : "";
185
+ return `<div>${labelHtml}<div style="display:flex;align-items:center;gap:8px">${minL}<input type="range" min="${min}" max="${max}" value="${value}" disabled style="flex:1;accent-color:${accent};opacity:0.7">${maxL}</div></div>`;
186
+ }
187
+ function renderTextInput(el) {
188
+ const placeholder = esc(el.placeholder ?? "");
189
+ return `<input type="text" placeholder="${placeholder}" disabled style="width:100%;padding:10px 12px;border-radius:8px;border:1px solid #E5E7EB;background:#F9FAFB;font-size:14px;color:#9CA3AF;font-family:inherit;box-sizing:border-box">`;
190
+ }
191
+ function renderToggle(el, accent) {
192
+ const label = esc(el.label);
193
+ const value = el.value;
194
+ const bg = value ? accent : "#D1D5DB";
195
+ const tx = value ? "20px" : "2px";
196
+ return `<div style="display:flex;align-items:center;justify-content:space-between">
197
+ <span style="font-size:14px;color:#374151">${label}</span>
198
+ <div style="width:44px;height:24px;background:${bg};border-radius:12px;position:relative;opacity:0.7"><div style="width:20px;height:20px;background:#fff;border-radius:50%;position:absolute;top:2px;left:${tx};transition:left .2s"></div></div>
199
+ </div>`;
200
+ }
201
+ function renderGroup(el, accent) {
202
+ const children = el.children;
203
+ let html = `<div style="display:flex;gap:12px">`;
204
+ for (const child of children) {
205
+ html += `<div style="flex:1">${renderElement(child, accent)}</div>`;
206
+ }
207
+ html += `</div>`;
208
+ return html;
209
+ }
210
+ // ─── Buttons ────────────────────────────────────────────
211
+ function renderButtons(buttons, layout, accent) {
212
+ if (!buttons || buttons.length === 0)
213
+ return "";
214
+ const dir = layout === "row"
215
+ ? "flex-direction:row"
216
+ : layout === "grid"
217
+ ? "display:grid;grid-template-columns:1fr 1fr"
218
+ : "flex-direction:column";
219
+ const wrap = layout === "row"
220
+ ? "display:flex;"
221
+ : layout === "grid"
222
+ ? ""
223
+ : "display:flex;";
224
+ let html = `<div style="${wrap}${dir};gap:8px;margin-top:12px">`;
225
+ for (let i = 0; i < buttons.length; i++) {
226
+ const btn = buttons[i];
227
+ const label = esc(btn.label);
228
+ const style = btn.style ?? (i === 0 ? "primary" : "secondary");
229
+ const bg = style === "primary" ? accent : "transparent";
230
+ const color = style === "primary" ? "#fff" : accent;
231
+ const border = style === "primary" ? "none" : `2px solid ${accent}`;
232
+ html += `<button onclick="showModal()" style="flex:1;padding:10px 16px;border-radius:10px;background:${bg};color:${color};border:${border};font-size:14px;font-weight:600;cursor:pointer;font-family:inherit">${label}</button>`;
233
+ }
234
+ html += `</div>`;
235
+ return html;
236
+ }
237
+ // ─── Main renderer ──────────────────────────────────────
238
+ export function renderSnapPage(snap, snapOrigin) {
239
+ const page = snap.page;
240
+ const accent = accentHex(page.theme?.accent);
241
+ // Extract title for <title> tag
242
+ const titleEl = page.elements.children.find((el) => el.type === "text" && el.style === "title");
243
+ const pageTitle = titleEl ? esc(titleEl.content) : "Farcaster Snap";
244
+ const snapUrl = encodeURIComponent(snapOrigin + "/");
245
+ // Render elements
246
+ let elementsHtml = "";
247
+ for (const el of page.elements.children) {
248
+ elementsHtml += `<div style="margin-bottom:12px">${renderElement(el, accent)}</div>`;
249
+ }
250
+ // Render buttons
251
+ const buttonsHtml = renderButtons(page.buttons, page.button_layout, accent);
252
+ return `<!DOCTYPE html>
253
+ <html lang="en">
254
+ <head>
255
+ <meta charset="utf-8">
256
+ <meta name="viewport" content="width=device-width, initial-scale=1">
257
+ <title>${pageTitle}</title>
258
+ <style>
259
+ *{margin:0;padding:0;box-sizing:border-box}
260
+ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#0A0A0A;min-height:100vh;display:flex;align-items:center;justify-content:center;flex-direction:column;padding:24px}
261
+ .card{background:#fff;border-radius:16px;max-width:420px;width:100%;padding:20px;box-shadow:0 4px 24px rgba(0,0,0,0.3)}
262
+ .foot{margin-top:16px;text-align:center}
263
+ .foot a{color:#8B5CF6;text-decoration:none;font-size:13px;display:inline-flex;align-items:center;gap:6px}
264
+ .foot a:hover{opacity:.8}
265
+ .foot svg{width:14px;height:12px}
266
+ .modal{display:none;position:fixed;inset:0;background:rgba(0,0,0,0.7);align-items:center;justify-content:center;z-index:99}
267
+ .modal-box{background:#1A1A2E;border-radius:16px;padding:32px;text-align:center;max-width:340px;width:90%}
268
+ .modal-box svg{width:40px;height:35px;color:#8B5CF6;margin-bottom:16px}
269
+ .modal-box h2{color:#FAFAFA;font-size:20px;margin-bottom:8px}
270
+ .modal-box p{color:#A1A1AA;font-size:14px;line-height:1.5;margin-bottom:24px}
271
+ .modal-box a{display:block;padding:12px;border-radius:10px;text-decoration:none;font-weight:600;font-size:15px;margin-bottom:12px}
272
+ .mb-primary{background:#8B5CF6;color:#fff}
273
+ .mb-secondary{background:#1A1A2E;color:#FAFAFA;border:1px solid #2D2D44}
274
+ .modal-box a:hover{opacity:.85}
275
+ .modal-box button{background:none;border:none;color:#A1A1AA;cursor:pointer;font-size:13px;font-family:inherit}
276
+ </style>
277
+ </head>
278
+ <body>
279
+ <div class="card">
280
+ ${elementsHtml}
281
+ ${buttonsHtml}
282
+ </div>
283
+ <div class="foot">
284
+ <a href="https://farcaster.xyz">${FC_ICON} Farcaster</a>
285
+ </div>
286
+ <div class="modal" id="m" onclick="if(event.target===this)this.style.display='none'">
287
+ <div class="modal-box">
288
+ ${FC_ICON}
289
+ <h2>Open in Farcaster</h2>
290
+ <p>Sign up or sign in to interact with this snap.</p>
291
+ <a href="https://farcaster.xyz" class="mb-primary">Sign up</a>
292
+ <a href="https://farcaster.xyz/~/developers/snaps?url=${snapUrl}" class="mb-secondary">Have an account? Try it</a>
293
+ <button onclick="document.getElementById('m').style.display='none'">Dismiss</button>
294
+ </div>
295
+ </div>
296
+ <script>function showModal(){document.getElementById('m').style.display='flex'}</script>
297
+ </body>
298
+ </html>`;
299
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@farcaster/snap-hono",
3
- "version": "1.1.4",
3
+ "version": "1.1.6",
4
4
  "description": "Hono integration for Farcaster Snap servers",
5
5
  "repository": {
6
6
  "type": "git",
@@ -26,7 +26,7 @@
26
26
  },
27
27
  "license": "MIT",
28
28
  "dependencies": {
29
- "@farcaster/snap": "1.2.2"
29
+ "@farcaster/snap": "1.3.1"
30
30
  },
31
31
  "peerDependencies": {
32
32
  "hono": ">=4.0.0"
package/src/index.ts CHANGED
@@ -3,6 +3,7 @@ import { cors } from "hono/cors";
3
3
  import { MEDIA_TYPE, type SnapFunction } from "@farcaster/snap";
4
4
  import { parseRequest } from "@farcaster/snap/server";
5
5
  import { payloadToResponse, snapHeaders } from "./payloadToResponse";
6
+ import { renderSnapPage } from "./renderSnapPage";
6
7
 
7
8
  export type SnapHandlerOptions = {
8
9
  /**
@@ -18,10 +19,10 @@ export type SnapHandlerOptions = {
18
19
  skipJFSVerification?: boolean;
19
20
 
20
21
  /**
21
- * Visible message in the HTML page served on GET when the client does not request snap JSON.
22
- * @default "This is a Farcaster Snap server."
22
+ * Raw HTML string for the browser fallback page. When set, takes precedence
23
+ * over the default branded fallback.
23
24
  */
24
- fallbackText?: string;
25
+ fallbackHtml?: string;
25
26
  };
26
27
 
27
28
  /**
@@ -41,9 +42,6 @@ export function registerSnapHandler(
41
42
  options: SnapHandlerOptions = {},
42
43
  ): void {
43
44
  const path = options.path ?? "/";
44
- const fallbackText =
45
- options.fallbackText ??
46
- "This is a Farcaster Snap server. See <a href='https://snap.farcaster.xyz'>snap.farcaster.xyz</a> for more info.";
47
45
 
48
46
  app.use(path, cors({ origin: "*" }));
49
47
 
@@ -51,7 +49,9 @@ export function registerSnapHandler(
51
49
  const resourcePath = resourcePathFromRequest(c.req.url);
52
50
  const accept = c.req.header("Accept");
53
51
  if (!clientWantsSnapResponse(accept)) {
54
- return new Response(fallbackHtmlDocument(fallbackText), {
52
+ const fallbackHtml =
53
+ options.fallbackHtml ?? (await getFallbackHtml(c.req.raw, snapFn));
54
+ return new Response(fallbackHtml, {
55
55
  status: 200,
56
56
  headers: snapHeaders(resourcePath, "text/html", [
57
57
  MEDIA_TYPE,
@@ -64,6 +64,7 @@ export function registerSnapHandler(
64
64
  action: { type: "get" },
65
65
  request: c.req.raw,
66
66
  });
67
+
67
68
  return payloadToResponse(response, {
68
69
  resourcePath,
69
70
  mediaTypes: [MEDIA_TYPE, "text/html"],
@@ -116,26 +117,72 @@ function resourcePathFromRequest(url: string): string {
116
117
  return u.pathname + u.search;
117
118
  }
118
119
 
119
- function escapeHtml(text: string): string {
120
- return text
121
- .replace(/&/g, "&amp;")
122
- .replace(/</g, "&lt;")
123
- .replace(/>/g, "&gt;")
124
- .replace(/"/g, "&quot;")
125
- .replace(/'/g, "&#39;");
120
+ async function getFallbackHtml(
121
+ request: Request,
122
+ snapFn: SnapFunction,
123
+ ): Promise<string> {
124
+ try {
125
+ const snap = await snapFn({
126
+ action: { type: "get" },
127
+ request,
128
+ });
129
+ return renderSnapPage(snap, snapOriginFromRequest(request));
130
+ } catch {
131
+ return brandedFallbackHtml(snapOriginFromRequest(request));
132
+ }
126
133
  }
127
134
 
128
- function fallbackHtmlDocument(message: string): string {
129
- const body = escapeHtml(message);
135
+ const FARCASTER_ICON_SVG = `<svg aria-hidden="true" focusable="false" viewBox="0 0 520 457" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M519.801 0V61.6809H458.172V123.31H477.054V123.331H519.801V456.795H416.57L416.507 456.49L363.832 207.03C358.81 183.251 345.667 161.736 326.827 146.434C307.988 131.133 284.255 122.71 260.006 122.71H259.8C235.551 122.71 211.818 131.133 192.979 146.434C174.139 161.736 160.996 183.259 155.974 207.03L103.239 456.795H0V123.323H42.7471V123.31H61.6262V61.6809H0V0H519.801Z" fill="currentColor"/></svg>`;
136
+
137
+ function snapOriginFromRequest(request: Request): string {
138
+ const fromEnv = process.env.SNAP_PUBLIC_BASE_URL?.trim();
139
+ if (fromEnv) return fromEnv.replace(/\/$/, "");
140
+
141
+ const proto = request.headers.get("x-forwarded-proto")?.trim() || "https";
142
+ const host =
143
+ request.headers.get("x-forwarded-host")?.trim() ||
144
+ request.headers.get("host")?.trim();
145
+ if (host) return `${proto}://${host}`.replace(/\/$/, "");
146
+
147
+ return "https://snap.farcaster.xyz";
148
+ }
149
+
150
+ function brandedFallbackHtml(snapOrigin: string): string {
151
+ const snapUrl = encodeURIComponent(snapOrigin + "/");
152
+ const testUrl = `https://farcaster.xyz/~/developers/snaps?url=${snapUrl}`;
153
+
130
154
  return `<!DOCTYPE html>
131
155
  <html lang="en">
132
156
  <head>
133
157
  <meta charset="utf-8">
134
158
  <meta name="viewport" content="width=device-width, initial-scale=1">
135
159
  <title>Farcaster Snap</title>
160
+ <style>
161
+ *{margin:0;padding:0;box-sizing:border-box}
162
+ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#0A0A0A;color:#FAFAFA;min-height:100vh;display:flex;align-items:center;justify-content:center}
163
+ .c{text-align:center;max-width:400px;padding:48px 32px}
164
+ .logo{color:#8B5CF6;margin-bottom:24px}
165
+ .logo svg{width:48px;height:42px}
166
+ h1{font-size:24px;font-weight:700;margin-bottom:8px}
167
+ p{color:#A1A1AA;font-size:15px;line-height:1.5;margin-bottom:32px}
168
+ .btns{display:flex;flex-direction:column;gap:12px}
169
+ a{display:flex;align-items:center;justify-content:center;gap:8px;padding:12px 24px;border-radius:12px;font-size:15px;font-weight:600;text-decoration:none;transition:opacity .15s}
170
+ a:hover{opacity:.85}
171
+ .p{background:#8B5CF6;color:#fff}
172
+ .s{background:#1A1A2E;color:#FAFAFA;border:1px solid #2D2D44}
173
+ .s svg{width:20px;height:18px}
174
+ </style>
136
175
  </head>
137
176
  <body>
138
- <p>${body}</p>
177
+ <div class="c">
178
+ <div class="logo">${FARCASTER_ICON_SVG}</div>
179
+ <h1>Farcaster Snap</h1>
180
+ <p>This is a Farcaster Snap &mdash; an interactive embed that lives in the feed.</p>
181
+ <div class="btns">
182
+ <a href="${testUrl}" class="p">Test this snap</a>
183
+ <a href="https://farcaster.xyz" class="s">${FARCASTER_ICON_SVG} Sign up for Farcaster</a>
184
+ </div>
185
+ </div>
139
186
  </body>
140
187
  </html>`;
141
188
  }
@@ -0,0 +1,386 @@
1
+ import type { SnapResponse } from "@farcaster/snap";
2
+
3
+ const PALETTE: Record<string, string> = {
4
+ gray: "#8F8F8F",
5
+ blue: "#006BFF",
6
+ red: "#FC0036",
7
+ amber: "#FFAE00",
8
+ green: "#28A948",
9
+ teal: "#00AC96",
10
+ purple: "#8B5CF6",
11
+ pink: "#F32782",
12
+ };
13
+
14
+ const FC_ICON = `<svg viewBox="0 0 520 457" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M519.801 0V61.6809H458.172V123.31H477.054V123.331H519.801V456.795H416.57L416.507 456.49L363.832 207.03C358.81 183.251 345.667 161.736 326.827 146.434C307.988 131.133 284.255 122.71 260.006 122.71H259.8C235.551 122.71 211.818 131.133 192.979 146.434C174.139 161.736 160.996 183.259 155.974 207.03L103.239 456.795H0V123.323H42.7471V123.31H61.6262V61.6809H0V0H519.801Z" fill="currentColor"/></svg>`;
15
+
16
+ function esc(s: string): string {
17
+ return s
18
+ .replace(/&/g, "&amp;")
19
+ .replace(/</g, "&lt;")
20
+ .replace(/>/g, "&gt;")
21
+ .replace(/"/g, "&quot;");
22
+ }
23
+
24
+ function accentHex(accent: string | undefined): string {
25
+ return PALETTE[accent ?? "purple"] ?? PALETTE.purple!;
26
+ }
27
+
28
+ function colorHex(color: string | undefined, accent: string): string {
29
+ if (!color || color === "accent") return accent;
30
+ return PALETTE[color] ?? accent;
31
+ }
32
+
33
+ // ─── Element renderers ──────────────────────────────────
34
+
35
+ function renderElement(el: Record<string, unknown>, accent: string): string {
36
+ const type = el.type as string;
37
+ switch (type) {
38
+ case "text":
39
+ return renderText(el, accent);
40
+ case "image":
41
+ return renderImage(el);
42
+ case "grid":
43
+ return renderGrid(el);
44
+ case "progress":
45
+ return renderProgress(el, accent);
46
+ case "bar_chart":
47
+ return renderBarChart(el, accent);
48
+ case "list":
49
+ return renderList(el);
50
+ case "button_group":
51
+ return renderButtonGroup(el, accent);
52
+ case "slider":
53
+ return renderSlider(el, accent);
54
+ case "text_input":
55
+ return renderTextInput(el);
56
+ case "toggle":
57
+ return renderToggle(el, accent);
58
+ case "group":
59
+ return renderGroup(el, accent);
60
+ case "divider":
61
+ return `<hr style="border:none;border-top:1px solid #E5E7EB;margin:4px 0">`;
62
+ case "spacer": {
63
+ const sizes: Record<string, string> = {
64
+ small: "8px",
65
+ medium: "16px",
66
+ large: "24px",
67
+ };
68
+ return `<div style="height:${
69
+ sizes[(el.size as string) ?? "medium"] ?? "16px"
70
+ }"></div>`;
71
+ }
72
+ default:
73
+ return "";
74
+ }
75
+ }
76
+
77
+ function renderText(el: Record<string, unknown>, _accent: string): string {
78
+ const style = el.style as string;
79
+ const content = esc(el.content as string);
80
+ const align = (el.align as string) ?? "left";
81
+ const styles: Record<string, string> = {
82
+ title: "font-size:20px;font-weight:700;color:#111",
83
+ body: "font-size:15px;line-height:1.5;color:#374151",
84
+ caption: "font-size:13px;color:#9CA3AF",
85
+ label:
86
+ "font-size:13px;font-weight:600;color:#6B7280;text-transform:uppercase;letter-spacing:0.5px",
87
+ };
88
+ return `<div style="${
89
+ styles[style] ?? styles.body
90
+ };text-align:${align}">${content}</div>`;
91
+ }
92
+
93
+ function renderImage(el: Record<string, unknown>): string {
94
+ const url = esc(el.url as string);
95
+ const aspect = (el.aspect as string) ?? "16:9";
96
+ const [w, h] = aspect.split(":").map(Number);
97
+ const ratio = w && h ? `${w}/${h}` : "16/9";
98
+ return `<div style="aspect-ratio:${ratio};border-radius:8px;overflow:hidden;background:#F3F4F6"><img src="${url}" alt="${esc(
99
+ (el.alt as string) ?? "",
100
+ )}" style="width:100%;height:100%;object-fit:cover"></div>`;
101
+ }
102
+
103
+ function renderGrid(el: Record<string, unknown>): string {
104
+ const cols = el.cols as number;
105
+ const rows = el.rows as number;
106
+ const cells = el.cells as Array<{
107
+ row: number;
108
+ col: number;
109
+ color?: string;
110
+ content?: string;
111
+ }>;
112
+ const cellSize = (el.cellSize as string) ?? "auto";
113
+ const gap = (el.gap as string) ?? "small";
114
+ const gapPx: Record<string, string> = {
115
+ none: "0",
116
+ small: "2px",
117
+ medium: "4px",
118
+ };
119
+ const cellMap = new Map<string, (typeof cells)[0]>();
120
+ for (const c of cells) cellMap.set(`${c.row},${c.col}`, c);
121
+
122
+ let cellsHtml = "";
123
+ for (let r = 0; r < rows; r++) {
124
+ for (let c = 0; c < cols; c++) {
125
+ const cell = cellMap.get(`${r},${c}`);
126
+ const bg = cell?.color ?? "transparent";
127
+ const content = cell?.content ? esc(cell.content) : "";
128
+ const sq = cellSize === "square" ? "aspect-ratio:1;" : "";
129
+ cellsHtml += `<div style="${sq}background:${bg};display:flex;align-items:center;justify-content:center;font-size:11px;color:#fff;border-radius:2px">${content}</div>`;
130
+ }
131
+ }
132
+
133
+ return `<div style="display:grid;grid-template-columns:repeat(${cols},1fr);gap:${
134
+ gapPx[gap] ?? "2px"
135
+ }">${cellsHtml}</div>`;
136
+ }
137
+
138
+ function renderProgress(el: Record<string, unknown>, accent: string): string {
139
+ const value = el.value as number;
140
+ const max = el.max as number;
141
+ const label = el.label as string | undefined;
142
+ const color = colorHex(el.color as string | undefined, accent);
143
+ const pct = max > 0 ? Math.min(100, (value / max) * 100) : 0;
144
+ const labelHtml = label
145
+ ? `<div style="font-size:13px;color:#6B7280;margin-bottom:4px">${esc(
146
+ label,
147
+ )}</div>`
148
+ : "";
149
+ return `<div>${labelHtml}<div style="height:8px;background:#E5E7EB;border-radius:4px;overflow:hidden"><div style="height:100%;width:${pct}%;background:${color};border-radius:4px"></div></div></div>`;
150
+ }
151
+
152
+ function renderBarChart(el: Record<string, unknown>, accent: string): string {
153
+ const bars = el.bars as Array<{
154
+ label: string;
155
+ value: number;
156
+ color?: string;
157
+ }>;
158
+ const max =
159
+ (el.max as number | undefined) ?? Math.max(...bars.map((b) => b.value), 1);
160
+ const defaultColor = colorHex(el.color as string | undefined, accent);
161
+
162
+ let html = `<div style="display:flex;align-items:flex-end;gap:12px;height:120px">`;
163
+ for (const bar of bars) {
164
+ const color = bar.color ? PALETTE[bar.color] ?? defaultColor : defaultColor;
165
+ const pct = max > 0 ? (bar.value / max) * 100 : 0;
166
+ html += `<div style="flex:1;display:flex;flex-direction:column;align-items:center;height:100%;justify-content:flex-end">`;
167
+ html += `<div style="font-size:11px;color:#6B7280;margin-bottom:4px">${bar.value}</div>`;
168
+ html += `<div style="width:100%;height:${pct}%;background:${color};border-radius:4px 4px 0 0;min-height:4px"></div>`;
169
+ html += `<div style="font-size:11px;color:#9CA3AF;margin-top:4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100%">${esc(
170
+ bar.label,
171
+ )}</div>`;
172
+ html += `</div>`;
173
+ }
174
+ html += `</div>`;
175
+ return html;
176
+ }
177
+
178
+ function renderList(el: Record<string, unknown>): string {
179
+ const style = (el.style as string) ?? "ordered";
180
+ const items = el.items as Array<{
181
+ content: string;
182
+ trailing?: string;
183
+ }>;
184
+
185
+ let html = "";
186
+ for (let i = 0; i < items.length; i++) {
187
+ const item = items[i]!;
188
+ const prefix =
189
+ style === "ordered"
190
+ ? `<span style="color:#9CA3AF;min-width:20px">${i + 1}.</span>`
191
+ : style === "unordered"
192
+ ? `<span style="color:#9CA3AF;min-width:20px">&bull;</span>`
193
+ : "";
194
+ const trailing = item.trailing
195
+ ? `<span style="color:#9CA3AF;font-size:13px;white-space:nowrap">${esc(
196
+ item.trailing,
197
+ )}</span>`
198
+ : "";
199
+ html += `<div style="display:flex;align-items:center;gap:8px;padding:6px 0">${prefix}<span style="flex:1;font-size:14px;color:#374151">${esc(
200
+ item.content,
201
+ )}</span>${trailing}</div>`;
202
+ }
203
+ return `<div>${html}</div>`;
204
+ }
205
+
206
+ function renderButtonGroup(
207
+ el: Record<string, unknown>,
208
+ accent: string,
209
+ ): string {
210
+ const options = el.options as string[];
211
+ const layout = (el.style as string) ?? "row";
212
+ const dir = layout === "stack" ? "column" : "row";
213
+ let html = `<div style="display:flex;flex-direction:${dir};gap:8px">`;
214
+ for (const opt of options) {
215
+ html += `<button onclick="showModal()" style="flex:1;padding:10px 12px;border-radius:8px;border:1px solid #E5E7EB;background:#fff;font-size:14px;color:#374151;cursor:pointer;font-family:inherit">${esc(
216
+ opt,
217
+ )}</button>`;
218
+ }
219
+ html += `</div>`;
220
+ return html;
221
+ }
222
+
223
+ function renderSlider(el: Record<string, unknown>, accent: string): string {
224
+ const label = el.label as string | undefined;
225
+ const min = el.min as number;
226
+ const max = el.max as number;
227
+ const value = (el.value as number) ?? (min + max) / 2;
228
+ const minLabel = el.minLabel as string | undefined;
229
+ const maxLabel = el.maxLabel as string | undefined;
230
+
231
+ const labelHtml = label
232
+ ? `<div style="font-size:13px;color:#6B7280;margin-bottom:4px">${esc(
233
+ label,
234
+ )}</div>`
235
+ : "";
236
+ const minL = minLabel
237
+ ? `<span style="font-size:11px;color:#9CA3AF">${esc(minLabel)}</span>`
238
+ : "";
239
+ const maxL = maxLabel
240
+ ? `<span style="font-size:11px;color:#9CA3AF">${esc(maxLabel)}</span>`
241
+ : "";
242
+
243
+ return `<div>${labelHtml}<div style="display:flex;align-items:center;gap:8px">${minL}<input type="range" min="${min}" max="${max}" value="${value}" disabled style="flex:1;accent-color:${accent};opacity:0.7">${maxL}</div></div>`;
244
+ }
245
+
246
+ function renderTextInput(el: Record<string, unknown>): string {
247
+ const placeholder = esc((el.placeholder as string) ?? "");
248
+ return `<input type="text" placeholder="${placeholder}" disabled style="width:100%;padding:10px 12px;border-radius:8px;border:1px solid #E5E7EB;background:#F9FAFB;font-size:14px;color:#9CA3AF;font-family:inherit;box-sizing:border-box">`;
249
+ }
250
+
251
+ function renderToggle(el: Record<string, unknown>, accent: string): string {
252
+ const label = esc(el.label as string);
253
+ const value = el.value as boolean;
254
+ const bg = value ? accent : "#D1D5DB";
255
+ const tx = value ? "20px" : "2px";
256
+ return `<div style="display:flex;align-items:center;justify-content:space-between">
257
+ <span style="font-size:14px;color:#374151">${label}</span>
258
+ <div style="width:44px;height:24px;background:${bg};border-radius:12px;position:relative;opacity:0.7"><div style="width:20px;height:20px;background:#fff;border-radius:50%;position:absolute;top:2px;left:${tx};transition:left .2s"></div></div>
259
+ </div>`;
260
+ }
261
+
262
+ function renderGroup(el: Record<string, unknown>, accent: string): string {
263
+ const children = el.children as Array<Record<string, unknown>>;
264
+ let html = `<div style="display:flex;gap:12px">`;
265
+ for (const child of children) {
266
+ html += `<div style="flex:1">${renderElement(child, accent)}</div>`;
267
+ }
268
+ html += `</div>`;
269
+ return html;
270
+ }
271
+
272
+ // ─── Buttons ────────────────────────────────────────────
273
+
274
+ function renderButtons(
275
+ buttons: Array<Record<string, unknown>> | undefined,
276
+ layout: string | undefined,
277
+ accent: string,
278
+ ): string {
279
+ if (!buttons || buttons.length === 0) return "";
280
+
281
+ const dir =
282
+ layout === "row"
283
+ ? "flex-direction:row"
284
+ : layout === "grid"
285
+ ? "display:grid;grid-template-columns:1fr 1fr"
286
+ : "flex-direction:column";
287
+ const wrap =
288
+ layout === "row"
289
+ ? "display:flex;"
290
+ : layout === "grid"
291
+ ? ""
292
+ : "display:flex;";
293
+
294
+ let html = `<div style="${wrap}${dir};gap:8px;margin-top:12px">`;
295
+ for (let i = 0; i < buttons.length; i++) {
296
+ const btn = buttons[i]!;
297
+ const label = esc(btn.label as string);
298
+ const style = (btn.style as string) ?? (i === 0 ? "primary" : "secondary");
299
+ const bg = style === "primary" ? accent : "transparent";
300
+ const color = style === "primary" ? "#fff" : accent;
301
+ const border = style === "primary" ? "none" : `2px solid ${accent}`;
302
+ html += `<button onclick="showModal()" style="flex:1;padding:10px 16px;border-radius:10px;background:${bg};color:${color};border:${border};font-size:14px;font-weight:600;cursor:pointer;font-family:inherit">${label}</button>`;
303
+ }
304
+ html += `</div>`;
305
+ return html;
306
+ }
307
+
308
+ // ─── Main renderer ──────────────────────────────────────
309
+
310
+ export function renderSnapPage(snap: SnapResponse, snapOrigin: string): string {
311
+ const page = snap.page;
312
+ const accent = accentHex(page.theme?.accent);
313
+
314
+ // Extract title for <title> tag
315
+ const titleEl = page.elements.children.find(
316
+ (el) =>
317
+ el.type === "text" && (el as Record<string, unknown>).style === "title",
318
+ ) as Record<string, unknown> | undefined;
319
+ const pageTitle = titleEl ? esc(titleEl.content as string) : "Farcaster Snap";
320
+
321
+ const snapUrl = encodeURIComponent(snapOrigin + "/");
322
+
323
+ // Render elements
324
+ let elementsHtml = "";
325
+ for (const el of page.elements.children) {
326
+ elementsHtml += `<div style="margin-bottom:12px">${renderElement(
327
+ el as Record<string, unknown>,
328
+ accent,
329
+ )}</div>`;
330
+ }
331
+
332
+ // Render buttons
333
+ const buttonsHtml = renderButtons(
334
+ page.buttons as Array<Record<string, unknown>> | undefined,
335
+ page.button_layout as string | undefined,
336
+ accent,
337
+ );
338
+
339
+ return `<!DOCTYPE html>
340
+ <html lang="en">
341
+ <head>
342
+ <meta charset="utf-8">
343
+ <meta name="viewport" content="width=device-width, initial-scale=1">
344
+ <title>${pageTitle}</title>
345
+ <style>
346
+ *{margin:0;padding:0;box-sizing:border-box}
347
+ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#0A0A0A;min-height:100vh;display:flex;align-items:center;justify-content:center;flex-direction:column;padding:24px}
348
+ .card{background:#fff;border-radius:16px;max-width:420px;width:100%;padding:20px;box-shadow:0 4px 24px rgba(0,0,0,0.3)}
349
+ .foot{margin-top:16px;text-align:center}
350
+ .foot a{color:#8B5CF6;text-decoration:none;font-size:13px;display:inline-flex;align-items:center;gap:6px}
351
+ .foot a:hover{opacity:.8}
352
+ .foot svg{width:14px;height:12px}
353
+ .modal{display:none;position:fixed;inset:0;background:rgba(0,0,0,0.7);align-items:center;justify-content:center;z-index:99}
354
+ .modal-box{background:#1A1A2E;border-radius:16px;padding:32px;text-align:center;max-width:340px;width:90%}
355
+ .modal-box svg{width:40px;height:35px;color:#8B5CF6;margin-bottom:16px}
356
+ .modal-box h2{color:#FAFAFA;font-size:20px;margin-bottom:8px}
357
+ .modal-box p{color:#A1A1AA;font-size:14px;line-height:1.5;margin-bottom:24px}
358
+ .modal-box a{display:block;padding:12px;border-radius:10px;text-decoration:none;font-weight:600;font-size:15px;margin-bottom:12px}
359
+ .mb-primary{background:#8B5CF6;color:#fff}
360
+ .mb-secondary{background:#1A1A2E;color:#FAFAFA;border:1px solid #2D2D44}
361
+ .modal-box a:hover{opacity:.85}
362
+ .modal-box button{background:none;border:none;color:#A1A1AA;cursor:pointer;font-size:13px;font-family:inherit}
363
+ </style>
364
+ </head>
365
+ <body>
366
+ <div class="card">
367
+ ${elementsHtml}
368
+ ${buttonsHtml}
369
+ </div>
370
+ <div class="foot">
371
+ <a href="https://farcaster.xyz">${FC_ICON} Farcaster</a>
372
+ </div>
373
+ <div class="modal" id="m" onclick="if(event.target===this)this.style.display='none'">
374
+ <div class="modal-box">
375
+ ${FC_ICON}
376
+ <h2>Open in Farcaster</h2>
377
+ <p>Sign up or sign in to interact with this snap.</p>
378
+ <a href="https://farcaster.xyz" class="mb-primary">Sign up</a>
379
+ <a href="https://farcaster.xyz/~/developers/snaps?url=${snapUrl}" class="mb-secondary">Have an account? Try it</a>
380
+ <button onclick="document.getElementById('m').style.display='none'">Dismiss</button>
381
+ </div>
382
+ </div>
383
+ <script>function showModal(){document.getElementById('m').style.display='flex'}</script>
384
+ </body>
385
+ </html>`;
386
+ }