@farcaster/snap-hono 1.4.1 → 1.4.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/index.js CHANGED
@@ -1,11 +1,10 @@
1
1
  import { cors } from "hono/cors";
2
- import { createDefaultDataStore, MEDIA_TYPE, ACTION_TYPE_GET, } from "@farcaster/snap";
2
+ import { MEDIA_TYPE, ACTION_TYPE_GET, } from "@farcaster/snap";
3
3
  import { parseRequest } from "@farcaster/snap/server";
4
4
  import { brandedFallbackHtml } from "./fallback.js";
5
5
  import { payloadToResponse, snapHeaders } from "./payloadToResponse.js";
6
6
  import { renderSnapPage } from "./renderSnapPage.js";
7
7
  import { renderSnapPageToPng, renderWithDedup, etagForPage, } from "./og-image.js";
8
- const defaultData = createDefaultDataStore();
9
8
  /**
10
9
  * Register GET and POST snap handlers on `app` at `options.path` (default `/`).
11
10
  *
@@ -32,7 +31,6 @@ export function registerSnapHandler(app, snapFn, options = {}) {
32
31
  const snap = await snapFn({
33
32
  action: { type: ACTION_TYPE_GET },
34
33
  request: stripAuthHeaders(c.req.raw),
35
- data: defaultData,
36
34
  });
37
35
  const snapJson = JSON.stringify(snap);
38
36
  const etag = etagForPage(snapJson);
@@ -107,7 +105,6 @@ export function registerSnapHandler(app, snapFn, options = {}) {
107
105
  const response = await snapFn({
108
106
  action: { type: ACTION_TYPE_GET },
109
107
  request: c.req.raw,
110
- data: defaultData,
111
108
  });
112
109
  return payloadToResponse(response, {
113
110
  resourcePath,
@@ -142,7 +139,6 @@ export function registerSnapHandler(app, snapFn, options = {}) {
142
139
  const response = await snapFn({
143
140
  action: parsed.action,
144
141
  request: raw,
145
- data: defaultData,
146
142
  });
147
143
  return payloadToResponse(response, {
148
144
  resourcePath: resourcePathFromRequest(raw.url),
@@ -186,7 +182,6 @@ async function getFallbackHtml(request, snapFn, ogImageUrl) {
186
182
  const snap = await snapFn({
187
183
  action: { type: ACTION_TYPE_GET },
188
184
  request: stripAuthHeaders(request),
189
- data: defaultData,
190
185
  });
191
186
  return renderSnapPage(snap, origin, { ogImageUrl, resourcePath, siteName });
192
187
  }
@@ -203,7 +198,7 @@ function snapOriginFromRequest(request) {
203
198
  request.headers.get("host")?.trim();
204
199
  if (host)
205
200
  return `${proto}://${host}`.replace(/\/$/, "");
206
- return "https://snap.farcaster.xyz";
201
+ return "https://docs.farcaster.xyz/snap";
207
202
  }
208
203
  function clientWantsSnapResponse(accept) {
209
204
  if (!accept || accept.trim() === "")
package/dist/og-image.js CHANGED
@@ -169,37 +169,66 @@ function colorHex(color, accent) {
169
169
  return PALETTE_LIGHT_HEX[color] ?? accent;
170
170
  }
171
171
  function mapText(el) {
172
- const style = el.style;
172
+ const size = String(el.size ?? "md");
173
+ const weight = String(el.weight ?? "normal");
173
174
  const align = el.align ?? "left";
174
175
  let content = String(el.content ?? "");
175
- // Inter WOFF subset: normalize arrows / punctuation so glyphs don't substitute badly in Satori.
176
- if (style === "caption" || style === "body") {
177
- content = content
178
- .replace(/\u2192/g, "->")
179
- .replace(/\u2190/g, "<-")
180
- .replace(/\u27a1/gi, "->");
181
- }
182
- // Match `renderSnapPage` `renderText` (card HTML): title 20px #111, body/caption/list tones.
183
- const styleMap = {
184
- title: { fontSize: 20, fontWeight: 700, color: "#111111", lineHeight: 1.3 },
185
- body: { fontSize: 15, color: "#374151", lineHeight: 1.5 },
186
- caption: { fontSize: 13, color: "#9CA3AF", lineHeight: 1.4 },
187
- label: {
188
- fontSize: 13,
189
- fontWeight: 600,
190
- color: "#6B7280",
191
- textTransform: "uppercase",
192
- letterSpacing: "0.5px",
193
- },
176
+ content = content
177
+ .replace(/\u2192/g, "->")
178
+ .replace(/\u2190/g, "<-")
179
+ .replace(/\u27a1/gi, "->");
180
+ const sizeStyles = {
181
+ md: { fontSize: 15, lineHeight: 1.5 },
182
+ sm: { fontSize: 13, lineHeight: 1.5 },
183
+ };
184
+ const weightStyles = {
185
+ bold: { fontWeight: 700 },
186
+ normal: { fontWeight: 400 },
194
187
  };
195
- const ts = styleMap[style] ?? styleMap["body"];
196
188
  return h("div", {
197
189
  display: "flex",
198
190
  width: OG_CARD_INNER_WIDTH_PX,
191
+ color: "#374151",
199
192
  textAlign: align,
200
- ...ts,
193
+ ...(sizeStyles[size] ?? sizeStyles.md),
194
+ ...(weightStyles[weight] ?? weightStyles.normal),
201
195
  }, content);
202
196
  }
197
+ function mapItem(el) {
198
+ const title = String(el.title ?? "");
199
+ const description = el.description ? String(el.description) : undefined;
200
+ return h("div", { display: "flex", flexDirection: "column", gap: 2, padding: "6px 10px" }, h("div", { display: "flex", fontSize: 15, fontWeight: 500, color: "#111" }, title), description
201
+ ? h("div", { display: "flex", fontSize: 13, color: "#6B7280", lineHeight: 1.4 }, description)
202
+ : null);
203
+ }
204
+ function mapBadge(el, accent) {
205
+ const label = String(el.label ?? "");
206
+ const color = colorHex(el.color, accent);
207
+ const variant = String(el.variant ?? "default");
208
+ const isFilled = variant === "default";
209
+ const bg = isFilled ? color : "transparent";
210
+ const fg = isFilled ? "#fff" : color;
211
+ const border = isFilled ? undefined : `1px solid ${color}`;
212
+ return h("div", {
213
+ display: "flex",
214
+ alignItems: "center",
215
+ paddingTop: 2, paddingBottom: 2, paddingLeft: 10, paddingRight: 10,
216
+ borderRadius: 9999,
217
+ fontSize: 12,
218
+ fontWeight: 500,
219
+ backgroundColor: bg,
220
+ color: fg,
221
+ ...(border ? { border } : {}),
222
+ }, label);
223
+ }
224
+ function mapSeparator() {
225
+ return h("div", {
226
+ display: "flex",
227
+ height: 1,
228
+ backgroundColor: "#E5E7EB",
229
+ width: "100%",
230
+ });
231
+ }
203
232
  function mapImage(el, imageMap) {
204
233
  const url = el.url;
205
234
  const dataUri = imageMap.get(url);
@@ -363,16 +392,41 @@ function mapElement(el, accent, imageMap) {
363
392
  switch (type) {
364
393
  case "text":
365
394
  return mapText(el);
395
+ case "item":
396
+ return mapItem(el);
397
+ case "badge":
398
+ return mapBadge(el, accent);
366
399
  case "image":
367
400
  return mapImage(el, imageMap);
401
+ case "separator":
368
402
  case "divider":
369
- return mapDivider();
403
+ return mapSeparator();
370
404
  case "progress":
371
405
  return mapProgress(el, accent);
372
- case "list":
373
- return mapList(el);
374
406
  case "toggle_group":
375
407
  return mapButtonGroup(el, accent);
408
+ case "input": {
409
+ const label = el.label ? String(el.label) : "";
410
+ const placeholder = el.placeholder ? String(el.placeholder) : "";
411
+ return h("div", { display: "flex", flexDirection: "column", gap: 6, width: OG_CARD_INNER_WIDTH_PX }, label ? h("div", { display: "flex", fontSize: 13, fontWeight: 500, color: "#374151" }, label) : null, h("div", {
412
+ display: "flex", padding: "10px 12px", borderRadius: 8,
413
+ border: "1px solid #E5E7EB", backgroundColor: "#fff",
414
+ fontSize: 14, color: "#9CA3AF",
415
+ }, placeholder || " "));
416
+ }
417
+ case "switch": {
418
+ const label = el.label ? String(el.label) : "";
419
+ const checked = Boolean(el.defaultChecked);
420
+ const bg = checked ? accent : "#D1D5DB";
421
+ return h("div", { display: "flex", alignItems: "center", justifyContent: "space-between", width: OG_CARD_INNER_WIDTH_PX }, h("div", { display: "flex", fontSize: 14, color: "#374151" }, label), h("div", { display: "flex", width: 44, height: 24, borderRadius: 12, backgroundColor: bg, position: "relative" }, h("div", { display: "flex", width: 20, height: 20, borderRadius: 10, backgroundColor: "#fff", position: "absolute", top: 2, left: checked ? 20 : 2 })));
422
+ }
423
+ case "slider": {
424
+ const label = el.label ? String(el.label) : "";
425
+ return h("div", { display: "flex", flexDirection: "column", gap: 6, width: OG_CARD_INNER_WIDTH_PX }, label ? h("div", { display: "flex", fontSize: 13, fontWeight: 500, color: "#374151" }, label) : null, h("div", { display: "flex", height: 10, backgroundColor: "#E5E7EB", borderRadius: 9999, width: "100%" }, h("div", { display: "flex", height: 10, width: "50%", backgroundColor: accent, borderRadius: 9999 })));
426
+ }
427
+ // Legacy types kept for backward compat with older specs
428
+ case "list":
429
+ return mapList(el);
376
430
  case "bar_chart":
377
431
  return mapBarChart(el, accent);
378
432
  case "group": {
@@ -388,8 +442,8 @@ function mapElement(el, accent, imageMap) {
388
442
  }
389
443
  function mapButton(btn, accent, i) {
390
444
  const label = String(btn.label ?? "");
391
- const style = btn.style ?? (i === 0 ? "primary" : "secondary");
392
- const isPrimary = style === "primary";
445
+ const variant = btn.variant ?? btn.style ?? "secondary";
446
+ const isPrimary = variant === "primary";
393
447
  // Primary CTA: generous vertical padding + minHeight so Satori/Yoga renders a tall tap target
394
448
  // (small padding deltas are easy to miss; flexBasis:0 rows can also under-measure height).
395
449
  const py = isPrimary ? 18 : 10;
@@ -425,21 +479,21 @@ function linesForWrappedText(charCount, innerWidthPx, avgCharPx) {
425
479
  return Math.max(1, Math.ceil((charCount * 1.12) / cpl));
426
480
  }
427
481
  function estimateTextHeight(el) {
428
- const style = el.style ?? "body";
482
+ const size = String(el.size ?? "md");
429
483
  const content = String(el.content ?? "");
430
484
  const w = OG_CARD_INNER_WIDTH_PX;
431
- switch (style) {
432
- case "title":
433
- return linesForWrappedText(content.length, w, 11) * 26;
434
- case "body":
435
- return linesForWrappedText(content.length, w, 7.5) * 23;
436
- case "caption":
437
- return linesForWrappedText(content.length, w, 7) * 18;
438
- case "label":
439
- return linesForWrappedText(content.length, w, 7) * 18;
440
- default:
441
- return linesForWrappedText(content.length, w, 7.5) * 23;
442
- }
485
+ if (size === "sm")
486
+ return linesForWrappedText(content.length, w, 7) * 20;
487
+ return linesForWrappedText(content.length, w, 7.5) * 23;
488
+ }
489
+ function estimateItemHeight(el) {
490
+ const title = String(el.title ?? "");
491
+ const desc = el.description ? String(el.description) : "";
492
+ const w = OG_CARD_INNER_WIDTH_PX;
493
+ let total = linesForWrappedText(title.length, w, 7.5) * 23 + 12;
494
+ if (desc)
495
+ total += linesForWrappedText(desc.length, w, 7) * 20;
496
+ return total;
443
497
  }
444
498
  function estimateImageHeight(el, imageMap) {
445
499
  const url = el.url;
@@ -499,12 +553,23 @@ function estimateElementHeight(el, imageMap) {
499
553
  switch (type) {
500
554
  case "text":
501
555
  return estimateTextHeight(el);
556
+ case "item":
557
+ return estimateItemHeight(el);
558
+ case "badge":
559
+ return 24;
502
560
  case "image":
503
561
  return estimateImageHeight(el, imageMap);
562
+ case "separator":
504
563
  case "divider":
505
564
  return 1;
506
565
  case "progress":
507
566
  return estimateProgressHeight(el);
567
+ case "input":
568
+ return (el.label ? 20 : 0) + 42;
569
+ case "switch":
570
+ return 28;
571
+ case "slider":
572
+ return (el.label ? 20 : 0) + 16;
508
573
  case "list":
509
574
  return estimateListHeight(el);
510
575
  case "toggle_group":
@@ -557,19 +622,28 @@ function estimateDefaultOgHeight(elements, imageMap, buttons, buttonLayout) {
557
622
  return Math.min(OG_MAX_HEIGHT_PX, Math.max(OG_MIN_HEIGHT_PX, Math.ceil(outerH)));
558
623
  }
559
624
  // ─── Spec helpers ─────────────────────────────────────
560
- /** Walk the flat spec from root and collect top-level children as El objects for the OG renderer. */
625
+ /** Walk the flat spec from root, recursing into stack containers, and collect leaf elements as El objects. */
561
626
  function specToElementList(spec) {
627
+ function collect(keys) {
628
+ const result = [];
629
+ for (const key of keys) {
630
+ const el = spec.elements[key];
631
+ if (!el)
632
+ continue;
633
+ // Recurse into stack and item_group containers
634
+ if ((el.type === "stack" || el.type === "item_group") && el.children?.length) {
635
+ result.push(...collect(el.children));
636
+ }
637
+ else {
638
+ result.push({ type: el.type, ...el.props });
639
+ }
640
+ }
641
+ return result;
642
+ }
562
643
  const rootEl = spec.elements[spec.root];
563
644
  if (!rootEl?.children)
564
645
  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);
