@farcaster/snap 2.1.1 → 2.2.0

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.
Files changed (68) hide show
  1. package/dist/colors.d.ts +10 -0
  2. package/dist/colors.js +22 -0
  3. package/dist/constants.d.ts +3 -1
  4. package/dist/constants.js +7 -2
  5. package/dist/index.d.ts +3 -3
  6. package/dist/index.js +3 -3
  7. package/dist/react/components/cell-grid.js +13 -14
  8. package/dist/react/components/image.js +5 -1
  9. package/dist/react/components/stack.js +53 -3
  10. package/dist/react/components/text.js +7 -1
  11. package/dist/react/hooks/use-snap-colors.js +2 -9
  12. package/dist/react/stack-direction-context.d.ts +7 -0
  13. package/dist/react/stack-direction-context.js +10 -0
  14. package/dist/react-native/components/snap-cell-grid.js +5 -7
  15. package/dist/react-native/components/snap-image.js +15 -2
  16. package/dist/react-native/components/snap-item.js +12 -2
  17. package/dist/react-native/components/snap-progress.js +8 -2
  18. package/dist/react-native/components/snap-stack.d.ts +1 -1
  19. package/dist/react-native/components/snap-stack.js +85 -10
  20. package/dist/react-native/components/snap-text.js +7 -2
  21. package/dist/react-native/stack-direction-context.d.ts +7 -0
  22. package/dist/react-native/stack-direction-context.js +9 -0
  23. package/dist/react-native/use-snap-palette.js +2 -2
  24. package/dist/schemas.d.ts +52 -0
  25. package/dist/schemas.js +10 -4
  26. package/dist/server/index.d.ts +2 -1
  27. package/dist/server/index.js +2 -1
  28. package/dist/server/parseRequest.d.ts +4 -3
  29. package/dist/server/parseRequest.js +91 -67
  30. package/dist/server/verify.d.ts +12 -5
  31. package/dist/server/verify.js +67 -19
  32. package/dist/stack-horizontal-utils.d.ts +4 -0
  33. package/dist/stack-horizontal-utils.js +29 -0
  34. package/dist/ui/catalog.d.ts +3 -2
  35. package/dist/ui/catalog.js +2 -2
  36. package/dist/ui/cell-grid.d.ts +2 -2
  37. package/dist/ui/cell-grid.js +11 -2
  38. package/dist/ui/stack.d.ts +1 -0
  39. package/dist/ui/stack.js +8 -0
  40. package/dist/verify.test.js +3 -3
  41. package/llms.txt +13 -2
  42. package/package.json +1 -1
  43. package/src/colors.ts +27 -0
  44. package/src/constants.ts +8 -2
  45. package/src/index.ts +6 -0
  46. package/src/react/components/cell-grid.tsx +17 -24
  47. package/src/react/components/image.tsx +8 -1
  48. package/src/react/components/stack.tsx +84 -11
  49. package/src/react/components/text.tsx +8 -1
  50. package/src/react/hooks/use-snap-colors.ts +3 -8
  51. package/src/react/stack-direction-context.tsx +27 -0
  52. package/src/react-native/components/snap-cell-grid.tsx +5 -11
  53. package/src/react-native/components/snap-image.tsx +17 -2
  54. package/src/react-native/components/snap-item.tsx +14 -2
  55. package/src/react-native/components/snap-progress.tsx +8 -2
  56. package/src/react-native/components/snap-stack.tsx +116 -14
  57. package/src/react-native/components/snap-text.tsx +7 -2
  58. package/src/react-native/stack-direction-context.tsx +25 -0
  59. package/src/react-native/use-snap-palette.ts +2 -1
  60. package/src/schemas.ts +14 -4
  61. package/src/server/index.ts +7 -1
  62. package/src/server/parseRequest.ts +117 -71
  63. package/src/server/verify.ts +99 -26
  64. package/src/stack-horizontal-utils.ts +27 -0
  65. package/src/ui/catalog.ts +2 -2
  66. package/src/ui/cell-grid.ts +15 -2
  67. package/src/ui/stack.ts +8 -0
  68. package/src/verify.test.ts +3 -3
package/dist/colors.d.ts CHANGED
@@ -24,6 +24,16 @@ export declare const PALETTE_COLOR_ACCENT: "accent";
24
24
  export declare const DEFAULT_THEME_ACCENT: "purple";
25
25
  export declare const PALETTE_COLOR_VALUES: readonly ["gray", "blue", "red", "amber", "green", "teal", "purple", "pink"];
26
26
  export type PaletteColor = (typeof PALETTE_COLOR_VALUES)[number];
27
+ export declare function isSnapHexColorString(s: string): boolean;
28
+ /**
29
+ * Resolve a snap color token for inline styles: `accent`, palette names, or
30
+ * literal `#rrggbb`. Unknown values fall back to `accentHex` (same as legacy
31
+ * `colorHex` behavior for non-hex strings).
32
+ */
33
+ export declare function resolveSnapColorHex(color: string | undefined, opts: {
34
+ accentHex: string;
35
+ appearance: "light" | "dark";
36
+ }): string;
27
37
  /** Light-mode hex for each palette color (emulator / reference client). */
28
38
  export declare const PALETTE_LIGHT_HEX: Record<PaletteColor, string>;
29
39
  /** Dark-mode hex for each palette color (reference). */
package/dist/colors.js CHANGED
@@ -32,6 +32,28 @@ export const PALETTE_COLOR_VALUES = [
32
32
  PALETTE_COLOR.purple,
33
33
  PALETTE_COLOR.pink,
34
34
  ];
