@farcaster/snap-hono 1.3.2 → 1.3.3

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.
@@ -1,6 +1,7 @@
1
1
  import type {
2
- SnapPageElementInput,
3
2
  SnapHandlerResult,
3
+ SnapSpec,
4
+ SnapUIElement,
4
5
  PaletteColor,
5
6
  } from "@farcaster/snap";
6
7
  import {
@@ -9,17 +10,11 @@ import {
9
10
  PALETTE_COLOR_ACCENT,
10
11
  } from "@farcaster/snap";
11
12
 
12
- type SnapPage = SnapHandlerResult["page"];
13
- type SnapPageButton = NonNullable<SnapPage["buttons"]>[number];
14
-
15
13
  // ─── OG meta ────────────────────────────────────────────
16
14
 
17
15
  export type RenderSnapPageOptions = {
18
- /** Absolute URL of the /~/og-image PNG route. */
19
16
  ogImageUrl?: string;
20
- /** Canonical pathname + search of the snap page (e.g. "/snap" or "/"). */
21
17
  resourcePath?: string;
22
- /** Optional og:site_name value (e.g. from SNAP_OG_SITE_NAME env). */
23
18
  siteName?: string;
24
19
  };
25
20
 
@@ -30,29 +25,25 @@ type PageMeta = {
30
25
  imageAlt?: string;
31
26
  };
32
27
 
33
- export function extractPageMeta(page: SnapPage): PageMeta {
28
+ export function extractPageMeta(spec: SnapSpec): PageMeta {
34
29
  let title = "Farcaster Snap";
35
30
  let description = "";
36
31
  let imageUrl: string | undefined;
37
32
  let imageAlt: string | undefined;
38
33
 
39
- for (const el of page.elements.children) {
40
- if (el.type === "text") {
41
- const style = el.style;
42
- const content = el.content;
43
- if (style === "title" && title === "Farcaster Snap" && content) {
44
- title = content;
45
- } else if (
46
- (style === "body" || style === "caption") &&
47
- !description &&
48
- content
49
- ) {
50
- description = content;
34
+ for (const el of Object.values(spec.elements)) {
35
+ const e = el as SnapUIElement;
36
+ if (e.type === "item") {
37
+ if (title === "Farcaster Snap" && e.props?.title) {
38
+ title = String(e.props.title);
39
+ }
40
+ if (!description && e.props?.description) {
41
+ description = String(e.props.description);
51
42
  }
52
43
  }
53
- if (el.type === "image" && !imageUrl) {
54
- imageUrl = el.url;
55
- imageAlt = el.alt;
44
+ if (e.type === "image" && !imageUrl) {
45
+ imageUrl = e.props?.url ? String(e.props.url) : undefined;
46
+ imageAlt = e.props?.alt ? String(e.props.alt) : undefined;
56
47
  }
57
48
  }
58
49
 
@@ -73,7 +64,6 @@ function buildOgMeta(opts: {
73
64
  siteName?: string;
74
65
  }): string {
75
66
  const { title, description, pageUrl, ogImageUrl, imageAlt, siteName } = opts;
76
-
77
67
  const imgUrl = ogImageUrl ?? undefined;
78
68
  const twitterCard = imgUrl ? "summary_large_image" : "summary";
79
69
 
@@ -89,24 +79,18 @@ function buildOgMeta(opts: {
89
79
  if (siteName) {
90
80
  lines.push(`<meta property="og:site_name" content="${esc(siteName)}">`);
91
81
  }
92
-
93
82
  if (imgUrl) {
94
83
  lines.push(`<meta property="og:image" content="${esc(imgUrl)}">`);
95
- lines.push(
96
- `<meta property="og:image:alt" content="${esc(imageAlt ?? title)}">`,
97
- );
84
+ lines.push(`<meta property="og:image:alt" content="${esc(imageAlt ?? title)}">`);
98
85
  }
99
-
100
86
  lines.push(
101
87
  `<meta name="twitter:card" content="${twitterCard}">`,
102
88
  `<meta name="twitter:title" content="${esc(title)}">`,
103
89
  `<meta name="twitter:description" content="${esc(description)}">`,
104
90
  );
105
-
106
91
  if (imgUrl) {
107
92
  lines.push(`<meta name="twitter:image" content="${esc(imgUrl)}">`);
108
93
  }
109
-
110
94
  return lines.join("\n");
111
95
  }
112
96
 
@@ -127,295 +111,173 @@ function accentHex(accent: PaletteColor | undefined): string {
127
111
  }
128
112
 
129
113
  function colorHex(
130
- color: PaletteColor | typeof PALETTE_COLOR_ACCENT | undefined,
114
+ color: string | undefined,
131
115
  accent: string,
132
116
  ): string {
133
117
  if (!color || color === PALETTE_COLOR_ACCENT) return accent;
134
- return PALETTE_LIGHT_HEX[color] ?? accent;
118
+ return (PALETTE_LIGHT_HEX as Record<string, string>)[color] ?? accent;
135
119
  }
136
120
 
137
121
  // ─── Element renderers ──────────────────────────────────
138
122
 
139
- function renderElement(el: SnapPageElementInput, accent: string): string {
140
- switch (el.type) {
141
- case "text":
142
- return renderText(el, accent);
143
- case "image":
144
- return renderImage(el);
145
- case "grid":
146
- return renderGrid(el);
147
- case "progress":
148
- return renderProgress(el, accent);
149
- case "bar_chart":
150
- return renderBarChart(el, accent);
151
- case "list":
152
- return renderList(el);
153
- case "button_group":
154
- return renderButtonGroup(el, accent);
155
- case "slider":
156
- return renderSlider(el, accent);
157
- case "text_input":
158
- return renderTextInput(el);
159
- case "toggle":
160
- return renderToggle(el, accent);
161
- case "group":
162
- return renderGroup(el, accent);
163
- case "divider":
164
- return `<hr style="border:none;border-top:1px solid #E5E7EB;margin:4px 0">`;
165
- case "spacer":
166
- return renderSpacer(el);
167
- default:
168
- return "";
169
- }
170
- }
171
-
172
- function renderText(
173
- el: Extract<SnapPageElementInput, { type: "text" }>,
174
- _accent: string,
175
- ): string {
176
- const style = el.style;
177
- const content = esc(el.content);
178
- const align = el.align ?? "left";
179
- const styles: Record<string, string> = {
180
- title: "font-size:20px;font-weight:700;color:#111",
181
- body: "font-size:15px;line-height:1.5;color:#374151",
182
- caption: "font-size:13px;color:#9CA3AF",
183
- label:
184
- "font-size:13px;font-weight:600;color:#6B7280;text-transform:uppercase;letter-spacing:0.5px",
185
- };
186
- return `<div style="${
187
- styles[style] ?? styles.body
188
- };text-align:${align}">${content}</div>`;
189
- }
190
-
191
- function renderImage(
192
- el: Extract<SnapPageElementInput, { type: "image" }>,
193
- ): string {
194
- const url = esc(el.url);
195
- const aspect = el.aspect ?? "16:9";
196
- const [w, h] = aspect.split(":").map(Number);
197
- const ratio = w && h ? `${w}/${h}` : "16/9";
198
- return `<div style="aspect-ratio:${ratio};border-radius:8px;overflow:hidden;background:#F3F4F6"><img src="${url}" alt="${esc(
199
- el.alt ?? "",
200
- )}" style="width:100%;height:100%;object-fit:cover"></div>`;
201
- }
202
-
203
- function renderGrid(
204
- el: Extract<SnapPageElementInput, { type: "grid" }>,
205
- ): string {
206
- const { cols, rows, cells } = el;
207
- const cellSize = el.cellSize ?? "auto";
208
- const gap = el.gap ?? "small";
209
- const gapPx: Record<string, string> = {
210
- none: "0",
211
- small: "2px",
212
- medium: "4px",
213
- };
214
- const cellMap = new Map<string, (typeof cells)[0]>();
215
- for (const c of cells) cellMap.set(`${c.row},${c.col}`, c);
216
-
217
- let cellsHtml = "";
218
- for (let r = 0; r < rows; r++) {
219
- for (let c = 0; c < cols; c++) {
220
- const cell = cellMap.get(`${r},${c}`);
221
- const bg = cell?.color ?? "transparent";
222
- const content = cell?.content ? esc(cell.content) : "";
223
- const sq = cellSize === "square" ? "aspect-ratio:1;" : "";
224
- cellsHtml += `<div style="${sq}background:${bg};display:flex;align-items:center;justify-content:center;font-size:11px;color:#fff;border-radius:2px">${content}</div>`;
225
- }
226
- }
227
-
228
- return `<div style="display:grid;grid-template-columns:repeat(${cols},1fr);gap:${
229
- gapPx[gap] ?? "2px"
230
- }">${cellsHtml}</div>`;
231
- }
232
-
233
- function renderProgress(
234
- el: Extract<SnapPageElementInput, { type: "progress" }>,
235
- accent: string,
236
- ): string {
237
- const { value, max, label } = el;
238
- const color = colorHex(el.color, accent);
239
- const pct = max > 0 ? Math.min(100, (value / max) * 100) : 0;
240
- const labelHtml = label
241
- ? `<div style="font-size:13px;color:#6B7280;margin-bottom:4px">${esc(
242
- label,
243
- )}</div>`
244
- : "";
245
- 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>`;
246
- }
247
-
248
- function renderBarChart(
249
- el: Extract<SnapPageElementInput, { type: "bar_chart" }>,
123
+ function renderElement(
124
+ key: string,
125
+ spec: SnapSpec,
250
126
  accent: string,
251
127
  ): string {
252
- const { bars } = el;
253
- const max =
254
- el.max ?? Math.max(...bars.map((b: { value: number }) => b.value), 1);
255
- const defaultColor = colorHex(el.color, accent);
256
-
257
- let html = `<div style="display:flex;align-items:flex-end;gap:12px;height:120px">`;
258
- for (const bar of bars) {
259
- const color = colorHex(bar.color, defaultColor);
260
- const pct = max > 0 ? (bar.value / max) * 100 : 0;
261
- html += `<div style="flex:1;display:flex;flex-direction:column;align-items:center;height:100%;justify-content:flex-end">`;
262
- html += `<div style="font-size:11px;color:#6B7280;margin-bottom:4px">${bar.value}</div>`;
263
- html += `<div style="width:100%;height:${pct}%;background:${color};border-radius:4px 4px 0 0;min-height:4px"></div>`;
264
- html += `<div style="font-size:11px;color:#9CA3AF;margin-top:4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100%">${esc(
265
- bar.label,
266
- )}</div>`;
267
- html += `</div>`;
268
- }
269
- html += `</div>`;
270
- return html;
271
- }
272
-
273
- function renderList(
274
- el: Extract<SnapPageElementInput, { type: "list" }>,
275
- ): string {
276
- const style = el.style ?? "ordered";
277
- const { items } = el;
128
+ const el = spec.elements[key] as SnapUIElement | undefined;
129
+ if (!el) return "";
130
+ const p = el.props ?? {};
278
131
 
279
- let html = "";
280
- for (let i = 0; i < items.length; i++) {
281
- const item = items[i]!;
282
- const prefix =
283
- style === "ordered"
284
- ? `<span style="color:#9CA3AF;min-width:20px">${i + 1}.</span>`
285
- : style === "unordered"
286
- ? `<span style="color:#9CA3AF;min-width:20px">&bull;</span>`
132
+ switch (el.type) {
133
+ case "icon": {
134
+ const color = colorHex(p.color as string | undefined, accent);
135
+ const size = String(p.size ?? "md") === "sm" ? 16 : 20;
136
+ // Simplified inline SVG for common icons; falls back to a circle for unknown
137
+ const name = String(p.name ?? "info");
138
+ const iconSvgs: Record<string, string> = {
139
+ check: `<polyline points="20 6 9 17 4 12"/>`,
140
+ x: `<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>`,
141
+ heart: `<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/>`,
142
+ star: `<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>`,
143
+ info: `<circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/>`,
144
+ "arrow-right": `<line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/>`,
145
+ "chevron-right": `<polyline points="9 18 15 12 9 6"/>`,
146
+ "external-link": `<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/>`,
147
+ zap: `<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>`,
148
+ user: `<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>`,
149
+ clock: `<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>`,
150
+ };
151
+ const inner = iconSvgs[name] ?? `<circle cx="12" cy="12" r="4"/>`;
152
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="${color}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:inline-block;vertical-align:middle">${inner}</svg>`;
153
+ }
154
+ case "badge": {
155
+ const color = colorHex(p.color as string | undefined, accent);
156
+ return `<span style="display:inline-block;padding:2px 10px;border-radius:9999px;font-size:12px;font-weight:500;line-height:1.5;background:${color};color:#fff">${esc(String(p.label ?? ""))}</span>`;
157
+ }
158
+ case "image": {
159
+ const url = esc(String(p.url ?? ""));
160
+ const aspect = String(p.aspect ?? "16:9");
161
+ const [w, h] = aspect.split(":").map(Number);
162
+ const ratio = w && h ? `${w}/${h}` : "16/9";
163
+ return `<div style="aspect-ratio:${ratio};border-radius:8px;overflow:hidden;background:#F3F4F6"><img src="${url}" alt="${esc(String(p.alt ?? ""))}" style="width:100%;height:100%;object-fit:cover"></div>`;
164
+ }
165
+ case "item": {
166
+ const variant = String(p.variant ?? "default");
167
+ const variantStyles: Record<string, string> = {
168
+ default: "",
169
+ outline: "border:1px solid #E5E7EB;border-radius:8px;padding:12px;",
170
+ muted: "background:#F9FAFB;border-radius:8px;padding:12px;",
171
+ };
172
+ const descHtml = p.description ? `<div style="font-size:13px;color:#6B7280;margin-top:2px">${esc(String(p.description))}</div>` : "";
173
+ const childIds = el.children ?? [];
174
+ const actionsHtml = childIds.length > 0
175
+ ? `<div style="margin-left:auto;padding-left:12px;display:flex;align-items:center;gap:4px">${childIds.map((id) => renderElement(id, spec, accent)).join("")}</div>`
287
176
  : "";
288
- const trailing = item.trailing
289
- ? `<span style="color:#9CA3AF;font-size:13px;white-space:nowrap">${esc(
290
- item.trailing,
291
- )}</span>`
292
- : "";
293
- html += `<div style="display:flex;align-items:center;gap:8px;padding:6px 0">${prefix}<span style="flex:1;font-size:14px;color:#374151">${esc(
294
- item.content,
295
- )}</span>${trailing}</div>`;
296
- }
297
- return `<div>${html}</div>`;
298
- }
299
-
300
- function renderButtonGroup(
301
- el: Extract<SnapPageElementInput, { type: "button_group" }>,
302
- accent: string,
303
- ): string {
304
- const { options } = el;
305
- const layout = el.style ?? "row";
306
- const dir = layout === "stack" ? "column" : "row";
307
- let html = `<div style="display:flex;flex-direction:${dir};gap:8px">`;
308
- for (const opt of options) {
309
- 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(
310
- opt,
311
- )}</button>`;
312
- }
313
- html += `</div>`;
314
- return html;
315
- }
316
-
317
- function renderSlider(
318
- el: Extract<SnapPageElementInput, { type: "slider" }>,
319
- accent: string,
320
- ): string {
321
- const { label, min, max, minLabel, maxLabel } = el;
322
- const value = el.value ?? (min + max) / 2;
323
-
324
- const labelHtml = label
325
- ? `<div style="font-size:13px;color:#6B7280;margin-bottom:4px">${esc(
326
- label,
327
- )}</div>`
328
- : "";
329
- const minL = minLabel
330
- ? `<span style="font-size:11px;color:#9CA3AF">${esc(minLabel)}</span>`
331
- : "";
332
- const maxL = maxLabel
333
- ? `<span style="font-size:11px;color:#9CA3AF">${esc(maxLabel)}</span>`
334
- : "";
335
-
336
- 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>`;
337
- }
338
-
339
- function renderTextInput(
340
- el: Extract<SnapPageElementInput, { type: "text_input" }>,
341
- ): string {
342
- const placeholder = esc(el.placeholder ?? "");
343
- 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">`;
344
- }
345
-
346
- function renderToggle(
347
- el: Extract<SnapPageElementInput, { type: "toggle" }>,
348
- accent: string,
349
- ): string {
350
- const label = esc(el.label);
351
- const { value } = el;
352
- const bg = value ? accent : "#D1D5DB";
353
- const tx = value ? "20px" : "2px";
354
- return `<div style="display:flex;align-items:center;justify-content:space-between">
355
- <span style="font-size:14px;color:#374151">${label}</span>
356
- <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>
357
- </div>`;
358
- }
359
-
360
- function renderSpacer(
361
- el: Extract<SnapPageElementInput, { type: "spacer" }>,
362
- ): string {
363
- const sizes: Record<string, string> = {
364
- small: "8px",
365
- medium: "16px",
366
- large: "24px",
367
- };
368
- return `<div style="height:${sizes[el.size ?? "medium"] ?? "16px"}"></div>`;
369
- }
370
-
371
- function renderGroup(
372
- el: Extract<SnapPageElementInput, { type: "group" }>,
373
- accent: string,
374
- ): string {
375
- let html = `<div style="display:flex;gap:12px">`;
376
- for (const child of el.children) {
377
- html += `<div style="flex:1">${renderElement(child, accent)}</div>`;
378
- }
379
- html += `</div>`;
380
- return html;
381
- }
382
-
383
- // ─── Buttons ────────────────────────────────────────────
384
-
385
- function renderButtons(
386
- buttons: SnapPage["buttons"],
387
- layout: SnapPage["button_layout"],
388
- accent: string,
389
- ): string {
390
- if (!buttons || buttons.length === 0) return "";
391
-
392
- const dir =
393
- layout === "row"
394
- ? "flex-direction:row"
395
- : layout === "grid"
396
- ? "display:grid;grid-template-columns:1fr 1fr"
397
- : "flex-direction:column";
398
- const wrap =
399
- layout === "row"
400
- ? "display:flex;"
401
- : layout === "grid"
402
- ? ""
403
- : "display:flex;";
404
-
405
- let html = `<div style="${wrap}${dir};gap:8px;margin-top:12px">`;
406
- for (let i = 0; i < buttons.length; i++) {
407
- const btn: SnapPageButton = buttons[i]!;
408
- const label = esc(btn.label);
409
- const style = btn.style ?? (i === 0 ? "primary" : "secondary");
410
- const bg = style === "primary" ? accent : "transparent";
411
- const color = style === "primary" ? "#fff" : accent;
412
- const border = style === "primary" ? "none" : `2px solid ${accent}`;
413
- const pad = style === "primary" ? "18px 16px" : "10px 16px";
414
- const minH = style === "primary" ? "min-height:52px;" : "";
415
- html += `<button onclick="showModal()" style="flex:1;${minH}padding:${pad};border-radius:10px;background:${bg};color:${color};border:${border};font-size:14px;font-weight:600;cursor:pointer;font-family:inherit;box-sizing:border-box">${label}</button>`;
177
+ return `<div style="display:flex;align-items:flex-start;${variantStyles[variant] ?? ""}"><div style="flex:1;min-width:0"><div style="font-size:15px;font-weight:500;color:#111">${esc(String(p.title ?? ""))}</div>${descHtml}</div>${actionsHtml}</div>`;
178
+ }
179
+ case "item_group": {
180
+ const childIds = el.children ?? [];
181
+ const border = Boolean(p.border);
182
+ const separator = Boolean(p.separator);
183
+ const outerStyle = border ? "border:1px solid #E5E7EB;border-radius:8px;overflow:hidden" : "";
184
+ let html = `<div style="display:flex;flex-direction:column;${outerStyle}">`;
185
+ for (let i = 0; i < childIds.length; i++) {
186
+ if (separator && i > 0) {
187
+ html += `<hr style="border:none;border-top:1px solid #E5E7EB;margin:0 12px">`;
188
+ }
189
+ const pad = border ? "padding:8px 12px;" : separator ? "padding:8px 0;" : "";
190
+ html += `<div style="${pad}">${renderElement(childIds[i]!, spec, accent)}</div>`;
191
+ }
192
+ html += `</div>`;
193
+ return html;
194
+ }
195
+ case "progress": {
196
+ const value = Number(p.value ?? 0);
197
+ const max = Number(p.max ?? 100);
198
+ const color = accent;
199
+ const pct = max > 0 ? Math.min(100, (value / max) * 100) : 0;
200
+ const labelHtml = p.label ? `<div style="font-size:13px;color:#6B7280;margin-bottom:4px">${esc(String(p.label))}</div>` : "";
201
+ 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>`;
202
+ }
203
+ case "separator": {
204
+ const orientation = String(p.orientation ?? "horizontal");
205
+ if (orientation === "vertical") return `<div style="width:1px;background:#E5E7EB;align-self:stretch;min-height:16px"></div>`;
206
+ return `<hr style="border:none;border-top:1px solid #E5E7EB;margin:4px 0">`;
207
+ }
208
+ case "slider": {
209
+ const min = Number(p.min ?? 0);
210
+ const max = Number(p.max ?? 100);
211
+ const value = p.defaultValue !== undefined ? Number(p.defaultValue) : (min + max) / 2;
212
+ const labelHtml = p.label ? `<div style="font-size:13px;font-weight:500;color:#374151;margin-bottom:4px">${esc(String(p.label))}</div>` : "";
213
+ return `<div>${labelHtml}<input type="range" min="${min}" max="${max}" value="${value}" disabled style="width:100%;accent-color:${accent};opacity:0.7"></div>`;
214
+ }
215
+ case "switch": {
216
+ const checked = Boolean(p.defaultChecked);
217
+ const bg = checked ? accent : "#D1D5DB";
218
+ const tx = checked ? "20px" : "2px";
219
+ return `<div style="display:flex;align-items:center;justify-content:space-between"><span style="font-size:14px;color:#374151">${esc(String(p.label ?? ""))}</span><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}"></div></div></div>`;
220
+ }
221
+ case "input": {
222
+ const labelHtml = p.label ? `<label style="display:block;font-size:13px;font-weight:500;color:#374151;margin-bottom:4px">${esc(String(p.label))}</label>` : "";
223
+ return `<div>${labelHtml}<input type="text" placeholder="${esc(String(p.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"></div>`;
224
+ }
225
+ case "toggle_group": {
226
+ const options = Array.isArray(p.options) ? p.options as string[] : [];
227
+ const orientation = String(p.orientation ?? "horizontal");
228
+ const dir = orientation === "vertical" ? "column" : "row";
229
+ const labelHtml = p.label ? `<div style="font-size:13px;font-weight:500;color:#374151;margin-bottom:4px">${esc(String(p.label))}</div>` : "";
230
+ let html = `<div>${labelHtml}<div style="display:flex;flex-direction:${dir};gap:4px;padding:4px;background:#F3F4F6;border-radius:8px">`;
231
+ for (const opt of options) {
232
+ html += `<button onclick="showModal()" style="flex:1;padding:8px 12px;border-radius:6px;border:none;background:#F3F4F6;font-size:13px;color:#374151;cursor:pointer;font-family:inherit">${esc(opt)}</button>`;
233
+ }
234
+ html += `</div></div>`;
235
+ return html;
236
+ }
237
+ case "button": {
238
+ const variant = String(p.variant ?? "default");
239
+ const bg = variant === "default" ? accent : "transparent";
240
+ const color = variant === "default" ? "#fff" : accent;
241
+ const border = variant === "default" ? "none" : `2px solid ${accent}`;
242
+ const pad = variant === "default" ? "18px 16px" : "10px 16px";
243
+ const minH = variant === "default" ? "min-height:52px;" : "";
244
+ return `<button onclick="showModal()" style="width:100%;${minH}padding:${pad};border-radius:10px;background:${bg};color:${color};border:${border};font-size:14px;font-weight:600;cursor:pointer;font-family:inherit;box-sizing:border-box">${esc(String(p.label ?? ""))}</button>`;
245
+ }
246
+ case "text": {
247
+ const size = String(p.size ?? "md");
248
+ const weight = String(p.weight ?? (size === "lg" ? "bold" : "normal"));
249
+ const align = String(p.align ?? "left");
250
+ const styles: Record<string, string> = {
251
+ lg: "font-size:20px",
252
+ md: "font-size:15px;line-height:1.5",
253
+ sm: "font-size:13px",
254
+ };
255
+ const weights: Record<string, string> = {
256
+ bold: "font-weight:700",
257
+ medium: "font-weight:500",
258
+ normal: "font-weight:400",
259
+ };
260
+ return `<div style="${styles[size] ?? styles.md};${weights[weight] ?? weights.normal};color:#374151;text-align:${align}">${esc(String(p.content ?? ""))}</div>`;
261
+ }
262
+ case "stack": {
263
+ const direction = String(p.direction ?? "vertical");
264
+ const gap: Record<string, string> = { none: "0", sm: "4px", md: "8px", lg: "16px" };
265
+ const gapVal = gap[String(p.gap ?? "md")] ?? "8px";
266
+ const dir = direction === "horizontal" ? "row" : "column";
267
+ const justifyMap: Record<string, string> = { start: "flex-start", center: "center", end: "flex-end", between: "space-between", around: "space-around" };
268
+ const jc = p.justify ? justifyMap[String(p.justify)] : undefined;
269
+ const childIds = el.children ?? [];
270
+ let html = `<div style="display:flex;flex-direction:${dir};gap:${gapVal}${jc ? `;justify-content:${jc}` : ""}">`;
271
+ for (const childKey of childIds) {
272
+ const flex = direction === "horizontal" ? "flex:1;" : "";
273
+ html += `<div style="${flex}">${renderElement(childKey, spec, accent)}</div>`;
274
+ }
275
+ html += `</div>`;
276
+ return html;
277
+ }
278
+ default:
279
+ return "";
416
280
  }
417
- html += `</div>`;
418
- return html;
419
281
  }
420
282
 
421
283
  // ─── Main renderer ──────────────────────────────────────
@@ -425,10 +287,10 @@ export function renderSnapPage(
425
287
  snapOrigin: string,
426
288
  opts?: RenderSnapPageOptions,
427
289
  ): string {
428
- const page = snap.page;
429
- const accent = accentHex(page.theme?.accent);
290
+ const spec = snap.ui as unknown as SnapSpec;
291
+ const accent = accentHex(snap.theme?.accent);
430
292
 
431
- const meta = extractPageMeta(page);
293
+ const meta = extractPageMeta(spec);
432
294
  const pageTitle = esc(meta.title);
433
295
  const resourcePath = opts?.resourcePath ?? "/";
434
296
  const pageUrl = snapOrigin.replace(/\/$/, "") + resourcePath;
@@ -442,18 +304,7 @@ export function renderSnapPage(
442
304
  });
443
305
 
444
306
  const snapUrl = encodeURIComponent(snapOrigin + "/");
445
-
446
- // Render elements
447
- let elementsHtml = "";
448
- for (const el of page.elements.children) {
449
- elementsHtml += `<div style="margin-bottom:12px">${renderElement(
450
- el,
451
- accent,
452
- )}</div>`;
453
- }
454
-
455
- // Render buttons
456
- const buttonsHtml = renderButtons(page.buttons, page.button_layout, accent);
307
+ const bodyHtml = renderElement(spec.root, spec, accent);
457
308
 
458
309
  return `<!DOCTYPE html>
459
310
  <html lang="en">
@@ -484,8 +335,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;b
484
335
  </head>
485
336
  <body>
486
337
  <div class="card">
487
- ${elementsHtml}
488
- ${buttonsHtml}
338
+ ${bodyHtml}
489
339
  </div>
490
340
  <div class="foot">
491
341
  <a href="https://farcaster.xyz">${FC_ICON} Farcaster</a>