@farcaster/snap-hono 1.4.2 → 1.4.4

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/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);
@@ -480,51 +521,60 @@ function mapBarChart(el: El, accent: string): VNode {
480
521
  bar.color !== undefined && bar.color !== ""
481
522
  ? colorHex(bar.color as string, accent)
482
523
  : chartDefault;
483
- const pct = maxVal > 0 ? (bar.value / maxVal) * 100 : 0;
524
+ const pct = maxVal > 0 ? Math.min(100, (bar.value / maxVal) * 100) : 0;
484
525
  return h(
485
526
  "div",
486
- {
487
- display: "flex",
488
- flex: 1,
489
- flexDirection: "column",
490
- alignItems: "center",
491
- height: "100%",
492
- justifyContent: "flex-end",
493
- },
494
- h(
495
- "div",
496
- { display: "flex", fontSize: 11, color: "#6B7280", marginBottom: 4 },
497
- String(bar.value),
498
- ),
499
- h("div", {
500
- display: "flex",
501
- width: "100%",
502
- height: `${pct}%`,
503
- backgroundColor: color,
504
- borderRadius: "4px 4px 0 0",
505
- minHeight: 4,
506
- }),
527
+ { display: "flex", flexDirection: "row", alignItems: "center", gap: 8, width: OG_CARD_INNER_WIDTH_PX },
528
+ h("div", { display: "flex", width: 80, fontSize: 12, color: "#6B7280", justifyContent: "flex-end" }, bar.label.slice(0, 20)),
507
529
  h(
508
530
  "div",
509
- { display: "flex", fontSize: 11, color: "#9CA3AF", marginTop: 4 },
510
- bar.label.slice(0, 12),
531
+ { display: "flex", flex: 1, height: 10, backgroundColor: "#E5E7EB", borderRadius: 9999, overflow: "hidden" },
532
+ h("div", { display: "flex", height: 10, width: `${pct}%`, backgroundColor: color, borderRadius: 9999 }),
511
533
  ),
534
+ h("div", { display: "flex", width: 32, fontSize: 12, color: "#6B7280" }, String(bar.value)),
512
535
  );
513
536
  });
514
537
  return h(
515
538
  "div",
516
- {
517
- display: "flex",
518
- flexDirection: "row",
519
- alignItems: "flex-end",
520
- gap: 12,
521
- height: 100,
522
- width: "100%",
523
- },
539
+ { display: "flex", flexDirection: "column", gap: 8, width: OG_CARD_INNER_WIDTH_PX },
524
540
  ...barNodes,
525
541
  );
526
542
  }
527
543
 
