@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.
package/dist/og-image.js CHANGED
@@ -371,7 +371,7 @@ function mapElement(el, accent, imageMap) {
371
371
  return mapProgress(el, accent);
372
372
  case "list":
373
373
  return mapList(el);
374
- case "button_group":
374
+ case "toggle_group":
375
375
  return mapButtonGroup(el, accent);
376
376
  case "bar_chart":
377
377
  return mapBarChart(el, accent);
@@ -507,7 +507,7 @@ function estimateElementHeight(el, imageMap) {
507
507
  return estimateProgressHeight(el);
508
508
  case "list":
509
509
  return estimateListHeight(el);
510
- case "button_group":
510
+ case "toggle_group":
511
511
  return estimateButtonGroupHeight(el);
512
512
  case "bar_chart":
513
513
  return 100;
@@ -556,24 +556,46 @@ function estimateDefaultOgHeight(elements, imageMap, buttons, buttonLayout) {
556
556
  const outerH = 2 * OG_OUTER_MARGIN_PX + cardH + OG_HEIGHT_SAFETY_PX;
557
557
  return Math.min(OG_MAX_HEIGHT_PX, Math.max(OG_MIN_HEIGHT_PX, Math.ceil(outerH)));
558
558
  }
559
+ // ─── Spec helpers ─────────────────────────────────────
560
+ /** Walk the flat spec from root and collect top-level children as El objects for the OG renderer. */
561
+ function specToElementList(spec) {
562
+ const rootEl = spec.elements[spec.root];
563
+ if (!rootEl?.children)
564
+ return [];
565
+ return rootEl.children
566
+ .map((key) => {
567
+ const el = spec.elements[key];
568
+ if (!el)
569
+ return null;
570
+ return { type: el.type, ...el.props };
571
+ })
572
+ .filter((el) => el != null);
573
+ }
574
+ /** Extract button elements (type: "button") from the spec. */
575
+ function specToButtons(spec) {
576
+ return Object.values(spec.elements)
577
+ .filter((el) => el.type === "button")
578
+ .map((el) => ({ type: "button", ...el.props }));
579
+ }
559
580
  // ─── Main PNG renderer ─────────────────────────────────────
560
581
  export async function renderSnapPageToPng(snap, options) {
561
- const page = snap.page;
562
- const accent = accentHex(page.theme?.accent);
582
+ const accent = accentHex(snap.theme?.accent);
583
+ const spec = snap.ui;
584
+ const elements = specToElementList(spec);
585
+ const pageButtons = specToButtons(spec);
563
586
  // Pre-fetch all image URLs (SSRF-safe)
564
- const imageUrls = page.elements.children
587
+ const imageUrls = elements
565
588
  .filter((el) => el.type === "image")
566
589
  .map((el) => el.url);
567
590
  const unique = [...new Set(imageUrls)];
568
591
  const fetched = await Promise.all(unique.map(async (url) => [url, await safeFetchImage(url)]));
569
592
  const imageMap = new Map(fetched.filter(([, v]) => v != null));
570
- const buttonLayout = page.button_layout;
571
- const pageButtons = page.buttons ?? [];
572
593
  const W = options?.width ?? DEFAULT_OG_WIDTH_PX;
573
594
  const H = options?.height ??
574
- estimateDefaultOgHeight(page.elements.children, imageMap, pageButtons, buttonLayout);
575
- // Build element VNodes
576
- const elementNodes = page.elements.children
595
+ estimateDefaultOgHeight(elements, imageMap, pageButtons, "column");
596
+ // Build element VNodes (skip buttons — handled separately)
597
+ const elementNodes = elements
598
+ .filter((el) => el.type !== "button")
577
599
  .map((el) => mapElement(el, accent, imageMap))
578
600
  .filter((n) => n != null);
579
601
  // Build button VNodes
@@ -588,7 +610,7 @@ export async function renderSnapPageToPng(snap, options) {
588
610
  ? [
589
611
  h("div", {
590
612
  display: "flex",
591
- flexDirection: buttonLayout === "row" ? "row" : "column",
613
+ flexDirection: "column",
592
614
  gap: 8,
593
615
  marginTop: 12,
594
616
  width: OG_CARD_INNER_WIDTH_PX,
@@ -1,19 +1,19 @@
1
1
  import { MEDIA_TYPE, validateSnapResponse, snapResponseSchema, } from "@farcaster/snap";
2
+ import { snapJsonRenderCatalog } from "@farcaster/snap/ui";
2
3
  const DEFAULT_LINK_MEDIA_TYPES = [MEDIA_TYPE, "text/html"];
3
4
  export function payloadToResponse(payload, options = {}) {
4
5
  const resourcePath = options.resourcePath ?? "/";
5
6
  const mediaTypes = options.mediaTypes ?? [...DEFAULT_LINK_MEDIA_TYPES];
7
+ // Validate snap envelope (version, theme, effects, ui shape)
6
8
  const validation = validateSnapResponse(payload);
7
9
  if (!validation.valid) {
8
- return new Response(JSON.stringify({
9
- error: "invalid snap page",
10
- issues: validation.issues,
11
- }), {
12
- status: 400,
13
- headers: {
14
- "Content-Type": "application/json; charset=utf-8",
15
- },
16
- });
10
+ return errorResponse("invalid snap page", validation.issues);
11
+ }
12
+ // Validate ui against catalog (element types, props, actions)
13
+ const catalogResult = snapJsonRenderCatalog.validate(payload.ui);
14
+ if (!catalogResult.success) {
15
+ const issues = catalogResult.error?.issues ?? [];
16
+ return errorResponse("invalid snap ui", issues);
17
17
  }
18
18
  const finalized = snapResponseSchema.parse(payload);
19
19
  return new Response(JSON.stringify(finalized), {
@@ -23,6 +23,12 @@ export function payloadToResponse(payload, options = {}) {
23
23
  },
24
24
  });
25
25
  }
26
+ function errorResponse(error, issues) {
27
+ return new Response(JSON.stringify({ error, issues }), {
28
+ status: 400,
29
+ headers: { "Content-Type": "application/json; charset=utf-8" },
30
+ });
31
+ }
26
32
  export function snapHeaders(resourcePath, currentMediaType, availableMediaTypes) {
27
33
  return {
28
34
  "Content-Type": `${currentMediaType}; charset=utf-8`,
@@ -1,11 +1,7 @@
1
- import type { SnapHandlerResult } from "@farcaster/snap";
2
- type SnapPage = SnapHandlerResult["page"];
1
+ import type { SnapHandlerResult, SnapSpec } from "@farcaster/snap";
3
2
  export type RenderSnapPageOptions = {
4
- /** Absolute URL of the /~/og-image PNG route. */
5
3
  ogImageUrl?: string;
6
- /** Canonical pathname + search of the snap page (e.g. "/snap" or "/"). */
7
4
  resourcePath?: string;
8
- /** Optional og:site_name value (e.g. from SNAP_OG_SITE_NAME env). */
9
5
  siteName?: string;
10
6
  };
11
7
  type PageMeta = {
@@ -14,6 +10,6 @@ type PageMeta = {
14
10
  imageUrl?: string;
15
11
  imageAlt?: string;
16
12
  };
17
- export declare function extractPageMeta(page: SnapPage): PageMeta;
13
+ export declare function extractPageMeta(spec: SnapSpec): PageMeta;
18
14
  export declare function renderSnapPage(snap: SnapHandlerResult, snapOrigin: string, opts?: RenderSnapPageOptions): string;
19
15
  export {};
@@ -1,25 +1,22 @@
1
1
  import { DEFAULT_THEME_ACCENT, PALETTE_LIGHT_HEX, PALETTE_COLOR_ACCENT, } from "@farcaster/snap";
2
- export function extractPageMeta(page) {
2
+ export function extractPageMeta(spec) {
3
3
  let title = "Farcaster Snap";
4
4
  let description = "";
5
5
  let imageUrl;
6
6
  let imageAlt;
7
- for (const el of page.elements.children) {
8
- if (el.type === "text") {
9
- const style = el.style;
10
- const content = el.content;
11
- if (style === "title" && title === "Farcaster Snap" && content) {
12
- title = content;
7
+ for (const el of Object.values(spec.elements)) {
8
+ const e = el;
9
+ if (e.type === "item") {
10
+ if (title === "Farcaster Snap" && e.props?.title) {
11
+ title = String(e.props.title);
13
12
  }
14
- else if ((style === "body" || style === "caption") &&
15
- !description &&
16
- content) {
17
- description = content;
13
+ if (!description && e.props?.description) {
14
+ description = String(e.props.description);
18
15
  }
19
16
  }
20
- if (el.type === "image" && !imageUrl) {
21
- imageUrl = el.url;
22
- imageAlt = el.alt;
17
+ if (e.type === "image" && !imageUrl) {
18
+ imageUrl = e.props?.url ? String(e.props.url) : undefined;
19
+ imageAlt = e.props?.alt ? String(e.props.alt) : undefined;
23
20
  }
24
21
  }
25
22
  return {
@@ -73,214 +70,167 @@ function colorHex(color, accent) {
73
70
  return PALETTE_LIGHT_HEX[color] ?? accent;
74
71
  }
75
72
  // ─── Element renderers ──────────────────────────────────
76
- function renderElement(el, accent) {
73
+ function renderElement(key, spec, accent) {
74
+ const el = spec.elements[key];
75
+ if (!el)
76
+ return "";
77
+ const p = el.props ?? {};
77
78
  switch (el.type) {
78
- case "text":
79
- return renderText(el, accent);
80
- case "image":
81
- return renderImage(el);
82
- case "grid":
83
- return renderGrid(el);
84
- case "progress":
85
- return renderProgress(el, accent);
86
- case "bar_chart":
87
- return renderBarChart(el, accent);
88
- case "list":
89
- return renderList(el);
90
- case "button_group":
91
- return renderButtonGroup(el, accent);
92
- case "slider":
93
- return renderSlider(el, accent);
94
- case "text_input":
95
- return renderTextInput(el);
96
- case "toggle":
97
- return renderToggle(el, accent);
98
- case "group":
99
- return renderGroup(el, accent);
100
- case "divider":
79
+ case "icon": {
80
+ const color = colorHex(p.color, accent);
81
+ const size = String(p.size ?? "md") === "sm" ? 16 : 20;
82
+ // Simplified inline SVG for common icons; falls back to a circle for unknown
83
+ const name = String(p.name ?? "info");
84
+ const iconSvgs = {
85
+ check: `<polyline points="20 6 9 17 4 12"/>`,
86
+ x: `<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>`,
87
+ 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"/>`,
88
+ 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"/>`,
89
+ 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"/>`,
90
+ "arrow-right": `<line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/>`,
91
+ "chevron-right": `<polyline points="9 18 15 12 9 6"/>`,
92
+ "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"/>`,
93
+ zap: `<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>`,
94
+ 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"/>`,
95
+ clock: `<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>`,
96
+ };
97
+ const inner = iconSvgs[name] ?? `<circle cx="12" cy="12" r="4"/>`;
98
+ 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>`;
99
+ }
100
+ case "badge": {
101
+ const color = colorHex(p.color, accent);
102
+ 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>`;
103
+ }
104
+ case "image": {
105
+ const url = esc(String(p.url ?? ""));
106
+ const aspect = String(p.aspect ?? "16:9");
107
+ const [w, h] = aspect.split(":").map(Number);
108
+ const ratio = w && h ? `${w}/${h}` : "16/9";
109
+ 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>`;
110
+ }
111
+ case "item": {
112
+ const variant = String(p.variant ?? "default");
113
+ const variantStyles = {
114
+ default: "",
115
+ outline: "border:1px solid #E5E7EB;border-radius:8px;padding:12px;",
116
+ muted: "background:#F9FAFB;border-radius:8px;padding:12px;",
117
+ };
118
+ const descHtml = p.description ? `<div style="font-size:13px;color:#6B7280;margin-top:2px">${esc(String(p.description))}</div>` : "";
119
+ const childIds = el.children ?? [];
120
+ const actionsHtml = childIds.length > 0
121
+ ? `<div style="margin-left:auto;padding-left:12px;display:flex;align-items:center;gap:4px">${childIds.map((id) => renderElement(id, spec, accent)).join("")}</div>`
122
+ : "";
123
+ 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>`;
124
+ }
125
+ case "item_group": {
126
+ const childIds = el.children ?? [];
127
+ const border = Boolean(p.border);
128
+ const separator = Boolean(p.separator);
129
+ const outerStyle = border ? "border:1px solid #E5E7EB;border-radius:8px;overflow:hidden" : "";
130
+ let html = `<div style="display:flex;flex-direction:column;${outerStyle}">`;
131
+ for (let i = 0; i < childIds.length; i++) {
132
+ if (separator && i > 0) {
133
+ html += `<hr style="border:none;border-top:1px solid #E5E7EB;margin:0 12px">`;
134
+ }
135
+ const pad = border ? "padding:8px 12px;" : separator ? "padding:8px 0;" : "";
136
+ html += `<div style="${pad}">${renderElement(childIds[i], spec, accent)}</div>`;
137
+ }
138
+ html += `</div>`;
139
+ return html;
140
+ }
141
+ case "progress": {
142
+ const value = Number(p.value ?? 0);
143
+ const max = Number(p.max ?? 100);
144
+ const color = accent;
145
+ const pct = max > 0 ? Math.min(100, (value / max) * 100) : 0;
146
+ const labelHtml = p.label ? `<div style="font-size:13px;color:#6B7280;margin-bottom:4px">${esc(String(p.label))}</div>` : "";
147
+ 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>`;
148
+ }
149
+ case "separator": {
150
+ const orientation = String(p.orientation ?? "horizontal");
151
+ if (orientation === "vertical")
152
+ return `<div style="width:1px;background:#E5E7EB;align-self:stretch;min-height:16px"></div>`;
101
153
  return `<hr style="border:none;border-top:1px solid #E5E7EB;margin:4px 0">`;
102
- case "spacer":
103
- return renderSpacer(el);
154
+ }
155
+ case "slider": {
156
+ const min = Number(p.min ?? 0);
157
+ const max = Number(p.max ?? 100);
158
+ const value = p.defaultValue !== undefined ? Number(p.defaultValue) : (min + max) / 2;
159
+ const labelHtml = p.label ? `<div style="font-size:13px;font-weight:500;color:#374151;margin-bottom:4px">${esc(String(p.label))}</div>` : "";
160
+ return `<div>${labelHtml}<input type="range" min="${min}" max="${max}" value="${value}" disabled style="width:100%;accent-color:${accent};opacity:0.7"></div>`;
161
+ }
162
+ case "switch": {
163
+ const checked = Boolean(p.defaultChecked);
164
+ const bg = checked ? accent : "#D1D5DB";
165
+ const tx = checked ? "20px" : "2px";
166
+ 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>`;
167
+ }
168
+ case "input": {
169
+ const labelHtml = p.label ? `<label style="display:block;font-size:13px;font-weight:500;color:#374151;margin-bottom:4px">${esc(String(p.label))}</label>` : "";
170
+ 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>`;
171
+ }
172
+ case "toggle_group": {
173
+ const options = Array.isArray(p.options) ? p.options : [];
174
+ const orientation = String(p.orientation ?? "horizontal");
175
+ const dir = orientation === "vertical" ? "column" : "row";
176
+ const labelHtml = p.label ? `<div style="font-size:13px;font-weight:500;color:#374151;margin-bottom:4px">${esc(String(p.label))}</div>` : "";
177
+ let html = `<div>${labelHtml}<div style="display:flex;flex-direction:${dir};gap:4px;padding:4px;background:#F3F4F6;border-radius:8px">`;
178
+ for (const opt of options) {
179
+ 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>`;
180
+ }
181
+ html += `</div></div>`;
182
+ return html;
183
+ }
184
+ case "button": {
185
+ const variant = String(p.variant ?? "default");
186
+ const bg = variant === "default" ? accent : "transparent";
187
+ const color = variant === "default" ? "#fff" : accent;
188
+ const border = variant === "default" ? "none" : `2px solid ${accent}`;
189
+ const pad = variant === "default" ? "18px 16px" : "10px 16px";
190
+ const minH = variant === "default" ? "min-height:52px;" : "";
191
+ 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>`;
192
+ }
193
+ case "text": {
194
+ const size = String(p.size ?? "md");
195
+ const weight = String(p.weight ?? (size === "lg" ? "bold" : "normal"));
196
+ const align = String(p.align ?? "left");
197
+ const styles = {
198
+ lg: "font-size:20px",
199
+ md: "font-size:15px;line-height:1.5",
200
+ sm: "font-size:13px",
201
+ };
202
+ const weights = {
203
+ bold: "font-weight:700",
204
+ medium: "font-weight:500",
205
+ normal: "font-weight:400",
206
+ };
207
+ return `<div style="${styles[size] ?? styles.md};${weights[weight] ?? weights.normal};color:#374151;text-align:${align}">${esc(String(p.content ?? ""))}</div>`;
208
+ }
209
+ case "stack": {
210
+ const direction = String(p.direction ?? "vertical");
211
+ const gap = { none: "0", sm: "4px", md: "8px", lg: "16px" };
212
+ const gapVal = gap[String(p.gap ?? "md")] ?? "8px";
213
+ const dir = direction === "horizontal" ? "row" : "column";
214
+ const justifyMap = { start: "flex-start", center: "center", end: "flex-end", between: "space-between", around: "space-around" };
215
+ const jc = p.justify ? justifyMap[String(p.justify)] : undefined;
216
+ const childIds = el.children ?? [];
217
+ let html = `<div style="display:flex;flex-direction:${dir};gap:${gapVal}${jc ? `;justify-content:${jc}` : ""}">`;
218
+ for (const childKey of childIds) {
219
+ const flex = direction === "horizontal" ? "flex:1;" : "";
220
+ html += `<div style="${flex}">${renderElement(childKey, spec, accent)}</div>`;
221
+ }
222
+ html += `</div>`;
223
+ return html;
224
+ }
104
225
  default:
105
226
  return "";
106
227
  }
107
228
  }
108
- function renderText(el, _accent) {
109
- const style = el.style;
110
- const content = esc(el.content);
111
- const align = el.align ?? "left";
112
- const styles = {
113
- title: "font-size:20px;font-weight:700;color:#111",
114
- body: "font-size:15px;line-height:1.5;color:#374151",
115
- caption: "font-size:13px;color:#9CA3AF",
116
- label: "font-size:13px;font-weight:600;color:#6B7280;text-transform:uppercase;letter-spacing:0.5px",
117
- };
118
- return `<div style="${styles[style] ?? styles.body};text-align:${align}">${content}</div>`;
119
- }
120
- function renderImage(el) {
121
- const url = esc(el.url);
122
- const aspect = el.aspect ?? "16:9";
123
- const [w, h] = aspect.split(":").map(Number);
124
- const ratio = w && h ? `${w}/${h}` : "16/9";
125
- 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>`;
126
- }
127
- function renderGrid(el) {
128
- const { cols, rows, cells } = el;
129
- const cellSize = el.cellSize ?? "auto";
130
- const gap = el.gap ?? "small";
131
- const gapPx = {
132
- none: "0",
133
- small: "2px",
134
- medium: "4px",
135
- };
136
- const cellMap = new Map();
137
- for (const c of cells)
138
- cellMap.set(`${c.row},${c.col}`, c);
139
- let cellsHtml = "";
140
- for (let r = 0; r < rows; r++) {
141
- for (let c = 0; c < cols; c++) {
142
- const cell = cellMap.get(`${r},${c}`);
143
- const bg = cell?.color ?? "transparent";
144
- const content = cell?.content ? esc(cell.content) : "";
145
- const sq = cellSize === "square" ? "aspect-ratio:1;" : "";
146
- cellsHtml += `<div style="${sq}background:${bg};display:flex;align-items:center;justify-content:center;font-size:11px;color:#fff;border-radius:2px">${content}</div>`;
147
- }
148
- }
149
- return `<div style="display:grid;grid-template-columns:repeat(${cols},1fr);gap:${gapPx[gap] ?? "2px"}">${cellsHtml}</div>`;
150
- }
151
- function renderProgress(el, accent) {
152
- const { value, max, label } = el;
153
- const color = colorHex(el.color, accent);
154
- const pct = max > 0 ? Math.min(100, (value / max) * 100) : 0;
155
- const labelHtml = label
156
- ? `<div style="font-size:13px;color:#6B7280;margin-bottom:4px">${esc(label)}</div>`
157
- : "";
158
- 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>`;
159
- }
160
- function renderBarChart(el, accent) {
161
- const { bars } = el;
162
- const max = el.max ?? Math.max(...bars.map((b) => b.value), 1);
163
- const defaultColor = colorHex(el.color, accent);
164
- let html = `<div style="display:flex;align-items:flex-end;gap:12px;height:120px">`;
165
- for (const bar of bars) {
166
- const color = colorHex(bar.color, defaultColor);
167
- const pct = max > 0 ? (bar.value / max) * 100 : 0;
168
- html += `<div style="flex:1;display:flex;flex-direction:column;align-items:center;height:100%;justify-content:flex-end">`;
169
- html += `<div style="font-size:11px;color:#6B7280;margin-bottom:4px">${bar.value}</div>`;
170
- html += `<div style="width:100%;height:${pct}%;background:${color};border-radius:4px 4px 0 0;min-height:4px"></div>`;
171
- 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>`;
172
- html += `</div>`;
173
- }
174
- html += `</div>`;
175
- return html;
176
- }
177
- function renderList(el) {
178
- const style = el.style ?? "ordered";
179
- const { items } = el;
180
- let html = "";
181
- for (let i = 0; i < items.length; i++) {
182
- const item = items[i];
183
- const prefix = style === "ordered"
184
- ? `<span style="color:#9CA3AF;min-width:20px">${i + 1}.</span>`
185
- : style === "unordered"
186
- ? `<span style="color:#9CA3AF;min-width:20px">&bull;</span>`
187
- : "";
188
- const trailing = item.trailing
189
- ? `<span style="color:#9CA3AF;font-size:13px;white-space:nowrap">${esc(item.trailing)}</span>`
190
- : "";
191
- 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>`;
192
- }
193
- return `<div>${html}</div>`;
194
- }
195
- function renderButtonGroup(el, accent) {
196
- const { options } = el;
197
- const layout = el.style ?? "row";
198
- const dir = layout === "stack" ? "column" : "row";
199
- let html = `<div style="display:flex;flex-direction:${dir};gap:8px">`;
200
- for (const opt of options) {
201
- 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>`;
202
- }
203
- html += `</div>`;
204
- return html;
205
- }
206
- function renderSlider(el, accent) {
207
- const { label, min, max, minLabel, maxLabel } = el;
208
- const value = el.value ?? (min + max) / 2;
209
- const labelHtml = label
210
- ? `<div style="font-size:13px;color:#6B7280;margin-bottom:4px">${esc(label)}</div>`
211
- : "";
212
- const minL = minLabel
213
- ? `<span style="font-size:11px;color:#9CA3AF">${esc(minLabel)}</span>`
214
- : "";
215
- const maxL = maxLabel
216
- ? `<span style="font-size:11px;color:#9CA3AF">${esc(maxLabel)}</span>`
217
- : "";
218
- 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>`;
219
- }
220
- function renderTextInput(el) {
221
- const placeholder = esc(el.placeholder ?? "");
222
- 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">`;
223
- }
224
- function renderToggle(el, accent) {
225
- const label = esc(el.label);
226
- const { value } = el;
227
- const bg = value ? accent : "#D1D5DB";
228
- const tx = value ? "20px" : "2px";
229
- return `<div style="display:flex;align-items:center;justify-content:space-between">
230
- <span style="font-size:14px;color:#374151">${label}</span>
231
- <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>
232
- </div>`;
233
- }
234
- function renderSpacer(el) {
235
- const sizes = {
236
- small: "8px",
237
- medium: "16px",
238
- large: "24px",
239
- };
240
- return `<div style="height:${sizes[el.size ?? "medium"] ?? "16px"}"></div>`;
241
- }
242
- function renderGroup(el, accent) {
243
- let html = `<div style="display:flex;gap:12px">`;
244
- for (const child of el.children) {
245
- html += `<div style="flex:1">${renderElement(child, accent)}</div>`;
246
- }
247
- html += `</div>`;
248
- return html;
249
- }
250
- // ─── Buttons ────────────────────────────────────────────
251
- function renderButtons(buttons, layout, accent) {
252
- if (!buttons || buttons.length === 0)
253
- return "";
254
- const dir = layout === "row"
255
- ? "flex-direction:row"
256
- : layout === "grid"
257
- ? "display:grid;grid-template-columns:1fr 1fr"
258
- : "flex-direction:column";
259
- const wrap = layout === "row"
260
- ? "display:flex;"
261
- : layout === "grid"
262
- ? ""
263
- : "display:flex;";
264
- let html = `<div style="${wrap}${dir};gap:8px;margin-top:12px">`;
265
- for (let i = 0; i < buttons.length; i++) {
266
- const btn = buttons[i];
267
- const label = esc(btn.label);
268
- const style = btn.style ?? (i === 0 ? "primary" : "secondary");
269
- const bg = style === "primary" ? accent : "transparent";
270
- const color = style === "primary" ? "#fff" : accent;
271
- const border = style === "primary" ? "none" : `2px solid ${accent}`;
272
- const pad = style === "primary" ? "18px 16px" : "10px 16px";
273
- const minH = style === "primary" ? "min-height:52px;" : "";
274
- 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>`;
275
- }
276
- html += `</div>`;
277
- return html;
278
- }
279
229
  // ─── Main renderer ──────────────────────────────────────
280
230
  export function renderSnapPage(snap, snapOrigin, opts) {
281
- const page = snap.page;
282
- const accent = accentHex(page.theme?.accent);
283
- const meta = extractPageMeta(page);
231
+ const spec = snap.ui;
232
+ const accent = accentHex(snap.theme?.accent);
233
+ const meta = extractPageMeta(spec);
284
234
  const pageTitle = esc(meta.title);
285
235
  const resourcePath = opts?.resourcePath ?? "/";
286
236
  const pageUrl = snapOrigin.replace(/\/$/, "") + resourcePath;
@@ -293,13 +243,7 @@ export function renderSnapPage(snap, snapOrigin, opts) {
293
243
  siteName: opts?.siteName,
294
244
  });
295
245
  const snapUrl = encodeURIComponent(snapOrigin + "/");
296
- // Render elements
297
- let elementsHtml = "";
298
- for (const el of page.elements.children) {
299
- elementsHtml += `<div style="margin-bottom:12px">${renderElement(el, accent)}</div>`;
300
- }
301
- // Render buttons
302
- const buttonsHtml = renderButtons(page.buttons, page.button_layout, accent);
246
+ const bodyHtml = renderElement(spec.root, spec, accent);
303
247
  return `<!DOCTYPE html>
304
248
  <html lang="en">
305
249
  <head>
@@ -329,8 +273,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;b
329
273
  </head>
330
274
  <body>
331
275
  <div class="card">
332
- ${elementsHtml}
333
- ${buttonsHtml}
276
+ ${bodyHtml}
334
277
  </div>
335
278
  <div class="foot">
336
279
  <a href="https://farcaster.xyz">${FC_ICON} Farcaster</a>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@farcaster/snap-hono",
3
- "version": "1.3.2",
3
+ "version": "1.3.3",
4
4
  "description": "Hono integration for Farcaster Snap servers",
5
5
  "repository": {
6
6
  "type": "git",
@@ -28,7 +28,7 @@
28
28
  "dependencies": {
29
29
  "@resvg/resvg-wasm": "^2.6.2",
30
30
  "satori": "^0.10.0",
31
- "@farcaster/snap": "1.5.2"
31
+ "@farcaster/snap": "2.0.0"
32
32
  },
33
33
  "peerDependencies": {
34
34
  "hono": ">=4.0.0"