@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
@@ -1,5 +1,5 @@
1
1
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
- import { verifyJFSRequestBody } from "./server/verify.js";
2
+ import { verifyJFS } from "./server/verify.js";
3
3
  const validRequestBody = `{
4
4
  "header":"eyJmaWQiOjI2MTMxOSwidHlwZSI6ImFwcF9rZXkiLCJrZXkiOiIweGY0ZGQyNjczYTUzMjEwYzQ3ZGYzZjFmNTk0NjZlZTdhMTM3ZmQxOGQ5NTVjMmU2OGExMmQwOTE2MGE2NmMyMTUifQ",
5
5
  "payload":"eyJmaWQiOjI2MTMxOSwiaW5wdXRzIjp7ImRpc3BsYXkiOiJJU08gKFVUQykifSwiYnV0dG9uX2luZGV4IjowLCJ0aW1lc3RhbXAiOjE3NzQ2OTMyMTN9",
@@ -7,7 +7,7 @@ const validRequestBody = `{
7
7
  }`;
8
8
  /** Matches JFS header `key` (Ed25519 public key, 32 bytes hex without `0x` in hub JSON field). */
9
9
  const HUB_SIGNER_KEY_HEX = "f4dd2673a53210c47df3f1f59466ee7a137fd18d955c2e68a12d09160a66c215";
10
- describe("verifyJFSRequestBody", () => {
10
+ describe("verifyJFS", () => {
11
11
  beforeEach(() => {
12
12
  vi.stubGlobal("fetch", vi.fn(async (input) => {
13
13
  const u = String(input);
@@ -41,7 +41,7 @@ describe("verifyJFSRequestBody", () => {
41
41
  vi.unstubAllGlobals();
42
42
  });
43
43
  it("accepts JSON JFS body and verifies crypto + hub signer list", async () => {
44
- const result = await verifyJFSRequestBody(JSON.parse(validRequestBody));
44
+ const result = await verifyJFS(JSON.parse(validRequestBody));
45
45
  expect(result.valid).toBe(true);
46
46
  if (result.valid) {
47
47
  expect(result.data).toEqual({
package/llms.txt CHANGED
@@ -37,7 +37,7 @@ Top-level fields: `version` (required, `"1.0"` or `"2.0"`), `theme` (optional, `
37
37
  | Total elements | Max **64** in `ui.elements` |
38
38
  | Root children | Max **7** children on the root element |
39
39
  | Children per element | Max **6** per non-root container (`stack`, `item_group`) |
40
- | Nesting depth | Max **4** levels from root to deepest leaf |
40
+ | Nesting depth | Max **5** levels from root to deepest leaf |
41
41
 
42
42
  ## Components (16 total)
43
43
 