544
+ function mapCellGrid(el: El, accent: string): VNode {
545
+ const cols = Number(el.cols ?? 2);
546
+ const rows = Number(el.rows ?? 2);
547
+ const cells = Array.isArray(el.cells) ? (el.cells as Array<{ row?: number; col?: number; color?: string; content?: string }>) : [];
548
+ const gap = String(el.gap ?? "sm");
549
+ const gapMap: Record<string, number> = { none: 0, sm: 1, md: 2, lg: 4 };
550
+ const gapPx = gapMap[gap] ?? 1;
551
+ const cellW = Math.floor((OG_CARD_INNER_WIDTH_PX - (cols - 1) * gapPx) / cols);
552
+
553
+ const cellMap = new Map<string, { color?: string; content?: string }>();
554
+ for (const c of cells) {
555
+ cellMap.set(`${Number(c.row ?? 0)},${Number(c.col ?? 0)}`, { color: c.color, content: c.content });
556
+ }
557
+
558
+ const rowNodes = [];
559
+ for (let r = 0; r < rows; r++) {
560
+ const cellNodes = [];
561
+ for (let c = 0; c < cols; c++) {
562
+ const cell = cellMap.get(`${r},${c}`);
563
+ const bg = cell?.color ? colorHex(cell.color, accent) : "#F3F4F6";
564
+ cellNodes.push(
565
+ h("div", {
566
+ display: "flex", alignItems: "center", justifyContent: "center",
567
+ width: cellW, height: cellW > 28 ? 28 : cellW, borderRadius: 4,
568
+ backgroundColor: bg, border: "1px solid #E5E7EB",
569
+ fontSize: 10, fontWeight: 600, color: "#374151",
570
+ }, cell?.content ?? ""),
571
+ );
572
+ }
573
+ rowNodes.push(h("div", { display: "flex", flexDirection: "row", gap: gapPx }, ...cellNodes));
574
+ }
575
+ return h("div", { display: "flex", flexDirection: "column", gap: gapPx, width: OG_CARD_INNER_WIDTH_PX }, ...rowNodes);
576
+ }
577
+
528
578
  function mapElement(
529
579
  el: El,
530
580
  accent: string,
@@ -534,18 +584,64 @@ function mapElement(
534
584
  switch (type) {
535
585
  case "text":
536
586
  return mapText(el);
587
+ case "item":
588
+ return mapItem(el);
589
+ case "badge":
590
+ return mapBadge(el, accent);
537
591
  case "image":
538
592
  return mapImage(el, imageMap);
593
+ case "separator":
539
594
  case "divider":
540
- return mapDivider();
595
+ return mapSeparator();
541
596
  case "progress":
542
597
  return mapProgress(el, accent);
543
- case "list":
544
- return mapList(el);
545
598
  case "toggle_group":
546
599
  return mapButtonGroup(el, accent);
600
+ case "input": {
601
+ const label = el.label ? String(el.label) : "";
602
+ const placeholder = el.placeholder ? String(el.placeholder) : "";
603
+ return h(
604
+ "div",
605
+ { display: "flex", flexDirection: "column", gap: 6, width: OG_CARD_INNER_WIDTH_PX },
606
+ label ? h("div", { display: "flex", fontSize: 13, fontWeight: 500, color: "#374151" }, label) : null,
607
+ h("div", {
608
+ display: "flex", padding: "10px 12px", borderRadius: 8,
609
+ border: "1px solid #E5E7EB", backgroundColor: "#fff",
610
+ fontSize: 14, color: "#9CA3AF",
611
+ }, placeholder || " "),
612
+ );
613
+ }
614
+ case "switch": {
615
+ const label = el.label ? String(el.label) : "";
616
+ const checked = Boolean(el.defaultChecked);
617
+ const bg = checked ? accent : "#D1D5DB";
618
+ return h(
619
+ "div",
620
+ { display: "flex", alignItems: "center", justifyContent: "space-between", width: OG_CARD_INNER_WIDTH_PX },
621
+ h("div", { display: "flex", fontSize: 14, color: "#374151" }, label),
622
+ h("div", { display: "flex", width: 44, height: 24, borderRadius: 12, backgroundColor: bg, position: "relative" },
623
+ h("div", { display: "flex", width: 20, height: 20, borderRadius: 10, backgroundColor: "#fff", position: "absolute", top: 2, left: checked ? 20 : 2 }),
624
+ ),
625
+ );
626
+ }
627
+ case "slider": {
628
+ const label = el.label ? String(el.label) : "";
629
+ return h(
630
+ "div",
631
+ { display: "flex", flexDirection: "column", gap: 6, width: OG_CARD_INNER_WIDTH_PX },
632
+ label ? h("div", { display: "flex", fontSize: 13, fontWeight: 500, color: "#374151" }, label) : null,
633
+ h("div", { display: "flex", height: 10, backgroundColor: "#E5E7EB", borderRadius: 9999, width: "100%" },
634
+ h("div", { display: "flex", height: 10, width: "50%", backgroundColor: accent, borderRadius: 9999 }),
635
+ ),
636
+ );
637
+ }
638
+ // Legacy types kept for backward compat with older specs
639
+ case "list":
640
+ return mapList(el);
547
641
  case "bar_chart":
548
642
  return mapBarChart(el, accent);
643
+ case "cell_grid":
644
+ return mapCellGrid(el, accent);
549
645
  case "group": {
550
646
  const children = (el.children as El[]) ?? [];
551
647
  const childNodes = children
@@ -564,8 +660,8 @@ function mapElement(
564
660
 
565
661
  function mapButton(btn: El, accent: string, i: number): VNode {
566
662
  const label = String(btn.label ?? "");
567
- const style = (btn.style as string) ?? (i === 0 ? "primary" : "secondary");
568
- const isPrimary = style === "primary";
663
+ const variant = (btn.variant as string) ?? (btn.style as string) ?? "secondary";
664
+ const isPrimary = variant === "primary";
569
665
  // Primary CTA: generous vertical padding + minHeight so Satori/Yoga renders a tall tap target
570
666
  // (small padding deltas are easy to miss; flexBasis:0 rows can also under-measure height).
571
667
  const py = isPrimary ? 18 : 10;
@@ -606,21 +702,20 @@ function linesForWrappedText(
606
702
  }
607
703
 
608
704
  function estimateTextHeight(el: El): number {
609
- const style = (el.style as string) ?? "body";
705
+ const size = String(el.size ?? "md");
610
706
  const content = String(el.content ?? "");
611
707
  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
- }
708
+ if (size === "sm") return linesForWrappedText(content.length, w, 7) * 20;
709
+ return linesForWrappedText(content.length, w, 7.5) * 23;
710
+ }
711
+
712
+ function estimateItemHeight(el: El): number {
713
+ const title = String(el.title ?? "");
714
+ const desc = el.description ? String(el.description) : "";
715
+ const w = OG_CARD_INNER_WIDTH_PX;
716
+ let total = linesForWrappedText(title.length, w, 7.5) * 23 + 12;
717
+ if (desc) total += linesForWrappedText(desc.length, w, 7) * 20;
718
+ return total;
624
719
  }
625
720
 
626
721
  function estimateImageHeight(el: El, imageMap: Map<string, string>): number {
@@ -688,18 +783,38 @@ function estimateElementHeight(el: El, imageMap: Map<string, string>): number {
688
783
  switch (type) {
689
784
  case "text":
690
785
  return estimateTextHeight(el);
786
+ case "item":
787
+ return estimateItemHeight(el);
788
+ case "badge":
789
+ return 24;
691
790
  case "image":
692
791
  return estimateImageHeight(el, imageMap);
792
+ case "separator":
693
793
  case "divider":
694
794
  return 1;
695
795
  case "progress":
696
796
  return estimateProgressHeight(el);
797
+ case "input":
798
+ return (el.label ? 20 : 0) + 42;
799
+ case "switch":
800
+ return 28;
801
+ case "slider":
802
+ return (el.label ? 20 : 0) + 16;
697
803
  case "list":
698
804
  return estimateListHeight(el);
699
805
  case "toggle_group":
700
806
  return estimateButtonGroupHeight(el);
701
- case "bar_chart":
702
- return 100;
807
+ case "bar_chart": {
808
+ const bars = Array.isArray(el.bars) ? el.bars : [];
809
+ return Math.max(1, bars.length) * 26;
810
+ }
811
+ case "cell_grid": {
812
+ const rows = Number(el.rows ?? 2);
813
+ const gap = String(el.gap ?? "sm");
814
+ const gapMap: Record<string, number> = { none: 0, sm: 1, md: 2, lg: 4 };
815
+ const gapPx = gapMap[gap] ?? 1;
816
+ return rows * 28 + (rows - 1) * gapPx;
817
+ }
703
818
  case "group": {
704
819
  const children = (el.children as El[]) ?? [];
705
820
  if (children.length === 0) return 0;
@@ -767,17 +882,25 @@ function estimateDefaultOgHeight(
767
882
 
768
883
  // ─── Spec helpers ─────────────────────────────────────
769
884
 
770
- /** Walk the flat spec from root and collect top-level children as El objects for the OG renderer. */
885
+ /** Walk the flat spec from root, recursing into stack containers, and collect leaf elements as El objects. */
771
886
  function specToElementList(spec: SnapSpec): El[] {
887
+ function collect(keys: string[]): El[] {
888
+ const result: El[] = [];
889
+ for (const key of keys) {
890
+ const el = spec.elements[key];
891
+ if (!el) continue;
892
+ // Recurse into stack and item_group containers
893
+ if ((el.type === "stack" || el.type === "item_group") && el.children?.length) {
894
+ result.push(...collect(el.children));
895
+ } else {
896
+ result.push({ type: el.type, ...el.props } as El);
897
+ }
898
+ }
899
+ return result;
900
+ }
772
901
  const rootEl = spec.elements[spec.root];
773
902
  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);
903
+ return collect(rootEl.children);
781
904
  }
782
905
 
783
906
  /** Extract button elements (type: "button") from the spec. */