646
+ return collect(rootEl.children);
573
647
  }
574
648
  /** Extract button elements (type: "button") from the spec. */
575
649
  function specToButtons(spec) {
@@ -4,6 +4,9 @@ export function extractPageMeta(spec) {
4
4
  let description = "";
5
5
  let imageUrl;
6
6
  let imageAlt;
7
+ // Fallbacks from text elements (lower priority than item)
8
+ let textTitle;
9
+ let textDescription;
7
10
  for (const el of Object.values(spec.elements)) {
8
11
  const e = el;
9
12
  if (e.type === "item") {
@@ -14,11 +17,25 @@ export function extractPageMeta(spec) {
14
17
  description = String(e.props.description);
15
18
  }
16
19
  }
20
+ if (e.type === "text" && e.props?.content) {
21
+ const content = String(e.props.content);
22
+ if (!textTitle && String(e.props.weight ?? "") === "bold") {
23
+ textTitle = content;
24
+ }
25
+ else if (!textDescription && String(e.props.weight ?? "") !== "bold") {
26
+ textDescription = content;
27
+ }
28
+ }
17
29
  if (e.type === "image" && !imageUrl) {
18
30
  imageUrl = e.props?.url ? String(e.props.url) : undefined;
19
31
  imageAlt = e.props?.alt ? String(e.props.alt) : undefined;
20
32
  }
21
33
  }
34
+ // Use text fallbacks if no item-derived values
35
+ if (title === "Farcaster Snap" && textTitle)
36
+ title = textTitle;
37
+ if (!description && textDescription)
38
+ description = textDescription;
22
39
  return {
23
40
  title,
24
41
  description: description || title,
@@ -69,7 +86,58 @@ function colorHex(color, accent) {
69
86
  return accent;
70
87
  return PALETTE_LIGHT_HEX[color] ?? accent;
71
88
  }
89
+ /** Readable foreground for a hex background (YIQ contrast check). */
90
+ function fgForBg(hex) {
91
+ const h = hex.replace(/^#/, "");
92
+ if (h.length !== 6)
93
+ return "#ffffff";
94
+ const r = Number.parseInt(h.slice(0, 2), 16);
95
+ const g = Number.parseInt(h.slice(2, 4), 16);
96
+ const b = Number.parseInt(h.slice(4, 6), 16);
97
+ const yiq = (r * 299 + g * 587 + b * 114) / 1000;
98
+ return yiq >= 180 ? "#0a0a0a" : "#ffffff";
99
+ }
100
+ /** Lucide-style SVG paths for all snap icons. */
101
+ const ICON_SVGS = {
102
+ check: `<polyline points="20 6 9 17 4 12"/>`,
103
+ x: `<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>`,
104
+ 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"/>`,
105
+ 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"/>`,
106
+ 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"/>`,
107
+ "arrow-right": `<line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/>`,
108
+ "arrow-left": `<line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/>`,
109
+ "chevron-right": `<polyline points="9 18 15 12 9 6"/>`,
110
+ "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"/>`,
111
+ "alert-triangle": `<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>`,
112
+ clock: `<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>`,
113
+ "message-circle": `<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/>`,
114
+ repeat: `<polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/>`,
115
+ share: `<circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/>`,
116
+ 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"/>`,
117
+ users: `<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>`,
118
+ trophy: `<path d="M6 9H4.5a2.5 2.5 0 0 1 0-5H6"/><path d="M18 9h1.5a2.5 2.5 0 0 0 0-5H18"/><path d="M4 22h16"/><path d="M10 14.66V17c0 .55-.47.98-.97 1.21C7.85 18.75 7 20.24 7 22"/><path d="M14 14.66V17c0 .55.47.98.97 1.21C16.15 18.75 17 20.24 17 22"/><path d="M18 2H6v7a6 6 0 0 0 12 0V2Z"/>`,
119
+ zap: `<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>`,
120
+ flame: `<path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 2.5z"/>`,
121
+ gift: `<polyline points="20 12 20 22 4 22 4 12"/><rect x="2" y="7" width="20" height="5"/><line x1="12" y1="22" x2="12" y2="7"/><path d="M12 7H7.5a2.5 2.5 0 0 1 0-5C11 2 12 7 12 7z"/><path d="M12 7h4.5a2.5 2.5 0 0 0 0-5C13 2 12 7 12 7z"/>`,
122
+ image: `<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/>`,
123
+ play: `<polygon points="5 3 19 12 5 21 5 3"/>`,
124
+ pause: `<rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/>`,
125
+ wallet: `<path d="M21 12V7H5a2 2 0 0 1 0-4h14v4"/><path d="M3 5v14a2 2 0 0 0 2 2h16v-5"/><path d="M18 12a2 2 0 0 0 0 4h4v-4Z"/>`,
126
+ coins: `<circle cx="8" cy="8" r="6"/><path d="M18.09 10.37A6 6 0 1 1 10.34 18"/><path d="M7 6h1v4"/><path d="m16.71 13.88.7.71-2.82 2.82"/>`,
127
+ plus: `<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>`,
128
+ minus: `<line x1="5" y1="12" x2="19" y2="12"/>`,
129
+ "refresh-cw": `<polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>`,
130
+ bookmark: `<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/>`,
131
+ "thumbs-up": `<path d="M14 9V5a3 3 0 0 0-3-3l-4 9v11h11.28a2 2 0 0 0 2-1.7l1.38-9a2 2 0 0 0-2-2.3zM7 22H4a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2h3"/>`,
132
+ "thumbs-down": `<path d="M10 15v4a3 3 0 0 0 3 3l4-9V2H5.72a2 2 0 0 0-2 1.7l-1.38 9a2 2 0 0 0 2 2.3zm7-13h2.67A2.31 2.31 0 0 1 22 4v7a2.31 2.31 0 0 1-2.33 2H17"/>`,
133
+ "trending-up": `<polyline points="23 6 13.5 15.5 8.5 10.5 1 18"/><polyline points="17 6 23 6 23 12"/>`,
134
+ "trending-down": `<polyline points="23 18 13.5 8.5 8.5 13.5 1 6"/><polyline points="17 18 23 18 23 12"/>`,
135
+ };
72
136
  // ─── Element renderers ──────────────────────────────────
137
+ function renderIcon(name, size, color) {
138
+ const inner = ICON_SVGS[name] ?? `<circle cx="12" cy="12" r="4"/>`;
139
+ 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;flex-shrink:0">${inner}</svg>`;
140
+ }
73
141
  function renderElement(key, spec, accent) {
74
142
  const el = spec.elements[key];
75
143
  if (!el)
@@ -79,48 +147,34 @@ function renderElement(key, spec, accent) {
79
147
  case "icon": {
80
148
  const color = colorHex(p.color, accent);
81
149
  const size = String(p.size ?? "md") === "sm" ? 16 : 20;
82
- // Simplified inline SVG for common icons; falls back to a circle for unknown
83
150
  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>`;
151
+ return `<span style="display:inline-flex;align-items:center">${renderIcon(name, size, color)}</span>`;
99
152
  }
100
153
  case "badge": {
101
154
  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>`;
155
+ const badgeVariant = String(p.variant ?? "default");
156
+ const isFilled = badgeVariant === "default";
157
+ const fg = isFilled ? fgForBg(color) : color;
158
+ const bgStyle = isFilled ? `background:${color};color:${fg}` : `border:1px solid ${color};color:${color}`;
159
+ const iconName = p.icon ? String(p.icon) : undefined;
160
+ const iconHtml = iconName ? renderIcon(iconName, 12, fg) : "";
161
+ const gap = iconHtml ? "gap:4px;" : "";
162
+ return `<span style="display:inline-flex;align-items:center;${gap}padding:2px 10px;border-radius:9999px;font-size:12px;font-weight:500;line-height:1.5;${bgStyle}">${iconHtml}${esc(String(p.label ?? ""))}</span>`;
103
163
  }
104
164
  case "image": {
105
165
  const url = esc(String(p.url ?? ""));
106
- const aspect = String(p.aspect ?? "16:9");
166
+ const aspect = String(p.aspect ?? "1:1");
107
167
  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>`;
168
+ const ratio = w && h ? `${w}/${h}` : "1/1";
169
+ return `<div style="flex:1;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
170
  }
111
171
  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
172
  const descHtml = p.description ? `<div style="font-size:13px;color:#6B7280;margin-top:2px">${esc(String(p.description))}</div>` : "";
119
173
  const childIds = el.children ?? [];
120
174
  const actionsHtml = childIds.length > 0
121
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>`
122
176
  : "";
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>`;
177
+ return `<div style="display:flex;align-items:flex-start;padding:6px 10px"><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
178
  }
125
179
  case "item_group": {
126
180
  const childIds = el.children ?? [];
@@ -141,10 +195,9 @@ function renderElement(key, spec, accent) {
141
195
  case "progress": {
142
196
  const value = Number(p.value ?? 0);
143
197
  const max = Number(p.max ?? 100);
144
- const color = accent;
145
198
  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>`;
199
+ const labelHtml = p.label ? `<div style="font-size:12px;color:#6B7280;margin-bottom:4px">${esc(String(p.label))}</div>` : "";
200
+ return `<div style="display:flex;flex:1;flex-direction:column;gap:4px">${labelHtml}<div style="height:10px;background:#E5E7EB;border-radius:9999px;overflow:hidden"><div style="height:100%;width:${pct}%;background:${accent};border-radius:9999px;transition:width 0.3s"></div></div></div>`;
148
201
  }
149
202
  case "separator": {
150
203
  const orientation = String(p.orientation ?? "horizontal");
@@ -156,67 +209,79 @@ function renderElement(key, spec, accent) {
156
209
  const min = Number(p.min ?? 0);
157
210
  const max = Number(p.max ?? 100);
158
211
  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>`;
212
+ const labelHtml = p.label ? `<div style="font-size:13px;font-weight:500;color:#374151;margin-bottom:6px">${esc(String(p.label))}</div>` : "";
213
+ return `<div style="display:flex;flex-direction:column;gap:6px">${labelHtml}<input type="range" min="${min}" max="${max}" value="${value}" disabled style="width:100%;height:10px;border-radius:9999px;accent-color:${accent};background:#E5E7EB;-webkit-appearance:none;appearance:none"></div>`;
161
214
  }
162
215
  case "switch": {
163
216
  const checked = Boolean(p.defaultChecked);
164
217
  const bg = checked ? accent : "#D1D5DB";
165
218
  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>`;
219
+ return `<div style="display:flex;align-items:center;justify-content:space-between;gap:12px"><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;transition:background 0.2s"><div style="width:20px;height:20px;background:#fff;border-radius:50%;position:absolute;top:2px;left:${tx};transition:left 0.2s;box-shadow:0 1px 3px rgba(0,0,0,0.2)"></div></div></div>`;
167
220
  }
168
221
  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>`;
222
+ const labelHtml = p.label ? `<label style="display:block;font-size:13px;font-weight:500;color:#374151;margin-bottom:6px">${esc(String(p.label))}</label>` : "";
223
+ return `<div style="display:flex;flex-direction:column;gap:6px">${labelHtml}<input type="text" placeholder="${esc(String(p.placeholder ?? ""))}" readonly style="width:100%;padding:10px 12px;border-radius:8px;border:1px solid #E5E7EB;background:#fff;font-size:14px;color:#374151;font-family:inherit;box-sizing:border-box"></div>`;
171
224
  }
172
225
  case "toggle_group": {
173
226
  const options = Array.isArray(p.options) ? p.options : [];
174
227
  const orientation = String(p.orientation ?? "horizontal");
175
228
  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">`;
229
+ const defaultVal = p.defaultValue !== undefined ? String(p.defaultValue) : undefined;
230
+ const labelHtml = p.label ? `<div style="font-size:13px;font-weight:500;color:#374151;margin-bottom:6px">${esc(String(p.label))}</div>` : "";
231
+ let html = `<div>${labelHtml}<div style="display:flex;flex-direction:${dir};gap:4px;padding:4px;background:rgba(229,231,235,0.2);border-radius:8px">`;
178
232
  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>`;
233
+ const selected = defaultVal === opt;
234
+ const optBg = selected ? accent : "transparent";
235
+ const optColor = selected ? fgForBg(accent) : "#374151";
236
+ const optWeight = selected ? "600" : "500";
237
+ html += `<button onclick="showModal()" style="flex:1;padding:8px 12px;border-radius:6px;border:none;background:${optBg};font-size:13px;font-weight:${optWeight};color:${optColor};cursor:pointer;font-family:inherit;transition:background 0.15s,color 0.15s">${esc(opt)}</button>`;
180
238
  }
181
239
  html += `</div></div>`;
182
240
  return html;
183
241
  }
184
242
  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>`;
243
+ const variant = String(p.variant ?? "secondary");
244
+ const isPrimary = variant === "primary";
245
+ const fg = isPrimary ? fgForBg(accent) : accent;
246
+ const bg = isPrimary ? accent : "transparent";
247
+ const border = isPrimary ? "none" : `2px solid ${accent}`;
248
+ const pad = isPrimary ? "14px 16px" : "10px 16px";
249
+ const minH = isPrimary ? "min-height:44px;" : "";
250
+ const iconName = p.icon ? String(p.icon) : undefined;
251
+ const iconHtml = iconName ? renderIcon(iconName, 16, fg) : "";
252
+ const gap = iconHtml ? "gap:8px;" : "";
253
+ return `<button onclick="showModal()" style="display:inline-flex;align-items:center;justify-content:center;${gap}width:100%;${minH}padding:${pad};border-radius:10px;background:${bg};color:${fg};border:${border};font-size:14px;font-weight:600;cursor:pointer;font-family:inherit;box-sizing:border-box">${iconHtml}${esc(String(p.label ?? ""))}</button>`;
192
254
  }
193
255
  case "text": {
194
256
  const size = String(p.size ?? "md");
195
- const weight = String(p.weight ?? (size === "lg" ? "bold" : "normal"));
257
+ const weight = String(p.weight ?? "normal");
196
258
  const align = String(p.align ?? "left");
197
259
  const styles = {
198
- lg: "font-size:20px",
199
260
  md: "font-size:15px;line-height:1.5",
200
- sm: "font-size:13px",
261
+ sm: "font-size:13px;line-height:1.5",
201
262
  };
202
263
  const weights = {
203
264
  bold: "font-weight:700",
204
- medium: "font-weight:500",
205
265
  normal: "font-weight:400",
206
266
  };
207
- return `<div style="${styles[size] ?? styles.md};${weights[weight] ?? weights.normal};color:#374151;text-align:${align}">${esc(String(p.content ?? ""))}</div>`;
267
+ return `<div style="flex:1;${styles[size] ?? styles.md};${weights[weight] ?? weights.normal};color:#374151;text-align:${align}">${esc(String(p.content ?? ""))}</div>`;
208
268
  }
209
269
  case "stack": {
210
270
  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";
271
+ const isHorizontal = direction === "horizontal";
272
+ const vGap = { none: "0", sm: "8px", md: "16px", lg: "24px" };
273
+ const hGap = { none: "0", sm: "4px", md: "8px", lg: "12px" };
274
+ const gapMap = isHorizontal ? hGap : vGap;
275
+ const gapVal = gapMap[String(p.gap ?? "md")] ?? (isHorizontal ? "8px" : "16px");
276
+ const dir = isHorizontal ? "row" : "column";
277
+ const wrap = isHorizontal ? "flex-wrap:wrap;" : "";
278
+ const align = isHorizontal ? "align-items:center;" : "";
214
279
  const justifyMap = { start: "flex-start", center: "center", end: "flex-end", between: "space-between", around: "space-around" };
215
280
  const jc = p.justify ? justifyMap[String(p.justify)] : undefined;
216
281
  const childIds = el.children ?? [];
217
- let html = `<div style="display:flex;flex-direction:${dir};gap:${gapVal}${jc ? `;justify-content:${jc}` : ""}">`;
282
+ let html = `<div style="display:flex;width:100%;flex-direction:${dir};gap:${gapVal};${wrap}${align}${jc ? `justify-content:${jc};` : ""}">`;
218
283
  for (const childKey of childIds) {
219
- const flex = direction === "horizontal" ? "flex:1;" : "";
284
+ const flex = isHorizontal ? "flex:1;min-width:0;" : "";
220
285
  html += `<div style="${flex}">${renderElement(childKey, spec, accent)}</div>`;
221
286
  }
222
287
  html += `</div>`;
@@ -255,17 +320,18 @@ ${ogMeta}
255
320
  *{margin:0;padding:0;box-sizing:border-box}
256
321
  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}
257
322
  .card{background:#fff;border-radius:16px;max-width:420px;width:100%;padding:20px;box-shadow:0 4px 24px rgba(0,0,0,0.3)}
323
+ .card button:hover{filter:brightness(0.92)}
258
324
  .foot{margin-top:16px;text-align:center}
259
- .foot a{color:#8B5CF6;text-decoration:none;font-size:13px;display:inline-flex;align-items:center;gap:6px}
325
+ .foot a{color:${accent};text-decoration:none;font-size:13px;display:inline-flex;align-items:center;gap:6px}
260
326
  .foot a:hover{opacity:.8}
261
327
  .foot svg{width:14px;height:12px}
262
328
  .modal{display:none;position:fixed;inset:0;background:rgba(0,0,0,0.7);align-items:center;justify-content:center;z-index:99}
263
329
  .modal-box{background:#1A1A2E;border-radius:16px;padding:32px;text-align:center;max-width:340px;width:90%}
264
- .modal-box svg{width:40px;height:35px;color:#8B5CF6;margin-bottom:16px}
330
+ .modal-box svg{width:40px;height:35px;color:${accent};margin-bottom:16px}
265
331
  .modal-box h2{color:#FAFAFA;font-size:20px;margin-bottom:8px}
266
332
  .modal-box p{color:#A1A1AA;font-size:14px;line-height:1.5;margin-bottom:24px}
267
333
  .modal-box a{display:block;padding:12px;border-radius:10px;text-decoration:none;font-weight:600;font-size:15px;margin-bottom:12px}
268
- .mb-primary{background:#8B5CF6;color:#fff}
334
+ .mb-primary{background:${accent};color:${fgForBg(accent)}}
269
335
  .mb-secondary{background:#1A1A2E;color:#FAFAFA;border:1px solid #2D2D44}
270
336
  .modal-box a:hover{opacity:.85}
271
337
  .modal-box button{background:none;border:none;color:#A1A1AA;cursor:pointer;font-size:13px;font-family:inherit}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@farcaster/snap-hono",
3
- "version": "1.4.1",
3
+ "version": "1.4.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.7.1"
31
+ "@farcaster/snap": "1.10.0"
32
32
  },
33
33
  "peerDependencies": {
34
34
  "hono": ">=4.0.0"
package/src/index.ts CHANGED
@@ -1,7 +1,6 @@
1
1
  import type { Hono } from "hono";
2
2
  import { cors } from "hono/cors";
3
3
  import {
4
- createDefaultDataStore,
5
4
  MEDIA_TYPE,
6
5
  type SnapFunction,
7
6
  ACTION_TYPE_GET,
@@ -17,8 +16,6 @@ import {
17
16
  type OgOptions,
18
17
  } from "./og-image";
19
18
 
20
- const defaultData = createDefaultDataStore();
21
-
22
19
  export type SnapHandlerOptions = {
23
20
  /**
24
21
  * Route path to register GET and POST handlers on.
@@ -81,7 +78,6 @@ export function registerSnapHandler(
81
78
  const snap = await snapFn({
82
79
  action: { type: ACTION_TYPE_GET },
83
80
  request: stripAuthHeaders(c.req.raw),
84
- data: defaultData,
85
81
  });
86
82
  const snapJson = JSON.stringify(snap);
87
83
  const etag = etagForPage(snapJson);
@@ -169,7 +165,6 @@ export function registerSnapHandler(
169
165
  const response = await snapFn({
170
166
  action: { type: ACTION_TYPE_GET },
171
167
  request: c.req.raw,
172
- data: defaultData,
173
168
  });
174
169
 
175
170
  return payloadToResponse(response, {
@@ -213,7 +208,6 @@ export function registerSnapHandler(
213
208
  const response = await snapFn({
214
209
  action: parsed.action,
215
210
  request: raw,
216
- data: defaultData,
217
211
  });
218
212
 
219
213
  return payloadToResponse(response, {
@@ -273,7 +267,6 @@ async function getFallbackHtml(
273
267
  const snap = await snapFn({
274
268
  action: { type: ACTION_TYPE_GET },
275
269
  request: stripAuthHeaders(request),
276
- data: defaultData,
277
270
  });
278
271
  return renderSnapPage(snap, origin, { ogImageUrl, resourcePath, siteName });
279
272
  } catch {
@@ -291,7 +284,7 @@ function snapOriginFromRequest(request: Request): string {
291
284
  request.headers.get("host")?.trim();
292
285
  if (host) return `${proto}://${host}`.replace(/\/$/, "");
293
286
 
294
- return "https://snap.farcaster.xyz";
287
+ return "https://docs.farcaster.xyz/snap";
295
288
  }
296
289
 
297
290
  function clientWantsSnapResponse(accept: string | undefined): boolean {
package/src/og-image.ts CHANGED
@@ -255,42 +255,83 @@ function colorHex(color: string | undefined, accent: string): string {
255
255
  }
256
256
 
257
257
  function mapText(el: El): VNode {
258
- const style = el.style as string;
258
+ const size = String(el.size ?? "md");
259
+ const weight = String(el.weight ?? "normal");
259
260
  const align = (el.align as string) ?? "left";
260
261
  let content = String(el.content ?? "");
261
- // Inter WOFF subset: normalize arrows / punctuation so glyphs don't substitute badly in Satori.
262
- if (style === "caption" || style === "body") {
263
- content = content
264
- .replace(/\u2192/g, "->")
265
- .replace(/\u2190/g, "<-")
266
- .replace(/\u27a1/gi, "->");
267
- }
268
- // Match `renderSnapPage` `renderText` (card HTML): title 20px #111, body/caption/list tones.
269
- const styleMap: Record<string, Record<string, unknown>> = {
270
- title: { fontSize: 20, fontWeight: 700, color: "#111111", lineHeight: 1.3 },
271
- body: { fontSize: 15, color: "#374151", lineHeight: 1.5 },
272
- caption: { fontSize: 13, color: "#9CA3AF", lineHeight: 1.4 },
273
- label: {
274
- fontSize: 13,
275
- fontWeight: 600,
276
- color: "#6B7280",
277
- textTransform: "uppercase",
278
- letterSpacing: "0.5px",
279
- },
262
+ content = content
263
+ .replace(/\u2192/g, "->")
264
+ .replace(/\u2190/g, "<-")
265
+ .replace(/\u27a1/gi, "->");
266
+ const sizeStyles: Record<string, Record<string, unknown>> = {
267
+ md: { fontSize: 15, lineHeight: 1.5 },
268
+ sm: { fontSize: 13, lineHeight: 1.5 },
269
+ };
270
+ const weightStyles: Record<string, Record<string, unknown>> = {
271
+ bold: { fontWeight: 700 },
272
+ normal: { fontWeight: 400 },
280
273
  };
281
- const ts = styleMap[style] ?? styleMap["body"]!;
282
274
  return h(
283
275
  "div",
284
276
  {
285
277
  display: "flex",
286
278
  width: OG_CARD_INNER_WIDTH_PX,
279
+ color: "#374151",
287
280
  textAlign: align,
288
- ...ts,
281
+ ...(sizeStyles[size] ?? sizeStyles.md),
282
+ ...(weightStyles[weight] ?? weightStyles.normal),
289
283
  },
290
284
  content,
291
285
  );
292
286
  }
293
287
 
288
+ function mapItem(el: El): VNode {
289
+ const title = String(el.title ?? "");
290
+ const description = el.description ? String(el.description) : undefined;
291
+ return h(
292
+ "div",
293
+ { display: "flex", flexDirection: "column", gap: 2, padding: "6px 10px" },
294
+ h("div", { display: "flex", fontSize: 15, fontWeight: 500, color: "#111" }, title),
295
+ description
296
+ ? h("div", { display: "flex", fontSize: 13, color: "#6B7280", lineHeight: 1.4 }, description)
297
+ : null,
298
+ );
299
+ }
300
+
301
+ function mapBadge(el: El, accent: string): VNode {
302
+ const label = String(el.label ?? "");
303
+ const color = colorHex(el.color as string | undefined, accent);
304
+ const variant = String(el.variant ?? "default");
305
+ const isFilled = variant === "default";
306
+ const bg = isFilled ? color : "transparent";
307
+ const fg = isFilled ? "#fff" : color;
308
+ const border = isFilled ? undefined : `1px solid ${color}`;
309
+ return h(
310
+ "div",
311
+ {
312
+ display: "flex",
313
+ alignItems: "center",
314
+ paddingTop: 2, paddingBottom: 2, paddingLeft: 10, paddingRight: 10,
315
+ borderRadius: 9999,
316
+ fontSize: 12,
317
+ fontWeight: 500,
318
+ backgroundColor: bg,
319
+ color: fg,
320
+ ...(border ? { border } : {}),
321
+ },
322
+ label,
323
+ );
324
+ }
325
+
326
+ function mapSeparator(): VNode {
327
+ return h("div", {
328
+ display: "flex",
329
+ height: 1,
330
+ backgroundColor: "#E5E7EB",
331
+ width: "100%",
332
+ });
333
+ }
334
+
294
335
  function mapImage(el: El, imageMap: Map<string, string>): VNode {
295
336
  const url = el.url as string;
296
337
  const dataUri = imageMap.get(url);
@@ -534,16 +575,60 @@ function mapElement(
534
575
  switch (type) {
535
576
  case "text":
536
577
  return mapText(el);
578
+ case "item":
579
+ return mapItem(el);
580
+ case "badge":
581
+ return mapBadge(el, accent);
537
582
  case "image":
538
583
  return mapImage(el, imageMap);
584
+ case "separator":
539
585
  case "divider":
540
- return mapDivider();
586
+ return mapSeparator();
541
587
  case "progress":
542
588
  return mapProgress(el, accent);
543
- case "list":
544
- return mapList(el);
545
589
  case "toggle_group":
546
590
  return mapButtonGroup(el, accent);
591
+ case "input": {
592
+ const label = el.label ? String(el.label) : "";
593
+ const placeholder = el.placeholder ? String(el.placeholder) : "";
594
+ return h(
595
+ "div",
596
+ { display: "flex", flexDirection: "column", gap: 6, width: OG_CARD_INNER_WIDTH_PX },
597
+ label ? h("div", { display: "flex", fontSize: 13, fontWeight: 500, color: "#374151" }, label) : null,
598
+ h("div", {
599
+ display: "flex", padding: "10px 12px", borderRadius: 8,
600
+ border: "1px solid #E5E7EB", backgroundColor: "#fff",
601
+ fontSize: 14, color: "#9CA3AF",
602
+ }, placeholder || " "),
603
+ );
604
+ }
605
+ case "switch": {
606
+ const label = el.label ? String(el.label) : "";
607
+ const checked = Boolean(el.defaultChecked);
608
+ const bg = checked ? accent : "#D1D5DB";
609
+ return h(
610
+ "div",
611
+ { display: "flex", alignItems: "center", justifyContent: "space-between", width: OG_CARD_INNER_WIDTH_PX },
612
+ h("div", { display: "flex", fontSize: 14, color: "#374151" }, label),
613
+ h("div", { display: "flex", width: 44, height: 24, borderRadius: 12, backgroundColor: bg, position: "relative" },
614
+ h("div", { display: "flex", width: 20, height: 20, borderRadius: 10, backgroundColor: "#fff", position: "absolute", top: 2, left: checked ? 20 : 2 }),
615
+ ),
616
+ );
617
+ }
618
+ case "slider": {
619
+ const label = el.label ? String(el.label) : "";
620
+ return h(
621
+ "div",
622
+ { display: "flex", flexDirection: "column", gap: 6, width: OG_CARD_INNER_WIDTH_PX },
623
+ label ? h("div", { display: "flex", fontSize: 13, fontWeight: 500, color: "#374151" }, label) : null,
624
+ h("div", { display: "flex", height: 10, backgroundColor: "#E5E7EB", borderRadius: 9999, width: "100%" },
625
+ h("div", { display: "flex", height: 10, width: "50%", backgroundColor: accent, borderRadius: 9999 }),
626
+ ),
627
+ );
628
+ }
629
+ // Legacy types kept for backward compat with older specs
630
+ case "list":
631
+ return mapList(el);
547
632
  case "bar_chart":
548
633
  return mapBarChart(el, accent);
549
634
  case "group": {
@@ -564,8 +649,8 @@ function mapElement(
564
649
 
565
650
  function mapButton(btn: El, accent: string, i: number): VNode {
566
651
  const label = String(btn.label ?? "");
567
- const style = (btn.style as string) ?? (i === 0 ? "primary" : "secondary");
568
- const isPrimary = style === "primary";
652
+ const variant = (btn.variant as string) ?? (btn.style as string) ?? "secondary";
653
+ const isPrimary = variant === "primary";
569
654
  // Primary CTA: generous vertical padding + minHeight so Satori/Yoga renders a tall tap target
570
655
  // (small padding deltas are easy to miss; flexBasis:0 rows can also under-measure height).
571
656
  const py = isPrimary ? 18 : 10;
@@ -606,21 +691,20 @@ function linesForWrappedText(
606
691
  }
607
692
 
608
693
  function estimateTextHeight(el: El): number {
609
- const style = (el.style as string) ?? "body";
694
+ const size = String(el.size ?? "md");
610
695
  const content = String(el.content ?? "");
611
696
  const w = OG_CARD_INNER_WIDTH_PX;
612
- switch (style) {
613
- case "title":
614
- return linesForWrappedText(content.length, w, 11) * 26;
615
- case "body":
616
- return linesForWrappedText(content.length, w, 7.5) * 23;
617
- case "caption":
618
- return linesForWrappedText(content.length, w, 7) * 18;
619
- case "label":
620
- return linesForWrappedText(content.length, w, 7) * 18;
621
- default:
622
- return linesForWrappedText(content.length, w, 7.5) * 23;
623
- }
697
+ if (size === "sm") return linesForWrappedText(content.length, w, 7) * 20;
698
+ return linesForWrappedText(content.length, w, 7.5) * 23;
699
+ }
700
+
701
+ function estimateItemHeight(el: El): number {
702
+ const title = String(el.title ?? "");
703
+ const desc = el.description ? String(el.description) : "";
704
+ const w = OG_CARD_INNER_WIDTH_PX;
705
+ let total = linesForWrappedText(title.length, w, 7.5) * 23 + 12;
706
+ if (desc) total += linesForWrappedText(desc.length, w, 7) * 20;
707
+ return total;
624
708
  }
625
709
 
626
710
  function estimateImageHeight(el: El, imageMap: Map<string, string>): number {
@@ -688,12 +772,23 @@ function estimateElementHeight(el: El, imageMap: Map<string, string>): number {
688
772
  switch (type) {
689
773
  case "text":
690
774
  return estimateTextHeight(el);
775
+ case "item":
776
+ return estimateItemHeight(el);
777
+ case "badge":
778
+ return 24;
691
779
  case "image":
692
780
  return estimateImageHeight(el, imageMap);
781
+ case "separator":
693
782
  case "divider":
694
783
  return 1;
695
784
  case "progress":
696
785
  return estimateProgressHeight(el);
786
+ case "input":
787
+ return (el.label ? 20 : 0) + 42;
788
+ case "switch":
789
+ return 28;
790
+ case "slider":
791
+ return (el.label ? 20 : 0) + 16;
697
792
  case "list":
698
793
  return estimateListHeight(el);
699
794
  case "toggle_group":
@@ -767,17 +862,25 @@ function estimateDefaultOgHeight(
767
862
 
768
863
  // ─── Spec helpers ─────────────────────────────────────
769
864
 
770
- /** Walk the flat spec from root and collect top-level children as El objects for the OG renderer. */
865
+ /** Walk the flat spec from root, recursing into stack containers, and collect leaf elements as El objects. */
771
866
  function specToElementList(spec: SnapSpec): El[] {
867
+ function collect(keys: string[]): El[] {
868
+ const result: El[] = [];
869
+ for (const key of keys) {
870
+ const el = spec.elements[key];
871
+ if (!el) continue;
872
+ // Recurse into stack and item_group containers
873
+ if ((el.type === "stack" || el.type === "item_group") && el.children?.length) {
874
+ result.push(...collect(el.children));
875
+ } else {
876
+ result.push({ type: el.type, ...el.props } as El);
877
+ }
878
+ }
879
+ return result;
880
+ }
772
881
  const rootEl = spec.elements[spec.root];
773
882
  if (!rootEl?.children) return [];
774
- return rootEl.children
775
- .map((key) => {
776
- const el = spec.elements[key];
777
- if (!el) return null;
778
- return { type: el.type, ...el.props } as El;
779
- })
780
- .filter((el): el is El => el != null);
883
+ return collect(rootEl.children);
781
884
  }
782
885
 
783
886
  /** Extract button elements (type: "button") from the spec. */
@@ -31,6 +31,10 @@ export function extractPageMeta(spec: SnapSpec): PageMeta {
31
31
  let imageUrl: string | undefined;
32
32
  let imageAlt: string | undefined;
33
33
 
34
+ // Fallbacks from text elements (lower priority than item)
35
+ let textTitle: string | undefined;
36
+ let textDescription: string | undefined;
37
+
34
38
  for (const el of Object.values(spec.elements)) {
35
39
  const e = el as SnapUIElement;
36
40
  if (e.type === "item") {
@@ -41,12 +45,24 @@ export function extractPageMeta(spec: SnapSpec): PageMeta {
41
45
  description = String(e.props.description);
42
46
  }
43
47
  }
48
+ if (e.type === "text" && e.props?.content) {
49
+ const content = String(e.props.content);
50
+ if (!textTitle && String(e.props.weight ?? "") === "bold") {
51
+ textTitle = content;
52
+ } else if (!textDescription && String(e.props.weight ?? "") !== "bold") {
53
+ textDescription = content;
54
+ }
55
+ }
44
56
  if (e.type === "image" && !imageUrl) {
45
57
  imageUrl = e.props?.url ? String(e.props.url) : undefined;
46
58
  imageAlt = e.props?.alt ? String(e.props.alt) : undefined;
47
59
  }
48
60
  }
49
61
 
62
+ // Use text fallbacks if no item-derived values
63
+ if (title === "Farcaster Snap" && textTitle) title = textTitle;
64
+ if (!description && textDescription) description = textDescription;
65
+
50
66
  return {
51
67
  title,
52
68
  description: description || title,
@@ -118,8 +134,61 @@ function colorHex(
118
134
  return (PALETTE_LIGHT_HEX as Record<string, string>)[color] ?? accent;
119
135
  }
120
136
 
137
+ /** Readable foreground for a hex background (YIQ contrast check). */
138
+ function fgForBg(hex: string): string {
139
+ const h = hex.replace(/^#/, "");
140
+ if (h.length !== 6) return "#ffffff";
141
+ const r = Number.parseInt(h.slice(0, 2), 16);
142
+ const g = Number.parseInt(h.slice(2, 4), 16);
143
+ const b = Number.parseInt(h.slice(4, 6), 16);
144
+ const yiq = (r * 299 + g * 587 + b * 114) / 1000;
145
+ return yiq >= 180 ? "#0a0a0a" : "#ffffff";
146
+ }
147
+
148
+ /** Lucide-style SVG paths for all snap icons. */
149
+ const ICON_SVGS: Record<string, string> = {
150
+ check: `<polyline points="20 6 9 17 4 12"/>`,
151
+ x: `<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>`,
152
+ 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"/>`,
153
+ 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"/>`,
154
+ 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"/>`,
155
+ "arrow-right": `<line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/>`,
156
+ "arrow-left": `<line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/>`,
157
+ "chevron-right": `<polyline points="9 18 15 12 9 6"/>`,
158
+ "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"/>`,
159
+ "alert-triangle": `<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>`,
160
+ clock: `<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>`,
161
+ "message-circle": `<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/>`,
162
+ repeat: `<polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/>`,
163
+ share: `<circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/>`,
164
+ 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"/>`,
165
+ users: `<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>`,
166
+ trophy: `<path d="M6 9H4.5a2.5 2.5 0 0 1 0-5H6"/><path d="M18 9h1.5a2.5 2.5 0 0 0 0-5H18"/><path d="M4 22h16"/><path d="M10 14.66V17c0 .55-.47.98-.97 1.21C7.85 18.75 7 20.24 7 22"/><path d="M14 14.66V17c0 .55.47.98.97 1.21C16.15 18.75 17 20.24 17 22"/><path d="M18 2H6v7a6 6 0 0 0 12 0V2Z"/>`,
167
+ zap: `<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>`,
168
+ flame: `<path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 2.5z"/>`,
169
+ gift: `<polyline points="20 12 20 22 4 22 4 12"/><rect x="2" y="7" width="20" height="5"/><line x1="12" y1="22" x2="12" y2="7"/><path d="M12 7H7.5a2.5 2.5 0 0 1 0-5C11 2 12 7 12 7z"/><path d="M12 7h4.5a2.5 2.5 0 0 0 0-5C13 2 12 7 12 7z"/>`,
170
+ image: `<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/>`,
171
+ play: `<polygon points="5 3 19 12 5 21 5 3"/>`,
172
+ pause: `<rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/>`,
173
+ wallet: `<path d="M21 12V7H5a2 2 0 0 1 0-4h14v4"/><path d="M3 5v14a2 2 0 0 0 2 2h16v-5"/><path d="M18 12a2 2 0 0 0 0 4h4v-4Z"/>`,
174
+ coins: `<circle cx="8" cy="8" r="6"/><path d="M18.09 10.37A6 6 0 1 1 10.34 18"/><path d="M7 6h1v4"/><path d="m16.71 13.88.7.71-2.82 2.82"/>`,
175
+ plus: `<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>`,
176
+ minus: `<line x1="5" y1="12" x2="19" y2="12"/>`,
177
+ "refresh-cw": `<polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>`,
178
+ bookmark: `<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/>`,
179
+ "thumbs-up": `<path d="M14 9V5a3 3 0 0 0-3-3l-4 9v11h11.28a2 2 0 0 0 2-1.7l1.38-9a2 2 0 0 0-2-2.3zM7 22H4a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2h3"/>`,
180
+ "thumbs-down": `<path d="M10 15v4a3 3 0 0 0 3 3l4-9V2H5.72a2 2 0 0 0-2 1.7l-1.38 9a2 2 0 0 0 2 2.3zm7-13h2.67A2.31 2.31 0 0 1 22 4v7a2.31 2.31 0 0 1-2.33 2H17"/>`,
181
+ "trending-up": `<polyline points="23 6 13.5 15.5 8.5 10.5 1 18"/><polyline points="17 6 23 6 23 12"/>`,
182
+ "trending-down": `<polyline points="23 18 13.5 8.5 8.5 13.5 1 6"/><polyline points="17 18 23 18 23 12"/>`,
183
+ };
184
+
121
185
  // ─── Element renderers ──────────────────────────────────
122
186
 
187
+ function renderIcon(name: string, size: number, color: string): string {
188
+ const inner = ICON_SVGS[name] ?? `<circle cx="12" cy="12" r="4"/>`;
189
+ 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;flex-shrink:0">${inner}</svg>`;
190
+ }
191
+
123
192
  function renderElement(
124
193
  key: string,
125
194
  spec: SnapSpec,
@@ -133,48 +202,34 @@ function renderElement(
133
202
  case "icon": {
134
203
  const color = colorHex(p.color as string | undefined, accent);
135
204
  const size = String(p.size ?? "md") === "sm" ? 16 : 20;
136
- // Simplified inline SVG for common icons; falls back to a circle for unknown
137
205
  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>`;
206
+ return `<span style="display:inline-flex;align-items:center">${renderIcon(name, size, color)}</span>`;
153
207
  }
154
208
  case "badge": {
155
209
  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>`;
210
+ const badgeVariant = String(p.variant ?? "default");
211
+ const isFilled = badgeVariant === "default";
212
+ const fg = isFilled ? fgForBg(color) : color;
213
+ const bgStyle = isFilled ? `background:${color};color:${fg}` : `border:1px solid ${color};color:${color}`;
214
+ const iconName = p.icon ? String(p.icon) : undefined;
215
+ const iconHtml = iconName ? renderIcon(iconName, 12, fg) : "";
216
+ const gap = iconHtml ? "gap:4px;" : "";
217
+ return `<span style="display:inline-flex;align-items:center;${gap}padding:2px 10px;border-radius:9999px;font-size:12px;font-weight:500;line-height:1.5;${bgStyle}">${iconHtml}${esc(String(p.label ?? ""))}</span>`;
157
218
  }
158
219
  case "image": {
159
220
  const url = esc(String(p.url ?? ""));
160
- const aspect = String(p.aspect ?? "16:9");
221
+ const aspect = String(p.aspect ?? "1:1");
161
222
  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>`;
223
+ const ratio = w && h ? `${w}/${h}` : "1/1";
224
+ return `<div style="flex:1;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
225
  }
165
226
  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
227
  const descHtml = p.description ? `<div style="font-size:13px;color:#6B7280;margin-top:2px">${esc(String(p.description))}</div>` : "";
173
228
  const childIds = el.children ?? [];
174
229
  const actionsHtml = childIds.length > 0
175
230
  ? `<div style="margin-left:auto;padding-left:12px;display:flex;align-items:center;gap:4px">${childIds.map((id) => renderElement(id, spec, accent)).join("")}</div>`
176
231
  : "";
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>`;
232
+ return `<div style="display:flex;align-items:flex-start;padding:6px 10px"><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
233
  }
179
234
  case "item_group": {
180
235
  const childIds = el.children ?? [];
@@ -195,10 +250,9 @@ function renderElement(
195
250
  case "progress": {
196
251
  const value = Number(p.value ?? 0);
197
252
  const max = Number(p.max ?? 100);
198
- const color = accent;
199
253
  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>`;
254
+ const labelHtml = p.label ? `<div style="font-size:12px;color:#6B7280;margin-bottom:4px">${esc(String(p.label))}</div>` : "";
255
+ return `<div style="display:flex;flex:1;flex-direction:column;gap:4px">${labelHtml}<div style="height:10px;background:#E5E7EB;border-radius:9999px;overflow:hidden"><div style="height:100%;width:${pct}%;background:${accent};border-radius:9999px;transition:width 0.3s"></div></div></div>`;
202
256
  }
203
257
  case "separator": {
204
258
  const orientation = String(p.orientation ?? "horizontal");
@@ -209,67 +263,79 @@ function renderElement(
209
263
  const min = Number(p.min ?? 0);
210
264
  const max = Number(p.max ?? 100);
211
265
  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>`;
266
+ const labelHtml = p.label ? `<div style="font-size:13px;font-weight:500;color:#374151;margin-bottom:6px">${esc(String(p.label))}</div>` : "";
267
+ return `<div style="display:flex;flex-direction:column;gap:6px">${labelHtml}<input type="range" min="${min}" max="${max}" value="${value}" disabled style="width:100%;height:10px;border-radius:9999px;accent-color:${accent};background:#E5E7EB;-webkit-appearance:none;appearance:none"></div>`;
214
268
  }
215
269
  case "switch": {
216
270
  const checked = Boolean(p.defaultChecked);
217
271
  const bg = checked ? accent : "#D1D5DB";
218
272
  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>`;
273
+ return `<div style="display:flex;align-items:center;justify-content:space-between;gap:12px"><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;transition:background 0.2s"><div style="width:20px;height:20px;background:#fff;border-radius:50%;position:absolute;top:2px;left:${tx};transition:left 0.2s;box-shadow:0 1px 3px rgba(0,0,0,0.2)"></div></div></div>`;
220
274
  }
221
275
  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>`;
276
+ const labelHtml = p.label ? `<label style="display:block;font-size:13px;font-weight:500;color:#374151;margin-bottom:6px">${esc(String(p.label))}</label>` : "";
277
+ return `<div style="display:flex;flex-direction:column;gap:6px">${labelHtml}<input type="text" placeholder="${esc(String(p.placeholder ?? ""))}" readonly style="width:100%;padding:10px 12px;border-radius:8px;border:1px solid #E5E7EB;background:#fff;font-size:14px;color:#374151;font-family:inherit;box-sizing:border-box"></div>`;
224
278
  }
225
279
  case "toggle_group": {
226
- const options = Array.isArray(p.options) ? p.options as string[] : [];
280
+ const options = Array.isArray(p.options) ? (p.options as string[]) : [];
227
281
  const orientation = String(p.orientation ?? "horizontal");
228
282
  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">`;
283
+ const defaultVal = p.defaultValue !== undefined ? String(p.defaultValue) : undefined;
284
+ const labelHtml = p.label ? `<div style="font-size:13px;font-weight:500;color:#374151;margin-bottom:6px">${esc(String(p.label))}</div>` : "";
285
+ let html = `<div>${labelHtml}<div style="display:flex;flex-direction:${dir};gap:4px;padding:4px;background:rgba(229,231,235,0.2);border-radius:8px">`;
231
286
  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>`;
287
+ const selected = defaultVal === opt;
288
+ const optBg = selected ? accent : "transparent";
289
+ const optColor = selected ? fgForBg(accent) : "#374151";
290
+ const optWeight = selected ? "600" : "500";
291
+ html += `<button onclick="showModal()" style="flex:1;padding:8px 12px;border-radius:6px;border:none;background:${optBg};font-size:13px;font-weight:${optWeight};color:${optColor};cursor:pointer;font-family:inherit;transition:background 0.15s,color 0.15s">${esc(opt)}</button>`;
233
292
  }
234
293
  html += `</div></div>`;
235
294
  return html;
236
295
  }
237
296
  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>`;
297
+ const variant = String(p.variant ?? "secondary");
298
+ const isPrimary = variant === "primary";
299
+ const fg = isPrimary ? fgForBg(accent) : accent;
300
+ const bg = isPrimary ? accent : "transparent";
301
+ const border = isPrimary ? "none" : `2px solid ${accent}`;
302
+ const pad = isPrimary ? "14px 16px" : "10px 16px";
303
+ const minH = isPrimary ? "min-height:44px;" : "";
304
+ const iconName = p.icon ? String(p.icon) : undefined;
305
+ const iconHtml = iconName ? renderIcon(iconName, 16, fg) : "";
306
+ const gap = iconHtml ? "gap:8px;" : "";
307
+ return `<button onclick="showModal()" style="display:inline-flex;align-items:center;justify-content:center;${gap}width:100%;${minH}padding:${pad};border-radius:10px;background:${bg};color:${fg};border:${border};font-size:14px;font-weight:600;cursor:pointer;font-family:inherit;box-sizing:border-box">${iconHtml}${esc(String(p.label ?? ""))}</button>`;
245
308
  }
246
309
  case "text": {
247
310
  const size = String(p.size ?? "md");
248
- const weight = String(p.weight ?? (size === "lg" ? "bold" : "normal"));
311
+ const weight = String(p.weight ?? "normal");
249
312
  const align = String(p.align ?? "left");
250
313
  const styles: Record<string, string> = {
251
- lg: "font-size:20px",
252
314
  md: "font-size:15px;line-height:1.5",
253
- sm: "font-size:13px",
315
+ sm: "font-size:13px;line-height:1.5",
254
316
  };
255
317
  const weights: Record<string, string> = {
256
318
  bold: "font-weight:700",
257
- medium: "font-weight:500",
258
319
  normal: "font-weight:400",
259
320
  };
260
- return `<div style="${styles[size] ?? styles.md};${weights[weight] ?? weights.normal};color:#374151;text-align:${align}">${esc(String(p.content ?? ""))}</div>`;
321
+ return `<div style="flex:1;${styles[size] ?? styles.md};${weights[weight] ?? weights.normal};color:#374151;text-align:${align}">${esc(String(p.content ?? ""))}</div>`;
261
322
  }
262
323
  case "stack": {
263
324
  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";
325
+ const isHorizontal = direction === "horizontal";
326
+ const vGap: Record<string, string> = { none: "0", sm: "8px", md: "16px", lg: "24px" };
327
+ const hGap: Record<string, string> = { none: "0", sm: "4px", md: "8px", lg: "12px" };
328
+ const gapMap = isHorizontal ? hGap : vGap;
329
+ const gapVal = gapMap[String(p.gap ?? "md")] ?? (isHorizontal ? "8px" : "16px");
330
+ const dir = isHorizontal ? "row" : "column";
331
+ const wrap = isHorizontal ? "flex-wrap:wrap;" : "";
332
+ const align = isHorizontal ? "align-items:center;" : "";
267
333
  const justifyMap: Record<string, string> = { start: "flex-start", center: "center", end: "flex-end", between: "space-between", around: "space-around" };
268
334
  const jc = p.justify ? justifyMap[String(p.justify)] : undefined;
269
335
  const childIds = el.children ?? [];
270
- let html = `<div style="display:flex;flex-direction:${dir};gap:${gapVal}${jc ? `;justify-content:${jc}` : ""}">`;
336
+ let html = `<div style="display:flex;width:100%;flex-direction:${dir};gap:${gapVal};${wrap}${align}${jc ? `justify-content:${jc};` : ""}">`;
271
337
  for (const childKey of childIds) {
272
- const flex = direction === "horizontal" ? "flex:1;" : "";
338
+ const flex = isHorizontal ? "flex:1;min-width:0;" : "";
273
339
  html += `<div style="${flex}">${renderElement(childKey, spec, accent)}</div>`;
274
340
  }
275
341
  html += `</div>`;
@@ -317,17 +383,18 @@ ${ogMeta}
317
383
  *{margin:0;padding:0;box-sizing:border-box}
318
384
  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}
319
385
  .card{background:#fff;border-radius:16px;max-width:420px;width:100%;padding:20px;box-shadow:0 4px 24px rgba(0,0,0,0.3)}
386
+ .card button:hover{filter:brightness(0.92)}
320
387
  .foot{margin-top:16px;text-align:center}
321
- .foot a{color:#8B5CF6;text-decoration:none;font-size:13px;display:inline-flex;align-items:center;gap:6px}
388
+ .foot a{color:${accent};text-decoration:none;font-size:13px;display:inline-flex;align-items:center;gap:6px}
322
389
  .foot a:hover{opacity:.8}
323
390
  .foot svg{width:14px;height:12px}
324
391
  .modal{display:none;position:fixed;inset:0;background:rgba(0,0,0,0.7);align-items:center;justify-content:center;z-index:99}
325
392
  .modal-box{background:#1A1A2E;border-radius:16px;padding:32px;text-align:center;max-width:340px;width:90%}
326
- .modal-box svg{width:40px;height:35px;color:#8B5CF6;margin-bottom:16px}
393
+ .modal-box svg{width:40px;height:35px;color:${accent};margin-bottom:16px}
327
394
  .modal-box h2{color:#FAFAFA;font-size:20px;margin-bottom:8px}
328
395
  .modal-box p{color:#A1A1AA;font-size:14px;line-height:1.5;margin-bottom:24px}
329
396
  .modal-box a{display:block;padding:12px;border-radius:10px;text-decoration:none;font-weight:600;font-size:15px;margin-bottom:12px}
330
- .mb-primary{background:#8B5CF6;color:#fff}
397
+ .mb-primary{background:${accent};color:${fgForBg(accent)}}
331
398
  .mb-secondary{background:#1A1A2E;color:#FAFAFA;border:1px solid #2D2D44}
332
399
  .modal-box a:hover{opacity:.85}
333
400
  .modal-box button{background:none;border:none;color:#A1A1AA;cursor:pointer;font-size:13px;font-family:inherit}