@@ -95,7 +95,7 @@ Top-level fields: `version` (required, `"1.0"` or `"2.0"`), `theme` (optional, `
95
95
  - `name` (string, optional): POST inputs key. Default: `"grid_tap"`
96
96
  - `cols` (number, required, 2–32)
97
97
  - `rows` (number, required, 2–16)
98
- - `cells` (array, required): sparse list of `{ row, col, color?: PaletteColor, content?: string }`
98
+ - `cells` (array, required): sparse list of `{ row, col, color?: PaletteColor | #rrggbb, content?: string }`
99
99
  - `gap` (optional): `"none"` (0px) | `"sm"` (1px) | `"md"` (2px) | `"lg"` (4px). Default: `"sm"`
100
100
  - `rowHeight` (number, optional, 8–64): pixel height per row. Default: 28. Grid height = rows × rowHeight
101
101
  - `select` (optional): `"off"` | `"single"` | `"multiple"`. Default: `"off"`. With `select: "off"`, bind `on.press` for press-to-act (each press writes `"row,col"` to `inputs[name]` and fires the action). With `"single"` / `"multiple"`, presses accumulate selection state and pair with a separate submit `button`; `on.press` is ignored.
@@ -107,6 +107,7 @@ Top-level fields: `version` (required, `"1.0"` or `"2.0"`), `theme` (optional, `
107
107
  - `direction` (optional): `"vertical"` | `"horizontal"`. Default: `"vertical"`
108
108
  - `gap` (optional): `"none"` | `"sm"` | `"md"` | `"lg"`. Default: `"md"`
109
109
  - `justify` (optional): `"start"` | `"center"` | `"end"` | `"between"` | `"around"`
110
+ - `columns` (optional, horizontal only): `2`–`6` — CSS grid with equal columns (mixed children or layout that needs fixed column counts).
110
111
  - Children are element IDs
111
112
 
112
113
  **item_group** — Groups item children.
@@ -171,6 +172,16 @@ Bound to buttons via `on.press`:
171
172
  | `send_token` | `token`, `amount?`, `recipientFid?`, `recipientAddress?` | Send token flow |
172
173
  | `swap_token` | `sellToken?`, `buyToken?` | Swap token flow |
173
174
 
175
+ ## Authenticated requests
176
+
177
+ POST requests carry a JFS envelope (JSON object **or** compact dot-separated string). `parseRequest` validates the payload and (unless `skipJFSVerification`) cryptographically verifies the JFS against an active hub signer for `user.fid`. On the server, `ctx.action.user.fid` is **always present and verified** for `type === "post"`.
178
+
179
+ GET requests MAY include optional viewer identity in the `X-Snap-Payload` request header (a JFS compact string with the same shape as POST minus `inputs` and `fid`). When present and valid, `ctx.action` on GET MAY include `user`, `timestamp`, `audience`, and `surface`.
180
+
181
+ `ctx.action.user` on GET is **best-effort and never guaranteed** — older or custom clients, cache layers, crawlers, and `curl` may yield an anonymous GET even for users who have POSTed to this snap before. Always render a working anonymous first load; treat viewer fields on GET as a strict enhancement. `parseRequest` (and `@farcaster/snap-hono`'s GET handler) silently fall back to anonymous `{ type: "get" }` when `X-Snap-Payload` is missing or invalid.
182
+
183
+ When responses depend on viewer identity, send `Vary: Accept, X-Snap-Payload` and consider `Cache-Control: private` so caches don't serve viewer-specific bodies to other viewers (`@farcaster/snap-hono` sets `Vary` automatically).
184
+
174
185
  ## Icon Names (34)
175
186
 
176
187
  `arrow-right`, `arrow-left`, `external-link`, `chevron-right`, `check`, `x`, `alert-triangle`, `info`, `clock`, `heart`, `message-circle`, `repeat`, `share`, `user`, `users`, `star`, `trophy`, `zap`, `flame`, `gift`, `image`, `play`, `pause`, `wallet`, `coins`, `plus`, `minus`, `refresh-cw`, `bookmark`, `thumbs-up`, `thumbs-down`, `trending-up`, `trending-down`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@farcaster/snap",
3
- "version": "2.1.1",
3
+ "version": "2.2.0",
4
4
  "description": "Farcaster Snaps 🫰",
5
5
  "repository": {
6
6
  "type": "git",
package/src/colors.ts CHANGED
@@ -38,6 +38,33 @@ export const PALETTE_COLOR_VALUES = [
38
38
 
39
39
  export type PaletteColor = (typeof PALETTE_COLOR_VALUES)[number];
40
40
 
41
+ /** Strict `#rrggbb` literal used by cell_grid (and clients that accept hex). */
42
+ const SNAP_HEX_6 = /^#[0-9a-fA-F]{6}$/;
43
+
44
+ export function isSnapHexColorString(s: string): boolean {
45
+ return SNAP_HEX_6.test(s.trim());
46
+ }
47
+
48
+ /**
49
+ * Resolve a snap color token for inline styles: `accent`, palette names, or
50
+ * literal `#rrggbb`. Unknown values fall back to `accentHex` (same as legacy
51
+ * `colorHex` behavior for non-hex strings).
52
+ */
53
+ export function resolveSnapColorHex(
54
+ color: string | undefined,
55
+ opts: { accentHex: string; appearance: "light" | "dark" },
56
+ ): string {
57
+ if (!color || color === PALETTE_COLOR_ACCENT) return opts.accentHex;
58
+ const trimmed = color.trim();
59
+ if (isSnapHexColorString(trimmed)) return trimmed;
60
+ const map =
61
+ opts.appearance === "dark" ? PALETTE_DARK_HEX : PALETTE_LIGHT_HEX;
62
+ if (Object.hasOwn(map, trimmed)) {
63
+ return map[trimmed as PaletteColor];
64
+ }
65
+ return opts.accentHex;
66
+ }
67
+
41
68
  /** Light-mode hex for each palette color (emulator / reference client). */
42
69
  export const PALETTE_LIGHT_HEX: Record<PaletteColor, string> = {
43
70
  gray: "#6E6A86",
package/src/constants.ts CHANGED
@@ -1,9 +1,14 @@
1
1
  export const SPEC_VERSION_1 = "1.0" as const;
2
2
  export const SPEC_VERSION_2 = "2.0" as const;
3
3
  export const SPEC_VERSION = SPEC_VERSION_2;
4
- export const SUPPORTED_SPEC_VERSIONS = [SPEC_VERSION_1, SPEC_VERSION_2] as const;
4
+ export const SUPPORTED_SPEC_VERSIONS = [
5
+ SPEC_VERSION_1,
6
+ SPEC_VERSION_2,
7
+ ] as const;
5
8
  export type SpecVersion = (typeof SUPPORTED_SPEC_VERSIONS)[number];
6
9
 
10
+ export const SNAP_PAYLOAD_HEADER = "X-Snap-Payload" as const;
11
+
7
12
  export const MEDIA_TYPE = "application/vnd.farcaster.snap+json" as const;
8
13
 
9
14
  export const EFFECT_VALUES = ["confetti"] as const;
@@ -20,7 +25,8 @@ export const GRID_GAP_VALUES = ["none", "sm", "md", "lg"] as const;
20
25
  export const MAX_ELEMENTS = 64;
21
26
  export const MAX_ROOT_CHILDREN = 7;
22
27
  export const MAX_CHILDREN = 6;
23
- export const MAX_DEPTH = 4;
28
+ /** Enough depth for side-by-side columns that contain labeled horizontal icon rows (pair → column → row → icon). */
29
+ export const MAX_DEPTH = 5;
24
30
 
25
31
  // ─── Bar chart ─────────────────────────────────────────
26
32
  export const BAR_CHART_MAX_BARS = 6;
package/src/index.ts CHANGED
@@ -8,6 +8,7 @@ export {
8
8
  SPEC_VERSION_2,
9
9
  SUPPORTED_SPEC_VERSIONS,
10
10
  type SpecVersion,
11
+ SNAP_PAYLOAD_HEADER,
11
12
  MEDIA_TYPE,
12
13
  EFFECT_VALUES,
13
14
  POST_GRID_TAP_KEY,
@@ -23,6 +24,8 @@ export {
23
24
  PALETTE_COLOR_VALUES,
24
25
  PALETTE_LIGHT_HEX,
25
26
  PALETTE_DARK_HEX,
27
+ isSnapHexColorString,
28
+ resolveSnapColorHex,
26
29
  type PaletteColor,
27
30
  } from "./colors";
28
31
  export {
@@ -30,7 +33,9 @@ export {
30
33
  ACTION_TYPE_POST,
31
34
  snapResponseSchema,
32
35
  payloadSchema,
36
+ getPayloadSchema,
33
37
  type SnapAction,
38
+ type SnapGetAction,
34
39
  type SnapContext,
35
40
  type SnapResponse,
36
41
  type SnapHandlerResult,
@@ -38,5 +43,6 @@ export {
38
43
  type SnapSpecInput,
39
44
  type SnapFunction,
40
45
  type SnapPayload,
46
+ type SnapGetPayload,
41
47
  } from "./schemas";
42
48
  export { validateSnapResponse, type ValidationResult } from "./validator";
@@ -64,12 +64,16 @@ export function SnapCellGrid({
64
64
  });
65
65
  }
66
66
 
67
+ /** Cells without a palette `color` — subtle fill so empty slots read as tiles. */
68
+ const emptyCellBg =
69
+ colors.mode === "dark" ? "rgba(255, 255, 255, 0.05)" : "rgba(0, 0, 0, 0.05)";
70
+
67
71
  const cellEls: ReactNode[] = [];
68
72
  for (let r = 0; r < rows; r++) {
69
73
  for (let c = 0; c < cols; c++) {
70
74
  const cell = cellMap.get(`${r},${c}`);
71
75
  const selected = interactive && isSelected(r, c);
72
- const bg = cell?.color ? colors.colorHex(cell.color) : "transparent";
76
+ const bg = cell?.color ? colors.colorHex(cell.color) : emptyCellBg;
73
77
 
74
78
  cellEls.push(
75
79
  <div
@@ -105,30 +109,19 @@ export function SnapCellGrid({
105
109
  }
106
110
  }
107
111
 
108
- const selectionLabel = isSelectable && selectedSet.size > 0
109
- ? `inputs.${name}: ${[...selectedSet].join(isMultiple ? " | " : "")}`
110
- : null;
111
-
112
112
  return (
113
- <div>
114
- <div
115
- style={{
116
- display: "grid",
117
- width: "100%",
118
- gridTemplateColumns: `repeat(${cols}, 1fr)`,
119
- gap: gapPx,
120
- padding: 4,
121
- borderRadius: 8,
122
- backgroundColor: colors.muted,
123
- }}
124
- >
125
- {cellEls}
126
- </div>
127
- {selectionLabel && (
128
- <div className="mt-1.5 truncate text-xs font-mono" style={{ color: colors.textMuted }}>
129
- {selectionLabel}
130
- </div>
131
- )}
113
+ <div
114
+ style={{
115
+ display: "grid",
116
+ width: "100%",
117
+ gridTemplateColumns: `repeat(${cols}, 1fr)`,
118
+ gap: gapPx,
119
+ padding: 4,
120
+ borderRadius: 8,
121
+ backgroundColor: colors.muted,
122
+ }}
123
+ >
124
+ {cellEls}
132
125
  </div>
133
126
  );
134
127
  }
@@ -1,6 +1,8 @@
1
1
  "use client";
2
2
 
3
3
  import { AspectRatio } from "@neynar/ui/aspect-ratio";
4
+ import { cn } from "@neynar/ui/utils";
5
+ import { useSnapStackDirection } from "../stack-direction-context";
4
6
 
5
7
  function aspectToRatio(aspect: string): number {
6
8
  const [w, h] = aspect.split(":").map(Number);
@@ -16,11 +18,16 @@ export function SnapImage({
16
18
  const url = String(props.url ?? "");
17
19
  const alt = String(props.alt ?? "");
18
20
  const ratio = aspectToRatio(String(props.aspect ?? "1:1"));
21
+ const stackDir = useSnapStackDirection();
22
+ const inHorizontalStack = stackDir === "horizontal";
19
23
 
20
24
  return (
21
25
  <AspectRatio
22
26
  ratio={ratio}
23
- className="relative w-full flex-1 overflow-hidden rounded-lg"
27
+ className={cn(
28
+ "relative overflow-hidden rounded-lg",
29
+ inHorizontalStack ? "min-w-0 flex-1 basis-0" : "w-full",
30
+ )}
24
31
  >
25
32
  {/* eslint-disable-next-line @next/next/no-img-element */}
26
33
  <img
@@ -2,6 +2,14 @@
2
2
 
3
3
  import type { ReactNode } from "react";
4
4
  import { cn } from "@neynar/ui/utils";
5
+ import {
6
+ countRenderableChildren,
7
+ horizontalChildrenAreAllButtons,
8
+ } from "../../stack-horizontal-utils.js";
9
+ import {
10
+ SnapStackDirectionProvider,
11
+ useSnapStackDirection,
12
+ } from "../stack-direction-context";
5
13
 
6
14
  const VGAP: Record<string, string> = {
7
15
  none: "gap-0",
@@ -17,7 +25,7 @@ const HGAP: Record<string, string> = {
17
25
  lg: "gap-3",
18
26
  };
19
27
 
20
- const JUSTIFY: Record<string, string> = {
28
+ const JUSTIFY_FLEX: Record<string, string> = {
21
29
  start: "justify-start",
22
30
  center: "justify-center",
23
31
  end: "justify-end",
@@ -25,6 +33,16 @@ const JUSTIFY: Record<string, string> = {
25
33
  around: "justify-around",
26
34
  };
27
35
 
36
+ /** Equal columns for explicit `columns` prop and for all-button horizontal rows. */
37
+ const COLUMN_GRID_CLASS: Record<number, string> = {
38
+ 1: "grid grid-cols-1 auto-rows-auto items-stretch [&>*]:min-w-0",
39
+ 2: "grid grid-cols-2 auto-rows-auto items-stretch [&>*]:min-w-0",
40
+ 3: "grid grid-cols-3 auto-rows-auto items-stretch [&>*]:min-w-0",
41
+ 4: "grid grid-cols-4 auto-rows-auto items-stretch [&>*]:min-w-0",
42
+ 5: "grid grid-cols-5 auto-rows-auto items-stretch [&>*]:min-w-0",
43
+ 6: "grid grid-cols-6 auto-rows-auto items-stretch [&>*]:min-w-0",
44
+ };
45
+
28
46
  export function SnapStack({
29
47
  element: { props },
30
48
  children,
@@ -32,24 +50,79 @@ export function SnapStack({
32
50
  element: { props: Record<string, unknown> };
33
51
  children?: ReactNode;
34
52
  }) {
53
+ const parentDirection = useSnapStackDirection();
35
54
  const direction = String(props.direction ?? "vertical");
36
55
  const gapKey = String(props.gap ?? "md");
37
56
  const isHorizontal = direction === "horizontal";
38
57
  const gap = isHorizontal
39
58
  ? (HGAP[gapKey] ?? "gap-2")
40
59
  : (VGAP[gapKey] ?? "gap-4");
41
- const justify = props.justify ? JUSTIFY[String(props.justify)] : undefined;
60
+ const justifyKey = props.justify ? String(props.justify) : undefined;
61
+ const justifyFlex = justifyKey ? JUSTIFY_FLEX[justifyKey] : undefined;
62
+ const buttonRowGrid =
63
+ isHorizontal && horizontalChildrenAreAllButtons(children);
64
+ const buttonRowCount = buttonRowGrid
65
+ ? countRenderableChildren(children)
66
+ : 0;
67
+
68
+ const columnsRaw = props.columns;
69
+ const columns =
70
+ typeof columnsRaw === "number" &&
71
+ columnsRaw >= 2 &&
72
+ columnsRaw <= 6 &&
73
+ Number.isInteger(columnsRaw)
74
+ ? columnsRaw
75
+ : undefined;
76
+ const explicitColumnGrid =
77
+ isHorizontal && columns !== undefined && !buttonRowGrid;
78
+ const columnGridClass =
79
+ explicitColumnGrid && columns !== undefined
80
+ ? COLUMN_GRID_CLASS[columns]
81
+ : undefined;
82
+
83
+ /**
84
+ * Row peers under a horizontal stack must shrink and share width (`flex-1` + `min-w-0`).
85
+ * Avoid `w-full` here: it resolves to 100% of the flex/grid container and fights peer sizing,
86
+ * so each column stacks on its own wrapped row instead of sitting side-by-side.
87
+ */
88
+ const isRowChild = parentDirection === "horizontal";
89
+ const rootWidthClass = isRowChild
90
+ ? "min-w-0 flex-1 basis-0 max-w-full"
91
+ : "w-full min-w-0";
92
+
93
+ const justifyBlockGrid =
94
+ justifyFlex &&
95
+ (!isHorizontal || (!buttonRowGrid && !explicitColumnGrid));
96
+
97
+ /** Single flex row (nowrap): peers stay side-by-side and shrink via min-w-0 / flex-1 on nested stacks. */
98
+ const horizontalFlexClasses =
99
+ "flex min-w-0 flex-row flex-nowrap items-stretch [&>*]:min-w-0";
42
100
 
43
101
  return (
44
- <div
45
- className={cn(
46
- "flex w-full",
47
- isHorizontal ? "flex-row items-center flex-wrap" : "flex-col",
48
- gap,
49
- justify,
50
- )}
102
+ <SnapStackDirectionProvider
103
+ direction={isHorizontal ? "horizontal" : "vertical"}
51
104
  >
52
- {children}
53
- </div>
105
+ <div
106
+ className={cn(
107
+ rootWidthClass,
108
+ isHorizontal
109
+ ? buttonRowGrid &&
110
+ buttonRowCount >= 1 &&
111
+ buttonRowCount <= 6 &&
112
+ COLUMN_GRID_CLASS[buttonRowCount]
113
+ ? cn(
114
+ COLUMN_GRID_CLASS[buttonRowCount]!,
115
+ gap,
116
+ "[&>*]:w-full",
117
+ )
118
+ : explicitColumnGrid && columnGridClass
119
+ ? cn(columnGridClass, gap)
120
+ : cn(horizontalFlexClasses, gap, justifyBlockGrid ? justifyFlex : undefined)
121
+ : cn("flex min-w-0 w-full flex-col", gap, justifyFlex),
122
+ )}
123
+ >
124
+ {children}
125
+ </div>
126
+ </SnapStackDirectionProvider>
54
127
  );
55
128
  }
@@ -1,7 +1,9 @@
1
1
  "use client";
2
2
 
3
3
  import { Text } from "@neynar/ui/typography";
4
+ import { cn } from "@neynar/ui/utils";
4
5
  import { useSnapColors } from "../hooks/use-snap-colors";
6
+ import { useSnapStackDirection } from "../stack-direction-context";
5
7
 
6
8
  const SIZE_MAP = {
7
9
  md: { textSize: "base" as const },
@@ -19,13 +21,18 @@ export function SnapText({
19
21
  const align = (props.align as "left" | "center" | "right") ?? undefined;
20
22
  const config = SIZE_MAP[size] ?? SIZE_MAP.md;
21
23
  const colors = useSnapColors();
24
+ const stackDir = useSnapStackDirection();
25
+ const inHorizontalStack = stackDir === "horizontal";
22
26
 
23
27
  return (
24
28
  <Text
25
29
  size={config.textSize}
26
30
  weight={weight}
27
31
  align={align}
28
- className="flex-1"
32
+ className={cn(
33
+ /** Row peers hug content like RN `wrapRow`; avoid `flex-1` stretching peers across the row. */
34
+ inHorizontalStack ? "min-w-0 shrink" : "flex-1",
35
+ )}
29
36
  style={{ color: colors.text }}
30
37
  >
31
38
  {content}
@@ -4,8 +4,7 @@ import { useMemo } from "react";
4
4
  import { useStateStore } from "@json-render/react";
5
5
  import { resolveSnapPaletteHex } from "../lib/resolve-palette-hex";
6
6
  import { useSnapPreviewPageAccent, useSnapAppearance } from "../accent-context";
7
- import type { PaletteColor } from "@farcaster/snap";
8
- import { PALETTE_DARK_HEX, PALETTE_LIGHT_HEX } from "@farcaster/snap";
7
+ import { resolveSnapColorHex } from "@farcaster/snap";
9
8
 
10
9
  /** Readable foreground color (black or white) for a given hex background. */
11
10
  export function pickForegroundForBg(hex: string): string {
@@ -76,7 +75,6 @@ function buildSnapColors(
76
75
  const accent = resolveSnapPaletteHex(accentName, mode);
77
76
  const accentFg = pickForegroundForBg(accent);
78
77
  const neutrals = mode === "dark" ? NEUTRAL_DARK : NEUTRAL_LIGHT;
79
- const paletteMap = mode === "dark" ? PALETTE_DARK_HEX : PALETTE_LIGHT_HEX;
80
78
 
81
79
  const accentHover =
82
80
  mode === "light"
@@ -87,11 +85,8 @@ function buildSnapColors(
87
85
 
88
86
  const paletteHex = (name: string) => resolveSnapPaletteHex(name, mode);
89
87
 
90
- const colorHex = (name: string | undefined) => {
91
- if (!name || name === "accent") return accent;
92
- if (Object.hasOwn(paletteMap, name)) return paletteMap[name as PaletteColor];
93
- return accent;
94
- };
88
+ const colorHex = (name: string | undefined) =>
89
+ resolveSnapColorHex(name, { accentHex: accent, appearance: mode });
95
90
 
96
91
  return {
97
92
  accent,
@@ -0,0 +1,27 @@
1
+ "use client";
2
+
3
+ import { createContext, useContext, type ReactNode } from "react";
4
+
5
+ export type SnapStackDirection = "vertical" | "horizontal";
6
+
7
+ const SnapStackDirectionContext = createContext<SnapStackDirection | undefined>(
8
+ undefined,
9
+ );
10
+
11
+ export function SnapStackDirectionProvider({
12
+ direction,
13
+ children,
14
+ }: {
15
+ direction: SnapStackDirection;
16
+ children: ReactNode;
17
+ }) {
18
+ return (
19
+ <SnapStackDirectionContext.Provider value={direction}>
20
+ {children}
21
+ </SnapStackDirectionContext.Provider>
22
+ );
23
+ }
24
+
25
+ export function useSnapStackDirection(): SnapStackDirection | undefined {
26
+ return useContext(SnapStackDirectionContext);
27
+ }
@@ -66,13 +66,17 @@ export function SnapCellGrid({
66
66
  const ringOuter = appearance === "dark" ? "#fff" : "#000";
67
67
  const ringInner = appearance === "dark" ? "#000" : "#fff";
68
68
 
69
+ /** Cells without a palette `color` — subtle fill so empty slots read as tiles. */
70
+ const emptyCellBg =
71
+ appearance === "dark" ? "rgba(255, 255, 255, 0.05)" : "rgba(0, 0, 0, 0.05)";
72
+
69
73
  const rowEls = [];
70
74
  for (let r = 0; r < rows; r++) {
71
75
  const rowCells = [];
72
76
  for (let c = 0; c < cols; c++) {
73
77
  const cell = cellMap.get(`${r},${c}`);
74
78
  const selected = interactive && isSelected(r, c);
75
- const bg = cell?.color ? hex(cell.color) : "transparent";
79
+ const bg = cell?.color ? hex(cell.color) : emptyCellBg;
76
80
 
77
81
  const cellContent = cell?.content ? (
78
82
  <Text style={[styles.cellText, { color: colors.textPrimary }]}>
@@ -121,18 +125,9 @@ export function SnapCellGrid({
121
125
  );
122
126
  }
123
127
 
124
- const selectionLabel = isSelectable && selectedSet.size > 0
125
- ? `inputs.${name}: ${[...selectedSet].join(isMultiple ? " | " : "")}`
126
- : null;
127
-
128
128
  return (
129
129
  <View style={[styles.wrap, { gap: gapPx, backgroundColor: colors.muted, padding: 4, borderRadius: 8 }]}>
130
130
  {rowEls}
131
- {selectionLabel ? (
132
- <Text style={[styles.selectionText, { color: colors.textSecondary }]}>
133
- {selectionLabel}
134
- </Text>
135
- ) : null}
136
131
  </View>
137
132
  );
138
133
  }
@@ -153,5 +148,4 @@ const styles = StyleSheet.create({
153
148
  justifyContent: "center",
154
149
  },
155
150
  cellText: { fontSize: 12, lineHeight: 16, fontWeight: "600" },
156
- selectionText: { fontSize: 11, fontFamily: "monospace", marginTop: 6 },
157
151
  });
@@ -1,6 +1,7 @@
1
1
  import type { ComponentRenderProps } from "@json-render/react-native";
2
2
  import { Image } from "expo-image";
3
3
  import { StyleSheet, View } from "react-native";
4
+ import { useSnapStackDirection } from "../stack-direction-context";
4
5
 
5
6
  function aspectToRatio(aspect: string): number {
6
7
  const [w, h] = aspect.split(":").map(Number);
@@ -14,9 +15,17 @@ export function SnapImage({
14
15
  const url = String(props.url ?? "");
15
16
  const alt = String(props.alt ?? "");
16
17
  const ratio = aspectToRatio(String(props.aspect ?? "1:1"));
18
+ const stackDir = useSnapStackDirection();
19
+ const inHorizontalStack = stackDir === "horizontal";
17
20
 
18
21
  return (
19
- <View style={[styles.frame, { aspectRatio: ratio }]}>
22
+ <View
23
+ style={[
24
+ styles.frame,
25
+ inHorizontalStack ? styles.frameInHorizontalRow : styles.frameFullWidth,
26
+ { aspectRatio: ratio },
27
+ ]}
28
+ >
20
29
  <Image
21
30
  source={{ uri: url }}
22
31
  style={StyleSheet.absoluteFill}
@@ -29,9 +38,15 @@ export function SnapImage({
29
38
 
30
39
  const styles = StyleSheet.create({
31
40
  frame: {
32
- width: "100%",
33
41
  borderRadius: 8,
34
42
  overflow: "hidden",
35
43
  backgroundColor: "#f3f4f6",
36
44
  },
45
+ frameFullWidth: {
46
+ width: "100%",
47
+ },
48
+ frameInHorizontalRow: {
49
+ flex: 1,
50
+ minWidth: 0,
51
+ },
37
52
  });
@@ -1,6 +1,7 @@
1
1
  import type { ComponentRenderProps } from "@json-render/react-native";
2
2
  import type { ReactNode } from "react";
3
3
  import { StyleSheet, Text, View } from "react-native";
4
+ import { useSnapStackDirection } from "../stack-direction-context";
4
5
  import { useSnapTheme } from "../theme";
5
6
 
6
7
  export function SnapItem({
@@ -12,12 +13,19 @@ export function SnapItem({
12
13
  const description = props.description
13
14
  ? String(props.description)
14
15
  : undefined;
15
- const variant = String(props.variant ?? "default");
16
+ /** Match web `Item className="flex-1"`: row peers must share width or title/description collapse. */
17
+ const rowPeer = useSnapStackDirection() === "horizontal";
16
18
 
17
19
  const containerVariant = { paddingVertical: 6, paddingHorizontal: 10 };
18
20
 
19
21
  return (
20
- <View style={[styles.container, containerVariant]}>
22
+ <View
23
+ style={[
24
+ styles.container,
25
+ containerVariant,
26
+ rowPeer && styles.rowPeer,
27
+ ]}
28
+ >
21
29
  <View style={styles.content}>
22
30
  {title ? <Text style={[styles.title, { color: colors.text }]}>{title}</Text> : null}
23
31
  {description ? (
@@ -40,6 +48,10 @@ const styles = StyleSheet.create({
40
48
  flexDirection: "row",
41
49
  alignItems: "center",
42
50
  },
51
+ rowPeer: {
52
+ flex: 1,
53
+ minWidth: 0,
54
+ },
43
55
  content: {
44
56
  flex: 1,
45
57
  },
@@ -1,5 +1,6 @@
1
1
  import type { ComponentRenderProps } from "@json-render/react-native";
2
2
  import { StyleSheet, Text, View } from "react-native";
3
+ import { useSnapStackDirection } from "../stack-direction-context";
3
4
  import { useSnapPalette } from "../use-snap-palette";
4
5
  import { useSnapTheme } from "../theme";
5
6
 
@@ -12,9 +13,10 @@ export function SnapProgress({
12
13
  const max = Math.max(1, Number(props.max ?? 100));
13
14
  const percent = Math.min(100, Math.max(0, (value / max) * 100));
14
15
  const label = props.label != null ? String(props.label) : null;
16
+ const inHorizontalStack = useSnapStackDirection() === "horizontal";
15
17
 
16
18
  return (
17
- <View style={styles.wrap}>
19
+ <View style={[styles.wrap, inHorizontalStack ? styles.wrapRowPeer : styles.wrapCol]}>
18
20
  {label ? (
19
21
  <Text style={[styles.label, { color: colors.textSecondary }]}>{label}</Text>
20
22
  ) : null}
@@ -26,7 +28,11 @@ export function SnapProgress({
26
28
  }
27
29
 
28
30
  const styles = StyleSheet.create({
29
- wrap: { width: "100%", gap: 4 },
31
+ wrap: { gap: 4 },
32
+ /** Vertical stacks: span card width (matches web `w-full`). */
33
+ wrapCol: { width: "100%" },
34
+ /** Horizontal row peers: share space; `width: 100%` each overflows the row. */
35
+ wrapRowPeer: { flex: 1, minWidth: 0 },
30
36
  label: { fontSize: 13, lineHeight: 18 },
31
37
  track: {
32
38
  height: 10,