35
+ /** Strict `#rrggbb` literal used by cell_grid (and clients that accept hex). */
36
+ const SNAP_HEX_6 = /^#[0-9a-fA-F]{6}$/;
37
+ export function isSnapHexColorString(s) {
38
+ return SNAP_HEX_6.test(s.trim());
39
+ }
40
+ /**
41
+ * Resolve a snap color token for inline styles: `accent`, palette names, or
42
+ * literal `#rrggbb`. Unknown values fall back to `accentHex` (same as legacy
43
+ * `colorHex` behavior for non-hex strings).
44
+ */
45
+ export function resolveSnapColorHex(color, opts) {
46
+ if (!color || color === PALETTE_COLOR_ACCENT)
47
+ return opts.accentHex;
48
+ const trimmed = color.trim();
49
+ if (isSnapHexColorString(trimmed))
50
+ return trimmed;
51
+ const map = opts.appearance === "dark" ? PALETTE_DARK_HEX : PALETTE_LIGHT_HEX;
52
+ if (Object.hasOwn(map, trimmed)) {
53
+ return map[trimmed];
54
+ }
55
+ return opts.accentHex;
56
+ }
35
57
  /** Light-mode hex for each palette color (emulator / reference client). */
36
58
  export const PALETTE_LIGHT_HEX = {
37
59
  gray: "#6E6A86",
@@ -3,6 +3,7 @@ export declare const SPEC_VERSION_2: "2.0";
3
3
  export declare const SPEC_VERSION: "2.0";
4
4
  export declare const SUPPORTED_SPEC_VERSIONS: readonly ["1.0", "2.0"];
5
5
  export type SpecVersion = (typeof SUPPORTED_SPEC_VERSIONS)[number];
6
+ export declare const SNAP_PAYLOAD_HEADER: "X-Snap-Payload";
6
7
  export declare const MEDIA_TYPE: "application/vnd.farcaster.snap+json";
7
8
  export declare const EFFECT_VALUES: readonly ["confetti"];
8
9
  export declare const POST_GRID_TAP_KEY: "grid_tap";
@@ -14,6 +15,7 @@ export declare const GRID_GAP_VALUES: readonly ["none", "sm", "md", "lg"];
14
15
  export declare const MAX_ELEMENTS = 64;
15
16
  export declare const MAX_ROOT_CHILDREN = 7;
16
17
  export declare const MAX_CHILDREN = 6;
17
- export declare const MAX_DEPTH = 4;
18
+ /** Enough depth for side-by-side columns that contain labeled horizontal icon rows (pair → column → row → icon). */
19
+ export declare const MAX_DEPTH = 5;
18
20
  export declare const BAR_CHART_MAX_BARS = 6;
19
21
  export declare const BAR_CHART_LABEL_MAX_CHARS = 40;
package/dist/constants.js CHANGED
@@ -1,7 +1,11 @@
1
1
  export const SPEC_VERSION_1 = "1.0";
2
2
  export const SPEC_VERSION_2 = "2.0";
3
3
  export const SPEC_VERSION = SPEC_VERSION_2;
4
- export const SUPPORTED_SPEC_VERSIONS = [SPEC_VERSION_1, SPEC_VERSION_2];
4
+ export const SUPPORTED_SPEC_VERSIONS = [
5
+ SPEC_VERSION_1,
6
+ SPEC_VERSION_2,
7
+ ];
8
+ export const SNAP_PAYLOAD_HEADER = "X-Snap-Payload";
5
9
  export const MEDIA_TYPE = "application/vnd.farcaster.snap+json";
6
10
  export const EFFECT_VALUES = ["confetti"];
7
11
  // ─── Pixel grid ────────────────────────────────────────
@@ -15,7 +19,8 @@ export const GRID_GAP_VALUES = ["none", "sm", "md", "lg"];
15
19
  export const MAX_ELEMENTS = 64;
16
20
  export const MAX_ROOT_CHILDREN = 7;
17
21
  export const MAX_CHILDREN = 6;
18
- export const MAX_DEPTH = 4;
22
+ /** Enough depth for side-by-side columns that contain labeled horizontal icon rows (pair → column → row → icon). */
23
+ export const MAX_DEPTH = 5;
19
24
  // ─── Bar chart ─────────────────────────────────────────
20
25
  export const BAR_CHART_MAX_BARS = 6;
21
26
  export const BAR_CHART_LABEL_MAX_CHARS = 40;
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export type { Spec as SnapSpec, UIElement as SnapUIElement, } from "@json-render/core";
2
- export { SPEC_VERSION, SPEC_VERSION_1, SPEC_VERSION_2, SUPPORTED_SPEC_VERSIONS, type SpecVersion, MEDIA_TYPE, EFFECT_VALUES, POST_GRID_TAP_KEY, MAX_ELEMENTS, MAX_ROOT_CHILDREN, MAX_CHILDREN, MAX_DEPTH, } from "./constants.js";
3
- export { DEFAULT_THEME_ACCENT, PALETTE_COLOR, PALETTE_COLOR_ACCENT, PALETTE_COLOR_VALUES, PALETTE_LIGHT_HEX, PALETTE_DARK_HEX, type PaletteColor, } from "./colors.js";
4
- export { ACTION_TYPE_GET, ACTION_TYPE_POST, snapResponseSchema, payloadSchema, type SnapAction, type SnapContext, type SnapResponse, type SnapHandlerResult, type SnapElementInput, type SnapSpecInput, type SnapFunction, type SnapPayload, } from "./schemas.js";
2
+ export { SPEC_VERSION, SPEC_VERSION_1, SPEC_VERSION_2, SUPPORTED_SPEC_VERSIONS, type SpecVersion, SNAP_PAYLOAD_HEADER, MEDIA_TYPE, EFFECT_VALUES, POST_GRID_TAP_KEY, MAX_ELEMENTS, MAX_ROOT_CHILDREN, MAX_CHILDREN, MAX_DEPTH, } from "./constants.js";
3
+ export { DEFAULT_THEME_ACCENT, PALETTE_COLOR, PALETTE_COLOR_ACCENT, PALETTE_COLOR_VALUES, PALETTE_LIGHT_HEX, PALETTE_DARK_HEX, isSnapHexColorString, resolveSnapColorHex, type PaletteColor, } from "./colors.js";
4
+ export { ACTION_TYPE_GET, ACTION_TYPE_POST, snapResponseSchema, payloadSchema, getPayloadSchema, type SnapAction, type SnapGetAction, type SnapContext, type SnapResponse, type SnapHandlerResult, type SnapElementInput, type SnapSpecInput, type SnapFunction, type SnapPayload, type SnapGetPayload, } from "./schemas.js";
5
5
  export { validateSnapResponse, type ValidationResult } from "./validator.js";
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- export { SPEC_VERSION, SPEC_VERSION_1, SPEC_VERSION_2, SUPPORTED_SPEC_VERSIONS, MEDIA_TYPE, EFFECT_VALUES, POST_GRID_TAP_KEY, MAX_ELEMENTS, MAX_ROOT_CHILDREN, MAX_CHILDREN, MAX_DEPTH, } from "./constants.js";
2
- export { DEFAULT_THEME_ACCENT, PALETTE_COLOR, PALETTE_COLOR_ACCENT, PALETTE_COLOR_VALUES, PALETTE_LIGHT_HEX, PALETTE_DARK_HEX, } from "./colors.js";
3
- export { ACTION_TYPE_GET, ACTION_TYPE_POST, snapResponseSchema, payloadSchema, } from "./schemas.js";
1
+ export { SPEC_VERSION, SPEC_VERSION_1, SPEC_VERSION_2, SUPPORTED_SPEC_VERSIONS, SNAP_PAYLOAD_HEADER, MEDIA_TYPE, EFFECT_VALUES, POST_GRID_TAP_KEY, MAX_ELEMENTS, MAX_ROOT_CHILDREN, MAX_CHILDREN, MAX_DEPTH, } from "./constants.js";
2
+ export { DEFAULT_THEME_ACCENT, PALETTE_COLOR, PALETTE_COLOR_ACCENT, PALETTE_COLOR_VALUES, PALETTE_LIGHT_HEX, PALETTE_DARK_HEX, isSnapHexColorString, resolveSnapColorHex, } from "./colors.js";
3
+ export { ACTION_TYPE_GET, ACTION_TYPE_POST, snapResponseSchema, payloadSchema, getPayloadSchema, } from "./schemas.js";
4
4
  export { validateSnapResponse } from "./validator.js";
@@ -1,5 +1,5 @@
1
1
  "use client";
2
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
3
  import { useStateStore } from "@json-render/react";
4
4
  import { cn } from "@neynar/ui/utils";
5
5
  import { POST_GRID_TAP_KEY } from "@farcaster/snap";
@@ -54,12 +54,14 @@ export function SnapCellGrid({ element: { props, on }, emit, }) {
54
54
  content: c.content != null ? String(c.content) : undefined,
55
55
  });
56
56
  }
57
+ /** Cells without a palette `color` — subtle fill so empty slots read as tiles. */
58
+ const emptyCellBg = colors.mode === "dark" ? "rgba(255, 255, 255, 0.05)" : "rgba(0, 0, 0, 0.05)";
57
59
  const cellEls = [];
58
60
  for (let r = 0; r < rows; r++) {
59
61
  for (let c = 0; c < cols; c++) {
60
62
  const cell = cellMap.get(`${r},${c}`);
61
63
  const selected = interactive && isSelected(r, c);
62
- const bg = cell?.color ? colors.colorHex(cell.color) : "transparent";
64
+ const bg = cell?.color ? colors.colorHex(cell.color) : emptyCellBg;
63
65
  cellEls.push(_jsx("div", { role: interactive ? "button" : undefined, tabIndex: interactive ? 0 : undefined, onClick: interactive ? () => handleTap(r, c) : undefined, onKeyDown: interactive
64
66
  ? (e) => {
65
67
  if (e.key === "Enter" || e.key === " ") {
@@ -76,16 +78,13 @@ export function SnapCellGrid({ element: { props, on }, emit, }) {
76
78
  }, children: cell?.content ?? "" }, `${r}-${c}`));
77
79
  }
78
80
  }
79
- const selectionLabel = isSelectable && selectedSet.size > 0
80
- ? `inputs.${name}: ${[...selectedSet].join(isMultiple ? " | " : "")}`
81
- : null;
82
- return (_jsxs("div", { children: [_jsx("div", { style: {
83
- display: "grid",
84
- width: "100%",
85
- gridTemplateColumns: `repeat(${cols}, 1fr)`,
86
- gap: gapPx,
87
- padding: 4,
88
- borderRadius: 8,
89
- backgroundColor: colors.muted,
90
- }, children: cellEls }), selectionLabel && (_jsx("div", { className: "mt-1.5 truncate text-xs font-mono", style: { color: colors.textMuted }, children: selectionLabel }))] }));
81
+ return (_jsx("div", { style: {
82
+ display: "grid",
83
+ width: "100%",
84
+ gridTemplateColumns: `repeat(${cols}, 1fr)`,
85
+ gap: gapPx,
86
+ padding: 4,
87
+ borderRadius: 8,
88
+ backgroundColor: colors.muted,
89
+ }, children: cellEls }));
91
90
  }
@@ -1,6 +1,8 @@
1
1
  "use client";
2
2
  import { jsx as _jsx } from "react/jsx-runtime";
3
3
  import { AspectRatio } from "@neynar/ui/aspect-ratio";
4
+ import { cn } from "@neynar/ui/utils";
5
+ import { useSnapStackDirection } from "../stack-direction-context.js";
4
6
  function aspectToRatio(aspect) {
5
7
  const [w, h] = aspect.split(":").map(Number);
6
8
  if (!w || !h)
@@ -11,5 +13,7 @@ export function SnapImage({ element: { props }, }) {
11
13
  const url = String(props.url ?? "");
12
14
  const alt = String(props.alt ?? "");
13
15
  const ratio = aspectToRatio(String(props.aspect ?? "1:1"));
14
- return (_jsx(AspectRatio, { ratio: ratio, className: "relative w-full flex-1 overflow-hidden rounded-lg", children: _jsx("img", { src: url, alt: alt, className: "absolute inset-0 size-full object-cover" }) }));
16
+ const stackDir = useSnapStackDirection();
17
+ const inHorizontalStack = stackDir === "horizontal";
18
+ return (_jsx(AspectRatio, { ratio: ratio, className: cn("relative overflow-hidden rounded-lg", inHorizontalStack ? "min-w-0 flex-1 basis-0" : "w-full"), children: _jsx("img", { src: url, alt: alt, className: "absolute inset-0 size-full object-cover" }) }));
15
19
  }
@@ -1,6 +1,8 @@
1
1
  "use client";
2
2
  import { jsx as _jsx } from "react/jsx-runtime";
3
3
  import { cn } from "@neynar/ui/utils";
4
+ import { countRenderableChildren, horizontalChildrenAreAllButtons, } from "../../stack-horizontal-utils.js";
5
+ import { SnapStackDirectionProvider, useSnapStackDirection, } from "../stack-direction-context.js";
4
6
  const VGAP = {
5
7
  none: "gap-0",
6
8
  sm: "gap-2",
@@ -13,20 +15,68 @@ const HGAP = {
13
15
  md: "gap-2",
14
16
  lg: "gap-3",
15
17
  };
16
- const JUSTIFY = {
18
+ const JUSTIFY_FLEX = {
17
19
  start: "justify-start",
18
20
  center: "justify-center",
19
21
  end: "justify-end",
20
22
  between: "justify-between",
21
23
  around: "justify-around",
22
24
  };
25
+ /** Equal columns for explicit `columns` prop and for all-button horizontal rows. */
26
+ const COLUMN_GRID_CLASS = {
27
+ 1: "grid grid-cols-1 auto-rows-auto items-stretch [&>*]:min-w-0",
28
+ 2: "grid grid-cols-2 auto-rows-auto items-stretch [&>*]:min-w-0",
29
+ 3: "grid grid-cols-3 auto-rows-auto items-stretch [&>*]:min-w-0",
30
+ 4: "grid grid-cols-4 auto-rows-auto items-stretch [&>*]:min-w-0",
31
+ 5: "grid grid-cols-5 auto-rows-auto items-stretch [&>*]:min-w-0",
32
+ 6: "grid grid-cols-6 auto-rows-auto items-stretch [&>*]:min-w-0",
33
+ };
23
34
  export function SnapStack({ element: { props }, children, }) {
35
+ const parentDirection = useSnapStackDirection();
24
36
  const direction = String(props.direction ?? "vertical");
25
37
  const gapKey = String(props.gap ?? "md");
26
38
  const isHorizontal = direction === "horizontal";
27
39
  const gap = isHorizontal
28
40
  ? (HGAP[gapKey] ?? "gap-2")
29
41
  : (VGAP[gapKey] ?? "gap-4");
30
- const justify = props.justify ? JUSTIFY[String(props.justify)] : undefined;
31
- return (_jsx("div", { className: cn("flex w-full", isHorizontal ? "flex-row items-center flex-wrap" : "flex-col", gap, justify), children: children }));
42
+ const justifyKey = props.justify ? String(props.justify) : undefined;
43
+ const justifyFlex = justifyKey ? JUSTIFY_FLEX[justifyKey] : undefined;
44
+ const buttonRowGrid = isHorizontal && horizontalChildrenAreAllButtons(children);
45
+ const buttonRowCount = buttonRowGrid
46
+ ? countRenderableChildren(children)
47
+ : 0;
48
+ const columnsRaw = props.columns;
49
+ const columns = typeof columnsRaw === "number" &&
50
+ columnsRaw >= 2 &&
51
+ columnsRaw <= 6 &&
52
+ Number.isInteger(columnsRaw)
53
+ ? columnsRaw
54
+ : undefined;
55
+ const explicitColumnGrid = isHorizontal && columns !== undefined && !buttonRowGrid;
56
+ const columnGridClass = explicitColumnGrid && columns !== undefined
57
+ ? COLUMN_GRID_CLASS[columns]
58
+ : undefined;
59
+ /**
60
+ * Row peers under a horizontal stack must shrink and share width (`flex-1` + `min-w-0`).
61
+ * Avoid `w-full` here: it resolves to 100% of the flex/grid container and fights peer sizing,
62
+ * so each column stacks on its own wrapped row instead of sitting side-by-side.
63
+ */
64
+ const isRowChild = parentDirection === "horizontal";
65
+ const rootWidthClass = isRowChild
66
+ ? "min-w-0 flex-1 basis-0 max-w-full"
67
+ : "w-full min-w-0";
68
+ const justifyBlockGrid = justifyFlex &&
69
+ (!isHorizontal || (!buttonRowGrid && !explicitColumnGrid));
70
+ /** Single flex row (nowrap): peers stay side-by-side and shrink via min-w-0 / flex-1 on nested stacks. */
71
+ const horizontalFlexClasses = "flex min-w-0 flex-row flex-nowrap items-stretch [&>*]:min-w-0";
72
+ return (_jsx(SnapStackDirectionProvider, { direction: isHorizontal ? "horizontal" : "vertical", children: _jsx("div", { className: cn(rootWidthClass, isHorizontal
73
+ ? buttonRowGrid &&
74
+ buttonRowCount >= 1 &&
75
+ buttonRowCount <= 6 &&
76
+ COLUMN_GRID_CLASS[buttonRowCount]
77
+ ? cn(COLUMN_GRID_CLASS[buttonRowCount], gap, "[&>*]:w-full")
78
+ : explicitColumnGrid && columnGridClass
79
+ ? cn(columnGridClass, gap)
80
+ : cn(horizontalFlexClasses, gap, justifyBlockGrid ? justifyFlex : undefined)
81
+ : cn("flex min-w-0 w-full flex-col", gap, justifyFlex)), children: children }) }));
32
82
  }
@@ -1,7 +1,9 @@
1
1
  "use client";
2
2
  import { jsx as _jsx } from "react/jsx-runtime";
3
3
  import { Text } from "@neynar/ui/typography";
4
+ import { cn } from "@neynar/ui/utils";
4
5
  import { useSnapColors } from "../hooks/use-snap-colors.js";
6
+ import { useSnapStackDirection } from "../stack-direction-context.js";
5
7
  const SIZE_MAP = {
6
8
  md: { textSize: "base" },
7
9
  sm: { textSize: "sm" },
@@ -13,5 +15,9 @@ export function SnapText({ element: { props }, }) {
13
15
  const align = props.align ?? undefined;
14
16
  const config = SIZE_MAP[size] ?? SIZE_MAP.md;
15
17
  const colors = useSnapColors();
16
- return (_jsx(Text, { size: config.textSize, weight: weight, align: align, className: "flex-1", style: { color: colors.text }, children: content }));
18
+ const stackDir = useSnapStackDirection();
19
+ const inHorizontalStack = stackDir === "horizontal";
20
+ return (_jsx(Text, { size: config.textSize, weight: weight, align: align, className: cn(
21
+ /** Row peers hug content like RN `wrapRow`; avoid `flex-1` stretching peers across the row. */
22
+ inHorizontalStack ? "min-w-0 shrink" : "flex-1"), style: { color: colors.text }, children: content }));
17
23
  }
@@ -3,7 +3,7 @@ import { useMemo } from "react";
3
3
  import { useStateStore } from "@json-render/react";
4
4
  import { resolveSnapPaletteHex } from "../lib/resolve-palette-hex.js";
5
5
  import { useSnapPreviewPageAccent, useSnapAppearance } from "../accent-context.js";
6
- import { PALETTE_DARK_HEX, PALETTE_LIGHT_HEX } from "@farcaster/snap";
6
+ import { resolveSnapColorHex } from "@farcaster/snap";
7
7
  /** Readable foreground color (black or white) for a given hex background. */
8
8
  export function pickForegroundForBg(hex) {
9
9
  const h = hex.replace(/^#/, "");
@@ -37,19 +37,12 @@ function buildSnapColors(accentName, mode) {
37
37
  const accent = resolveSnapPaletteHex(accentName, mode);
38
38
  const accentFg = pickForegroundForBg(accent);
39
39
  const neutrals = mode === "dark" ? NEUTRAL_DARK : NEUTRAL_LIGHT;
40
- const paletteMap = mode === "dark" ? PALETTE_DARK_HEX : PALETTE_LIGHT_HEX;
41
40
  const accentHover = mode === "light"
42
41
  ? `color-mix(in srgb, ${accent} 82%, #000000)`
43
42
  : `color-mix(in srgb, ${accent} 78%, #ffffff)`;
44
43
  const outlineHover = `color-mix(in srgb, ${accent} 14%, ${neutrals.surface})`;
45
44
  const paletteHex = (name) => resolveSnapPaletteHex(name, mode);
46
- const colorHex = (name) => {
47
- if (!name || name === "accent")
48
- return accent;
49
- if (Object.hasOwn(paletteMap, name))
50
- return paletteMap[name];
51
- return accent;
52
- };
45
+ const colorHex = (name) => resolveSnapColorHex(name, { accentHex: accent, appearance: mode });
53
46
  return {
54
47
  accent,
55
48
  accentFg,
@@ -0,0 +1,7 @@
1
+ import { type ReactNode } from "react";
2
+ export type SnapStackDirection = "vertical" | "horizontal";
3
+ export declare function SnapStackDirectionProvider({ direction, children, }: {
4
+ direction: SnapStackDirection;
5
+ children: ReactNode;
6
+ }): import("react/jsx-runtime").JSX.Element;
7
+ export declare function useSnapStackDirection(): SnapStackDirection | undefined;
@@ -0,0 +1,10 @@
1
+ "use client";
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { createContext, useContext } from "react";
4
+ const SnapStackDirectionContext = createContext(undefined);
5
+ export function SnapStackDirectionProvider({ direction, children, }) {
6
+ return (_jsx(SnapStackDirectionContext.Provider, { value: direction, children: children }));
7
+ }
8
+ export function useSnapStackDirection() {
9
+ return useContext(SnapStackDirectionContext);
10
+ }
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { StyleSheet, Text, View, Pressable } from "react-native";
3
3
  import { useStateStore } from "@json-render/react-native";
4
4
  import { useSnapPalette } from "../use-snap-palette.js";
@@ -58,13 +58,15 @@ export function SnapCellGrid({ element, emit, }) {
58
58
  }
59
59
  const ringOuter = appearance === "dark" ? "#fff" : "#000";
60
60
  const ringInner = appearance === "dark" ? "#000" : "#fff";
61
+ /** Cells without a palette `color` — subtle fill so empty slots read as tiles. */
62
+ const emptyCellBg = appearance === "dark" ? "rgba(255, 255, 255, 0.05)" : "rgba(0, 0, 0, 0.05)";
61
63
  const rowEls = [];
62
64
  for (let r = 0; r < rows; r++) {
63
65
  const rowCells = [];
64
66
  for (let c = 0; c < cols; c++) {
65
67
  const cell = cellMap.get(`${r},${c}`);
66
68
  const selected = interactive && isSelected(r, c);
67
- const bg = cell?.color ? hex(cell.color) : "transparent";
69
+ const bg = cell?.color ? hex(cell.color) : emptyCellBg;
68
70
  const cellContent = cell?.content ? (_jsx(Text, { style: [styles.cellText, { color: colors.textPrimary }], children: cell.content })) : null;
69
71
  // Two-tone ring: outer View with contrasting border, inner View with inverse border
70
72
  const cellView = selected ? (_jsx(View, { style: [styles.cell, { height: rowHeight, borderWidth: 1, borderColor: ringOuter, borderRadius: 4 }], children: _jsx(View, { style: [
@@ -75,10 +77,7 @@ export function SnapCellGrid({ element, emit, }) {
75
77
  }
76
78
  rowEls.push(_jsx(View, { style: [styles.gridRow, { gap: gapPx }], children: rowCells }, r));
77
79
  }
78
- const selectionLabel = isSelectable && selectedSet.size > 0
79
- ? `inputs.${name}: ${[...selectedSet].join(isMultiple ? " | " : "")}`
80
- : null;
81
- return (_jsxs(View, { style: [styles.wrap, { gap: gapPx, backgroundColor: colors.muted, padding: 4, borderRadius: 8 }], children: [rowEls, selectionLabel ? (_jsx(Text, { style: [styles.selectionText, { color: colors.textSecondary }], children: selectionLabel })) : null] }));
80
+ return (_jsx(View, { style: [styles.wrap, { gap: gapPx, backgroundColor: colors.muted, padding: 4, borderRadius: 8 }], children: rowEls }));
82
81
  }
83
82
  const styles = StyleSheet.create({
84
83
  wrap: { width: "100%" },
@@ -96,5 +95,4 @@ const styles = StyleSheet.create({
96
95
  justifyContent: "center",
97
96
  },
98
97
  cellText: { fontSize: 12, lineHeight: 16, fontWeight: "600" },
99
- selectionText: { fontSize: 11, fontFamily: "monospace", marginTop: 6 },
100
98
  });
@@ -1,6 +1,7 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { Image } from "expo-image";
3
3
  import { StyleSheet, View } from "react-native";
4
+ import { useSnapStackDirection } from "../stack-direction-context.js";
4
5
  function aspectToRatio(aspect) {
5
6
  const [w, h] = aspect.split(":").map(Number);
6
7
  if (!w || !h)
@@ -11,13 +12,25 @@ export function SnapImage({ element: { props }, }) {
11
12
  const url = String(props.url ?? "");
12
13
  const alt = String(props.alt ?? "");
13
14
  const ratio = aspectToRatio(String(props.aspect ?? "1:1"));
14
- return (_jsx(View, { style: [styles.frame, { aspectRatio: ratio }], children: _jsx(Image, { source: { uri: url }, style: StyleSheet.absoluteFill, contentFit: "cover", accessibilityLabel: alt || undefined }) }));
15
+ const stackDir = useSnapStackDirection();
16
+ const inHorizontalStack = stackDir === "horizontal";
17
+ return (_jsx(View, { style: [
18
+ styles.frame,
19
+ inHorizontalStack ? styles.frameInHorizontalRow : styles.frameFullWidth,
20
+ { aspectRatio: ratio },
21
+ ], children: _jsx(Image, { source: { uri: url }, style: StyleSheet.absoluteFill, contentFit: "cover", accessibilityLabel: alt || undefined }) }));
15
22
  }
16
23
  const styles = StyleSheet.create({
17
24
  frame: {
18
- width: "100%",
19
25
  borderRadius: 8,
20
26
  overflow: "hidden",
21
27
  backgroundColor: "#f3f4f6",
22
28
  },
29
+ frameFullWidth: {
30
+ width: "100%",
31
+ },
32
+ frameInHorizontalRow: {
33
+ flex: 1,
34
+ minWidth: 0,
35
+ },
23
36
  });
@@ -1,5 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { StyleSheet, Text, View } from "react-native";
3
+ import { useSnapStackDirection } from "../stack-direction-context.js";
3
4
  import { useSnapTheme } from "../theme.js";
4
5
  export function SnapItem({ element: { props }, children, }) {
5
6
  const { colors } = useSnapTheme();
@@ -7,15 +8,24 @@ export function SnapItem({ element: { props }, children, }) {
7
8
  const description = props.description
8
9
  ? String(props.description)
9
10
  : undefined;
10
- const variant = String(props.variant ?? "default");
11
+ /** Match web `Item className="flex-1"`: row peers must share width or title/description collapse. */
12
+ const rowPeer = useSnapStackDirection() === "horizontal";
11
13
  const containerVariant = { paddingVertical: 6, paddingHorizontal: 10 };
12
- return (_jsxs(View, { style: [styles.container, containerVariant], children: [_jsxs(View, { style: styles.content, children: [title ? _jsx(Text, { style: [styles.title, { color: colors.text }], children: title }) : null, description ? (_jsx(Text, { style: [styles.description, { color: colors.textSecondary }], children: description })) : null] }), children ? (_jsx(View, { style: styles.actions, children: _jsx(View, { style: { flex: 0 }, children: children }) })) : null] }));
14
+ return (_jsxs(View, { style: [
15
+ styles.container,
16
+ containerVariant,
17
+ rowPeer && styles.rowPeer,
18
+ ], children: [_jsxs(View, { style: styles.content, children: [title ? _jsx(Text, { style: [styles.title, { color: colors.text }], children: title }) : null, description ? (_jsx(Text, { style: [styles.description, { color: colors.textSecondary }], children: description })) : null] }), children ? (_jsx(View, { style: styles.actions, children: _jsx(View, { style: { flex: 0 }, children: children }) })) : null] }));
13
19
  }
14
20
  const styles = StyleSheet.create({
15
21
  container: {
16
22
  flexDirection: "row",
17
23
  alignItems: "center",
18
24
  },
25
+ rowPeer: {
26
+ flex: 1,
27
+ minWidth: 0,
28
+ },
19
29
  content: {
20
30
  flex: 1,
21
31
  },
@@ -1,5 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { StyleSheet, Text, View } from "react-native";
3
+ import { useSnapStackDirection } from "../stack-direction-context.js";
3
4
  import { useSnapPalette } from "../use-snap-palette.js";
4
5
  import { useSnapTheme } from "../theme.js";
5
6
  export function SnapProgress({ element: { props }, }) {
@@ -9,10 +10,15 @@ export function SnapProgress({ element: { props }, }) {
9
10
  const max = Math.max(1, Number(props.max ?? 100));
10
11
  const percent = Math.min(100, Math.max(0, (value / max) * 100));
11
12
  const label = props.label != null ? String(props.label) : null;
12
- return (_jsxs(View, { style: styles.wrap, children: [label ? (_jsx(Text, { style: [styles.label, { color: colors.textSecondary }], children: label })) : null, _jsx(View, { style: [styles.track, { backgroundColor: colors.muted }], children: _jsx(View, { style: [styles.fill, { width: `${percent}%`, backgroundColor: accentHex }] }) })] }));
13
+ const inHorizontalStack = useSnapStackDirection() === "horizontal";
14
+ return (_jsxs(View, { style: [styles.wrap, inHorizontalStack ? styles.wrapRowPeer : styles.wrapCol], children: [label ? (_jsx(Text, { style: [styles.label, { color: colors.textSecondary }], children: label })) : null, _jsx(View, { style: [styles.track, { backgroundColor: colors.muted }], children: _jsx(View, { style: [styles.fill, { width: `${percent}%`, backgroundColor: accentHex }] }) })] }));
13
15
  }
14
16
  const styles = StyleSheet.create({
15
- wrap: { width: "100%", gap: 4 },
17
+ wrap: { gap: 4 },
18
+ /** Vertical stacks: span card width (matches web `w-full`). */
19
+ wrapCol: { width: "100%" },
20
+ /** Horizontal row peers: share space; `width: 100%` each overflows the row. */
21
+ wrapRowPeer: { flex: 1, minWidth: 0 },
16
22
  label: { fontSize: 13, lineHeight: 18 },
17
23
  track: {
18
24
  height: 10,
@@ -1,5 +1,5 @@
1
1
  import type { ComponentRenderProps } from "@json-render/react-native";
2
- import type { ReactNode } from "react";
2
+ import { type ReactNode } from "react";
3
3
  export declare function SnapStack({ element: { props }, children, }: ComponentRenderProps<Record<string, unknown>> & {
4
4
  children?: ReactNode;
5
5
  }): import("react").JSX.Element;
@@ -1,5 +1,8 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { Children } from "react";
2
3
  import { StyleSheet, View } from "react-native";
4
+ import { countRenderableChildren, horizontalChildrenAreAllButtons, } from "../../stack-horizontal-utils.js";
5
+ import { SnapStackDirectionProvider, useSnapStackDirection, } from "../stack-direction-context.js";
3
6
  const VGAP = {
4
7
  none: 0,
5
8
  sm: 8,
@@ -19,7 +22,13 @@ const JUSTIFY = {
19
22
  between: "space-between",
20
23
  around: "space-around",
21
24
  };
25
+ /** Equal-width cells for explicit `columns` and all-button horizontal rows. */
26
+ function wrapEqualColumnCells(children) {
27
+ const cells = Children.toArray(children).filter((c) => c != null && c !== false);
28
+ return cells.map((child, i) => (_jsx(View, { style: styles.equalColumnCell, children: child }, i)));
29
+ }
22
30
  export function SnapStack({ element: { props }, children, }) {
31
+ const parentDirection = useSnapStackDirection();
23
32
  const direction = String(props.direction ?? "vertical");
24
33
  const rawGap = props.gap;
25
34
  const isHorizontal = direction === "horizontal";
@@ -29,21 +38,87 @@ export function SnapStack({ element: { props }, children, }) {
29
38
  : typeof rawGap === "string" && rawGap in gapMap
30
39
  ? gapMap[rawGap]
31
40
  : isHorizontal ? HGAP.md : VGAP.md;
32
- const justify = props.justify ? JUSTIFY[String(props.justify)] : undefined;
33
- return (_jsx(View, { style: [
34
- styles.stack,
35
- isHorizontal ? styles.horizontal : undefined,
36
- { gap },
37
- justify ? { justifyContent: justify } : undefined,
38
- ], children: children }));
41
+ const buttonRowGrid = isHorizontal && horizontalChildrenAreAllButtons(children);
42
+ const buttonRowCount = buttonRowGrid
43
+ ? countRenderableChildren(children)
44
+ : 0;
45
+ const columnsRaw = props.columns;
46
+ const columns = typeof columnsRaw === "number" &&
47
+ columnsRaw >= 2 &&
48
+ columnsRaw <= 6 &&
49
+ Number.isInteger(columnsRaw)
50
+ ? columnsRaw
51
+ : undefined;
52
+ const explicitColumnGrid = isHorizontal && columns !== undefined && !buttonRowGrid;
53
+ const justify = props.justify &&
54
+ (!isHorizontal || (!buttonRowGrid && !explicitColumnGrid))
55
+ ? JUSTIFY[String(props.justify)]
56
+ : undefined;
57
+ const isRowChild = parentDirection === "horizontal";
58
+ const packedHorizontal = isHorizontal &&
59
+ ((buttonRowGrid &&
60
+ buttonRowCount >= 1 &&
61
+ buttonRowCount <= 6) ||
62
+ explicitColumnGrid);
63
+ let horizontalBody = children;
64
+ if (isHorizontal &&
65
+ buttonRowGrid &&
66
+ buttonRowCount >= 1 &&
67
+ buttonRowCount <= 6) {
68
+ horizontalBody = wrapEqualColumnCells(children);
69
+ }
70
+ else if (isHorizontal && explicitColumnGrid && columns !== undefined) {
71
+ horizontalBody = wrapEqualColumnCells(children);
72
+ }
73
+ return (_jsx(SnapStackDirectionProvider, { direction: isHorizontal ? "horizontal" : "vertical", children: _jsx(View, { style: [
74
+ isRowChild ? styles.stackRowChild : styles.stack,
75
+ isHorizontal
76
+ ? packedHorizontal
77
+ ? styles.horizontalPacked
78
+ : styles.horizontalDefault
79
+ : styles.verticalStack,
80
+ { gap },
81
+ justify ? { justifyContent: justify } : undefined,
82
+ ], children: horizontalBody }) }));
39
83
  }
40
84
  const styles = StyleSheet.create({
41
85
  stack: {
42
86
  width: "100%",
87
+ minWidth: 0,
88
+ },
89
+ verticalStack: {
90
+ width: "100%",
91
+ minWidth: 0,
43
92
  },
44
- horizontal: {
93
+ /** Nested stack inside a horizontal row — share width with siblings (matches web flex peers). */
94
+ stackRowChild: {
95
+ flexGrow: 1,
96
+ flexShrink: 1,
97
+ flexBasis: 0,
98
+ minWidth: 0,
99
+ maxWidth: "100%",
100
+ alignSelf: "stretch",
101
+ },
102
+ /** Default horizontal row: single line, equal-height peers. */
103
+ horizontalDefault: {
45
104
  flexDirection: "row",
46
- alignItems: "center",
47
- flexWrap: "wrap",
105
+ alignItems: "stretch",
106
+ flexWrap: "nowrap",
107
+ width: "100%",
108
+ minWidth: 0,
109
+ },
110
+ /** Single row for packed equal-width cells (button grids & explicit columns). */
111
+ horizontalPacked: {
112
+ flexDirection: "row",
113
+ flexWrap: "nowrap",
114
+ alignItems: "stretch",
115
+ width: "100%",
116
+ minWidth: 0,
117
+ },
118
+ equalColumnCell: {
119
+ flexGrow: 1,
120
+ flexShrink: 1,
121
+ flexBasis: 0,
122
+ minWidth: 0,
48
123
  },
49
124
  });
@@ -1,5 +1,6 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { StyleSheet, Text, View } from "react-native";
3
+ import { useSnapStackDirection } from "../stack-direction-context.js";
3
4
  import { useSnapTheme } from "../theme.js";
4
5
  const SIZE_STYLES = {
5
6
  md: { fontSize: 16, lineHeight: 24 },
@@ -18,7 +19,8 @@ export function SnapText({ element: { props }, }) {
18
19
  const sizeStyle = SIZE_STYLES[size] ?? SIZE_STYLES.md;
19
20
  const resolvedWeight = weight ? WEIGHT_MAP[weight] : sizeStyle?.fontWeight;
20
21
  const textAlign = align === "center" ? "center" : align === "right" ? "right" : "left";
21
- return (_jsx(View, { style: styles.wrap, children: _jsx(Text, { style: [
22
+ const inHorizontalStack = useSnapStackDirection() === "horizontal";
23
+ return (_jsx(View, { style: inHorizontalStack ? styles.wrapRow : styles.wrapCol, children: _jsx(Text, { style: [
22
24
  styles.base,
23
25
  {
24
26
  color: colors.text,
@@ -30,6 +32,9 @@ export function SnapText({ element: { props }, }) {
30
32
  ], children: content }) }));
31
33
  }
32
34
  const styles = StyleSheet.create({
33
- wrap: { width: "100%" },
35
+ /** Full width for vertical stacks (alignment / wrapping). */
36
+ wrapCol: { width: "100%" },
37
+ /** Row peers: hug content; avoid width 100% fighting nowrap horizontal rows. */
38
+ wrapRow: { flexShrink: 1, minWidth: 0 },
34
39
  base: {},
35
40
  });
@@ -0,0 +1,7 @@
1
+ import { type ReactNode } from "react";
2
+ export type SnapStackDirection = "vertical" | "horizontal";
3
+ export declare function SnapStackDirectionProvider({ direction, children, }: {
4
+ direction: SnapStackDirection;
5
+ children: ReactNode;
6
+ }): import("react").JSX.Element;
7
+ export declare function useSnapStackDirection(): SnapStackDirection | undefined;