@farcaster/snap 2.2.0 → 2.3.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Neynar
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
package/dist/colors.d.ts CHANGED
@@ -34,6 +34,14 @@ export declare function resolveSnapColorHex(color: string | undefined, opts: {
34
34
  accentHex: string;
35
35
  appearance: "light" | "dark";
36
36
  }): string;
37
+ /**
38
+ * Pick a readable text color for a given hex background.
39
+ *
40
+ * Uses WCAG relative luminance with a 0.5 threshold. Returns `rgba(...)` so
41
+ * callers can soften the text against the background — defaults to 0.8 alpha
42
+ * to let a hint of the cell color bleed through.
43
+ */
44
+ export declare function readableTextOnHex(hex: string, alpha?: number): string;
37
45
  /** Light-mode hex for each palette color (emulator / reference client). */
38
46
  export declare const PALETTE_LIGHT_HEX: Record<PaletteColor, string>;
39
47
  /** Dark-mode hex for each palette color (reference). */
package/dist/colors.js CHANGED
@@ -54,6 +54,27 @@ export function resolveSnapColorHex(color, opts) {
54
54
  }
55
55
  return opts.accentHex;
56
56
  }
57
+ /**
58
+ * Pick a readable text color for a given hex background.
59
+ *
60
+ * Uses WCAG relative luminance with a 0.5 threshold. Returns `rgba(...)` so
61
+ * callers can soften the text against the background — defaults to 0.8 alpha
62
+ * to let a hint of the cell color bleed through.
63
+ */
64
+ export function readableTextOnHex(hex, alpha = 0.8) {
65
+ const m = /^#([0-9a-fA-F]{6})$/.exec(hex.trim());
66
+ if (!m)
67
+ return `rgba(0,0,0,${alpha})`;
68
+ const n = Number.parseInt(m[1], 16);
69
+ const toLin = (c) => {
70
+ const s = c / 255;
71
+ return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
72
+ };
73
+ const L = 0.2126 * toLin((n >> 16) & 0xff) +
74
+ 0.7152 * toLin((n >> 8) & 0xff) +
75
+ 0.0722 * toLin(n & 0xff);
76
+ return L >= 0.5 ? `rgba(0,0,0,${alpha})` : `rgba(255,255,255,${alpha})`;
77
+ }
57
78
  /** Light-mode hex for each palette color (emulator / reference client). */
58
79
  export const PALETTE_LIGHT_HEX = {
59
80
  gray: "#6E6A86",
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
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";
3
+ export { DEFAULT_THEME_ACCENT, PALETTE_COLOR, PALETTE_COLOR_ACCENT, PALETTE_COLOR_VALUES, PALETTE_LIGHT_HEX, PALETTE_DARK_HEX, isSnapHexColorString, readableTextOnHex, resolveSnapColorHex, type PaletteColor, } from "./colors.js";
4
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
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";
2
+ export { DEFAULT_THEME_ACCENT, PALETTE_COLOR, PALETTE_COLOR_ACCENT, PALETTE_COLOR_VALUES, PALETTE_LIGHT_HEX, PALETTE_DARK_HEX, isSnapHexColorString, readableTextOnHex, resolveSnapColorHex, } from "./colors.js";
3
3
  export { ACTION_TYPE_GET, ACTION_TYPE_POST, snapResponseSchema, payloadSchema, getPayloadSchema, } from "./schemas.js";
4
4
  export { validateSnapResponse } from "./validator.js";
@@ -5,6 +5,7 @@ import { ExternalLink } from "lucide-react";
5
5
  import { Button } from "@neynar/ui/button";
6
6
  import { cn } from "@neynar/ui/utils";
7
7
  import { useSnapColors } from "../hooks/use-snap-colors.js";
8
+ import { useSnapStackDirection } from "../stack-direction-context.js";
8
9
  import { ICON_MAP } from "./icon.js";
9
10
  function isExternalLinkAction(on) {
10
11
  if (!on)
@@ -24,6 +25,7 @@ export function SnapActionButton({ element, emit, }) {
24
25
  const [hovered, setHovered] = useState(false);
25
26
  const Icon = iconName ? ICON_MAP[iconName] : undefined;
26
27
  const showExternalIcon = isExternalLinkAction(element.on);
28
+ const inHorizontalStack = useSnapStackDirection() === "horizontal";
27
29
  const style = {
28
30
  cursor: "pointer",
29
31
  ...(isPrimary
@@ -40,5 +42,13 @@ export function SnapActionButton({ element, emit, }) {
40
42
  borderColor: "transparent",
41
43
  }),
42
44
  };
43
- return (_jsx("div", { className: "w-full min-w-0 flex-1", children: _jsxs(Button, { type: "button", variant: isPrimary ? "default" : "secondary", className: cn("w-full gap-2"), style: style, onClick: () => emit("press"), onPointerEnter: () => setHovered(true), onPointerLeave: () => setHovered(false), children: [Icon && _jsx(Icon, { size: 16 }), label, showExternalIcon && (_jsx(ExternalLink, { size: 14, style: { opacity: 0.6 } }))] }) }));
45
+ return (
46
+ /**
47
+ * In a horizontal stack, `flex-1` lets the wrapper share row width with peers.
48
+ * In a vertical stack, `flex-1` would silently grow the button to fill column
49
+ * height (1/N distribution when siblings also flex-grow); stick to `w-full`.
50
+ */
51
+ _jsx("div", { className: inHorizontalStack
52
+ ? "w-full min-w-0 flex-1"
53
+ : "w-full min-w-0", children: _jsxs(Button, { type: "button", variant: isPrimary ? "default" : "secondary", className: cn("w-full gap-2"), style: style, onClick: () => emit("press"), onPointerEnter: () => setHovered(true), onPointerLeave: () => setHovered(false), children: [Icon && _jsx(Icon, { size: 16 }), label, showExternalIcon && (_jsx(ExternalLink, { size: 14, style: { opacity: 0.6 } }))] }) }));
44
54
  }
@@ -2,7 +2,7 @@
2
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
- import { POST_GRID_TAP_KEY } from "@farcaster/snap";
5
+ import { POST_GRID_TAP_KEY, readableTextOnHex } from "@farcaster/snap";
6
6
  import { useSnapColors } from "../hooks/use-snap-colors.js";
7
7
  export function SnapCellGrid({ element: { props, on }, emit, }) {
8
8
  const { get, set } = useStateStore();
@@ -22,38 +22,46 @@ export function SnapCellGrid({ element: { props, on }, emit, }) {
22
22
  const name = props.name ? String(props.name) : POST_GRID_TAP_KEY;
23
23
  const tapPath = `/inputs/${name}`;
24
24
  const tapRaw = get(tapPath);
25
- // Parse selection single mode: "row,col" string; multi mode: "row,col|row,col|..." string
25
+ const cellMap = new Map();
26
+ for (const c of cells) {
27
+ cellMap.set(`${Number(c.row)},${Number(c.col)}`, {
28
+ color: c.color,
29
+ content: c.content != null ? String(c.content) : undefined,
30
+ value: typeof c.value === "string" ? c.value : undefined,
31
+ });
32
+ }
33
+ // Each cell's wire value — its `value` if set, otherwise "row,col" fallback.
34
+ const cellWireValue = (r, c) => cellMap.get(`${r},${c}`)?.value ?? `${r},${c}`;
35
+ // Multi mode joins values with `|`; single mode is the value itself.
26
36
  const selectedSet = new Set();
27
37
  if (typeof tapRaw === "string" && tapRaw.length > 0) {
28
- for (const part of tapRaw.split("|")) {
29
- if (part.includes(","))
30
- selectedSet.add(part);
38
+ if (isMultiple) {
39
+ for (const part of tapRaw.split("|")) {
40
+ if (part.length > 0)
41
+ selectedSet.add(part);
42
+ }
43
+ }
44
+ else {
45
+ selectedSet.add(tapRaw);
31
46
  }
32
47
  }
33
- const isSelected = (r, c) => isSelectable && selectedSet.has(`${r},${c}`);
48
+ const isSelected = (r, c) => isSelectable && selectedSet.has(cellWireValue(r, c));
34
49
  const handleTap = (r, c) => {
35
- const key = `${r},${c}`;
50
+ const wire = cellWireValue(r, c);
36
51
  if (isMultiple) {
37
52
  const next = new Set(selectedSet);
38
- if (next.has(key))
39
- next.delete(key);
53
+ if (next.has(wire))
54
+ next.delete(wire);
40
55
  else
41
- next.add(key);
56
+ next.add(wire);
42
57
  set(tapPath, [...next].join("|"));
43
58
  }
44
59
  else {
45
- set(tapPath, key);
60
+ set(tapPath, wire);
46
61
  }
47
62
  if (hasPressAction)
48
63
  emit("press");
49
64
  };
50
- const cellMap = new Map();
51
- for (const c of cells) {
52
- cellMap.set(`${Number(c.row)},${Number(c.col)}`, {
53
- color: c.color,
54
- content: c.content != null ? String(c.content) : undefined,
55
- });
56
- }
57
65
  /** Cells without a palette `color` — subtle fill so empty slots read as tiles. */
58
66
  const emptyCellBg = colors.mode === "dark" ? "rgba(255, 255, 255, 0.05)" : "rgba(0, 0, 0, 0.05)";
59
67
  const cellEls = [];
@@ -61,7 +69,9 @@ export function SnapCellGrid({ element: { props, on }, emit, }) {
61
69
  for (let c = 0; c < cols; c++) {
62
70
  const cell = cellMap.get(`${r},${c}`);
63
71
  const selected = interactive && isSelected(r, c);
64
- const bg = cell?.color ? colors.colorHex(cell.color) : emptyCellBg;
72
+ const bgHex = cell?.color ? colors.colorHex(cell.color) : null;
73
+ const bg = bgHex ?? emptyCellBg;
74
+ const textColor = bgHex ? readableTextOnHex(bgHex) : colors.text;
65
75
  cellEls.push(_jsx("div", { role: interactive ? "button" : undefined, tabIndex: interactive ? 0 : undefined, onClick: interactive ? () => handleTap(r, c) : undefined, onKeyDown: interactive
66
76
  ? (e) => {
67
77
  if (e.key === "Enter" || e.key === " ") {
@@ -72,6 +82,7 @@ export function SnapCellGrid({ element: { props, on }, emit, }) {
72
82
  : undefined, className: cn("flex items-center justify-center rounded text-xs font-semibold", interactive ? "cursor-pointer select-none" : "cursor-default"), style: {
73
83
  height: rowHeight,
74
84
  background: bg,
85
+ color: textColor,
75
86
  boxShadow: selected
76
87
  ? `inset 0 0 0 1px ${colors.mode === "dark" ? "#000" : "#fff"}, inset 0 0 0 2px ${colors.mode === "dark" ? "#fff" : "#000"}`
77
88
  : undefined,
@@ -1,10 +1,15 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { Item, ItemContent, ItemTitle, ItemDescription, ItemActions, } from "@neynar/ui/item";
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
  export function SnapItem({ element: { props, children: childIds }, children, }) {
6
8
  const title = String(props.title ?? "");
7
9
  const description = props.description ? String(props.description) : undefined;
8
10
  const colors = useSnapColors();
9
- return (_jsxs(Item, { className: "flex-1 py-1.5 px-2.5", children: [_jsxs(ItemContent, { className: "gap-0.5", children: [_jsx(ItemTitle, { style: { color: colors.text }, children: title }), description && (_jsx(ItemDescription, { className: "mt-0", style: { color: colors.textMuted }, children: description }))] }), childIds && childIds.length > 0 && _jsx(ItemActions, { children: children })] }));
11
+ const inHorizontalStack = useSnapStackDirection() === "horizontal";
12
+ return (_jsxs(Item, { className: cn("py-1.5 px-2.5",
13
+ /** Horizontal: share width with peers. Vertical: don't fill column height. */
14
+ inHorizontalStack && "flex-1"), children: [_jsxs(ItemContent, { className: "gap-0.5", children: [_jsx(ItemTitle, { style: { color: colors.text }, children: title }), description && (_jsx(ItemDescription, { className: "mt-0", style: { color: colors.textMuted }, children: description }))] }), childIds && childIds.length > 0 && _jsx(ItemActions, { children: children })] }));
10
15
  }
@@ -1,11 +1,16 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { cn } from "@neynar/ui/utils";
3
4
  import { useSnapColors } from "../hooks/use-snap-colors.js";
5
+ import { useSnapStackDirection } from "../stack-direction-context.js";
4
6
  export function SnapProgress({ element: { props }, }) {
5
7
  const colors = useSnapColors();
6
8
  const value = Number(props.value ?? 0);
7
9
  const max = Math.max(1, Number(props.max ?? 100));
8
10
  const percent = Math.min(100, Math.max(0, (value / max) * 100));
9
11
  const label = props.label ? String(props.label) : null;
10
- return (_jsxs("div", { className: "flex w-full flex-1 flex-col gap-1", children: [label && (_jsx("span", { className: "text-xs", style: { color: colors.textMuted }, children: label })), _jsx("div", { className: "h-2.5 w-full overflow-hidden rounded-full", style: { backgroundColor: colors.muted }, children: _jsx("div", { className: "h-full rounded-full transition-all", style: { width: `${percent}%`, backgroundColor: colors.accent } }) })] }));
12
+ const inHorizontalStack = useSnapStackDirection() === "horizontal";
13
+ return (_jsxs("div", { className: cn("flex w-full flex-col gap-1",
14
+ /** Horizontal: share width with peers. Vertical: don't fill column height. */
15
+ inHorizontalStack && "flex-1"), children: [label && (_jsx("span", { className: "text-xs", style: { color: colors.textMuted }, children: label })), _jsx("div", { className: "h-2.5 w-full overflow-hidden rounded-full", style: { backgroundColor: colors.muted }, children: _jsx("div", { className: "h-full rounded-full transition-all", style: { width: `${percent}%`, backgroundColor: colors.accent } }) })] }));
11
16
  }
@@ -1,7 +1,7 @@
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";
4
+ import { countRenderableChildren, defaultHorizontalGapSize, horizontalChildrenAreAllButtons, } from "../../stack-horizontal-utils.js";
5
5
  import { SnapStackDirectionProvider, useSnapStackDirection, } from "../stack-direction-context.js";
6
6
  const VGAP = {
7
7
  none: "gap-0",
@@ -13,7 +13,7 @@ const HGAP = {
13
13
  none: "gap-0",
14
14
  sm: "gap-1",
15
15
  md: "gap-2",
16
- lg: "gap-3",
16
+ lg: "gap-4",
17
17
  };
18
18
  const JUSTIFY_FLEX = {
19
19
  start: "justify-start",
@@ -34,11 +34,7 @@ const COLUMN_GRID_CLASS = {
34
34
  export function SnapStack({ element: { props }, children, }) {
35
35
  const parentDirection = useSnapStackDirection();
36
36
  const direction = String(props.direction ?? "vertical");
37
- const gapKey = String(props.gap ?? "md");
38
37
  const isHorizontal = direction === "horizontal";
39
- const gap = isHorizontal
40
- ? (HGAP[gapKey] ?? "gap-2")
41
- : (VGAP[gapKey] ?? "gap-4");
42
38
  const justifyKey = props.justify ? String(props.justify) : undefined;
43
39
  const justifyFlex = justifyKey ? JUSTIFY_FLEX[justifyKey] : undefined;
44
40
  const buttonRowGrid = isHorizontal && horizontalChildrenAreAllButtons(children);
@@ -52,6 +48,23 @@ export function SnapStack({ element: { props }, children, }) {
52
48
  Number.isInteger(columnsRaw)
53
49
  ? columnsRaw
54
50
  : undefined;
51
+ // Horizontal default depends on column count: 2→lg, 3→md, 4+→sm. Vertical stays md.
52
+ // Count comes from explicit `columns`, then button-row inference, else direct children
53
+ // count (any horizontal stack is N columns wide regardless of child types).
54
+ const horizontalColumnCount = isHorizontal
55
+ ? (columns ??
56
+ (buttonRowGrid ? buttonRowCount : undefined) ??
57
+ countRenderableChildren(children))
58
+ : undefined;
59
+ const explicitGap = typeof props.gap === "string" && props.gap in (isHorizontal ? HGAP : VGAP);
60
+ const gapKey = explicitGap
61
+ ? String(props.gap)
62
+ : isHorizontal
63
+ ? defaultHorizontalGapSize(horizontalColumnCount)
64
+ : "md";
65
+ const gap = isHorizontal
66
+ ? (HGAP[gapKey] ?? HGAP.md)
67
+ : (VGAP[gapKey] ?? VGAP.md);
55
68
  const explicitColumnGrid = isHorizontal && columns !== undefined && !buttonRowGrid;
56
69
  const columnGridClass = explicitColumnGrid && columns !== undefined
57
70
  ? COLUMN_GRID_CLASS[columns]
@@ -18,6 +18,13 @@ export function SnapText({ element: { props }, }) {
18
18
  const stackDir = useSnapStackDirection();
19
19
  const inHorizontalStack = stackDir === "horizontal";
20
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 }));
21
+ /**
22
+ * Row peers hug content like RN `wrapRow` — `min-w-0 shrink` lets text wrap
23
+ * inside a horizontal stack without forcing peers wide. In a vertical stack
24
+ * the `<p>` already fills its parent's width via `display: block`; avoid
25
+ * `flex-1` here because `flex-grow: 1` on a vertical-flex child fills the
26
+ * column's height, distributing siblings when the row is taller than its
27
+ * content (e.g. text next to a tall image).
28
+ */
29
+ inHorizontalStack ? "min-w-0 shrink" : "min-w-0"), style: { color: colors.text }, children: content }));
23
30
  }
@@ -3,7 +3,7 @@ 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";
5
5
  import { useSnapTheme } from "../theme.js";
6
- import { POST_GRID_TAP_KEY } from "@farcaster/snap";
6
+ import { POST_GRID_TAP_KEY, readableTextOnHex } from "@farcaster/snap";
7
7
  export function SnapCellGrid({ element, emit, }) {
8
8
  const { props } = element;
9
9
  const on = element.on;
@@ -25,37 +25,46 @@ export function SnapCellGrid({ element, emit, }) {
25
25
  const name = props.name ? String(props.name) : POST_GRID_TAP_KEY;
26
26
  const tapPath = `/inputs/${name}`;
27
27
  const tapRaw = get(tapPath);
28
+ const cellMap = new Map();
29
+ for (const c of cells) {
30
+ cellMap.set(`${Number(c.row)},${Number(c.col)}`, {
31
+ color: c.color,
32
+ content: c.content != null ? String(c.content) : undefined,
33
+ value: typeof c.value === "string" ? c.value : undefined,
34
+ });
35
+ }
36
+ // Each cell's wire value — its `value` if set, otherwise "row,col" fallback.
37
+ const cellWireValue = (r, c) => cellMap.get(`${r},${c}`)?.value ?? `${r},${c}`;
38
+ // Multi mode joins values with `|`; single mode is the value itself.
28
39
  const selectedSet = new Set();
29
40
  if (typeof tapRaw === "string" && tapRaw.length > 0) {
30
- for (const part of tapRaw.split("|")) {
31
- if (part.includes(","))
32
- selectedSet.add(part);
41
+ if (isMultiple) {
42
+ for (const part of tapRaw.split("|")) {
43
+ if (part.length > 0)
44
+ selectedSet.add(part);
45
+ }
46
+ }
47
+ else {
48
+ selectedSet.add(tapRaw);
33
49
  }
34
50
  }
35
- const isSelected = (r, c) => isSelectable && selectedSet.has(`${r},${c}`);
51
+ const isSelected = (r, c) => isSelectable && selectedSet.has(cellWireValue(r, c));
36
52
  const handleTap = (r, c) => {
37
- const key = `${r},${c}`;
53
+ const wire = cellWireValue(r, c);
38
54
  if (isMultiple) {
39
55
  const next = new Set(selectedSet);
40
- if (next.has(key))
41
- next.delete(key);
56
+ if (next.has(wire))
57
+ next.delete(wire);
42
58
  else
43
- next.add(key);
59
+ next.add(wire);
44
60
  set(tapPath, [...next].join("|"));
45
61
  }
46
62
  else {
47
- set(tapPath, key);
63
+ set(tapPath, wire);
48
64
  }
49
65
  if (hasPressAction)
50
66
  emit("press");
51
67
  };
52
- const cellMap = new Map();
53
- for (const c of cells) {
54
- cellMap.set(`${Number(c.row)},${Number(c.col)}`, {
55
- color: c.color,
56
- content: c.content != null ? String(c.content) : undefined,
57
- });
58
- }
59
68
  const ringOuter = appearance === "dark" ? "#fff" : "#000";
60
69
  const ringInner = appearance === "dark" ? "#000" : "#fff";
61
70
  /** Cells without a palette `color` — subtle fill so empty slots read as tiles. */
@@ -66,8 +75,10 @@ export function SnapCellGrid({ element, emit, }) {
66
75
  for (let c = 0; c < cols; c++) {
67
76
  const cell = cellMap.get(`${r},${c}`);
68
77
  const selected = interactive && isSelected(r, c);
69
- const bg = cell?.color ? hex(cell.color) : emptyCellBg;
70
- const cellContent = cell?.content ? (_jsx(Text, { style: [styles.cellText, { color: colors.textPrimary }], children: cell.content })) : null;
78
+ const bgHex = cell?.color ? hex(cell.color) : null;
79
+ const bg = bgHex ?? emptyCellBg;
80
+ const textColor = bgHex ? readableTextOnHex(bgHex) : colors.text;
81
+ const cellContent = cell?.content ? (_jsx(Text, { style: [styles.cellText, { color: textColor }], children: cell.content })) : null;
71
82
  // Two-tone ring: outer View with contrasting border, inner View with inverse border
72
83
  const cellView = selected ? (_jsx(View, { style: [styles.cell, { height: rowHeight, borderWidth: 1, borderColor: ringOuter, borderRadius: 4 }], children: _jsx(View, { style: [
73
84
  styles.innerCell,
@@ -1,7 +1,7 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { Children } from "react";
3
3
  import { StyleSheet, View } from "react-native";
4
- import { countRenderableChildren, horizontalChildrenAreAllButtons, } from "../../stack-horizontal-utils.js";
4
+ import { countRenderableChildren, defaultHorizontalGapSize, horizontalChildrenAreAllButtons, } from "../../stack-horizontal-utils.js";
5
5
  import { SnapStackDirectionProvider, useSnapStackDirection, } from "../stack-direction-context.js";
6
6
  const VGAP = {
7
7
  none: 0,
@@ -13,7 +13,7 @@ const HGAP = {
13
13
  none: 0,
14
14
  sm: 4,
15
15
  md: 8,
16
- lg: 12,
16
+ lg: 16,
17
17
  };
18
18
  const JUSTIFY = {
19
19
  start: "flex-start",
@@ -33,11 +33,6 @@ export function SnapStack({ element: { props }, children, }) {
33
33
  const rawGap = props.gap;
34
34
  const isHorizontal = direction === "horizontal";
35
35
  const gapMap = isHorizontal ? HGAP : VGAP;
36
- const gap = typeof rawGap === "number"
37
- ? rawGap
38
- : typeof rawGap === "string" && rawGap in gapMap
39
- ? gapMap[rawGap]
40
- : isHorizontal ? HGAP.md : VGAP.md;
41
36
  const buttonRowGrid = isHorizontal && horizontalChildrenAreAllButtons(children);
42
37
  const buttonRowCount = buttonRowGrid
43
38
  ? countRenderableChildren(children)
@@ -49,6 +44,21 @@ export function SnapStack({ element: { props }, children, }) {
49
44
  Number.isInteger(columnsRaw)
50
45
  ? columnsRaw
51
46
  : undefined;
47
+ // Horizontal default depends on column count: 2→lg, 3→md, 4+→sm. Vertical stays md.
48
+ // Count comes from explicit `columns`, then button-row inference, else direct children
49
+ // count (any horizontal stack is N columns wide regardless of child types).
50
+ const horizontalColumnCount = isHorizontal
51
+ ? (columns ??
52
+ (buttonRowGrid ? buttonRowCount : undefined) ??
53
+ countRenderableChildren(children))
54
+ : undefined;
55
+ const gap = typeof rawGap === "number"
56
+ ? rawGap
57
+ : typeof rawGap === "string" && rawGap in gapMap
58
+ ? gapMap[rawGap]
59
+ : isHorizontal
60
+ ? gapMap[defaultHorizontalGapSize(horizontalColumnCount)]
61
+ : VGAP.md;
52
62
  const explicitColumnGrid = isHorizontal && columns !== undefined && !buttonRowGrid;
53
63
  const justify = props.justify &&
54
64
  (!isHorizontal || (!buttonRowGrid && !explicitColumnGrid))
@@ -2,3 +2,9 @@ import { type ReactNode } from "react";
2
2
  export declare function horizontalChildrenAreAllButtons(children: ReactNode): boolean;
3
3
  /** Direct snap catalog children under a stack (used for all-button grid column count). */
4
4
  export declare function countRenderableChildren(children: ReactNode): number;
5
+ /**
6
+ * Default horizontal stack gap as a t-shirt size, chosen by column count:
7
+ * 2 cols → lg, 3 cols → md, 4+ cols → sm. Unknown count falls back to md.
8
+ * Tighter gaps for denser layouts; authors can always override via the `gap` prop.
9
+ */
10
+ export declare function defaultHorizontalGapSize(columnCount: number | undefined): "sm" | "md" | "lg";
@@ -27,3 +27,17 @@ export function horizontalChildrenAreAllButtons(children) {
27
27
  export function countRenderableChildren(children) {
28
28
  return Children.toArray(children).filter(isRenderableChild).length;
29
29
  }
30
+ /**
31
+ * Default horizontal stack gap as a t-shirt size, chosen by column count:
32
+ * 2 cols → lg, 3 cols → md, 4+ cols → sm. Unknown count falls back to md.
33
+ * Tighter gaps for denser layouts; authors can always override via the `gap` prop.
34
+ */
35
+ export function defaultHorizontalGapSize(columnCount) {
36
+ if (columnCount === undefined)
37
+ return "md";
38
+ if (columnCount <= 2)
39
+ return "lg";
40
+ if (columnCount === 3)
41
+ return "md";
42
+ return "sm";
43
+ }
@@ -391,6 +391,7 @@ export declare const snapJsonRenderCatalog: import("@json-render/core").Catalog<
391
391
  pink: "pink";
392
392
  }>, z.ZodString]>>>;
393
393
  content: z.ZodOptional<z.ZodString>;
394
+ value: z.ZodOptional<z.ZodString>;
394
395
  }, z.core.$strip>>;
395
396
  gap: z.ZodOptional<z.ZodEnum<{
396
397
  sm: "sm";
@@ -17,6 +17,7 @@ export declare const cellGridProps: z.ZodObject<{
17
17
  pink: "pink";
18
18
  }>, z.ZodString]>>>;
19
19
  content: z.ZodOptional<z.ZodString>;
20
+ value: z.ZodOptional<z.ZodString>;
20
21
  }, z.core.$strip>>;
21
22
  gap: z.ZodOptional<z.ZodEnum<{
22
23
  sm: "sm";
@@ -15,6 +15,7 @@ const cellGridCellSchema = z.object({
15
15
  col: z.number().int().nonnegative(),
16
16
  color: cellGridCellColorSchema.optional(),
17
17
  content: z.string().optional(),
18
+ value: z.string().min(1).max(30).optional(),
18
19
  });
19
20
  export const cellGridProps = z
20
21
  .object({
package/llms.txt CHANGED
@@ -95,17 +95,17 @@ 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 | #rrggbb, content?: string }`
98
+ - `cells` (array, required): sparse list of `{ row, col, color?: PaletteColor | #rrggbb, content?: string, value?: string (1–30 chars) }`. When `value` is set on a cell, that string is what's written to `inputs[name]` on press/select; otherwise `"row,col"` is used. Use `value` for grids with meaningful labels (calendar days, alphabet letters, region codes) so handlers don't have to reverse-lookup. Use the row/col fallback for true coordinate grids (minesweeper, tic-tac-toe, pixel art).
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
- - `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.
102
- - Events: `press` — fires on cell press, only when `select: "off"`; `inputs[name]` is set to `"row,col"` before the bound action runs
101
+ - `select` (optional): `"off"` | `"single"` | `"multiple"`. Default: `"off"`. With `select: "off"`, bind `on.press` for press-to-act (each press writes the cell's `value` or `"row,col"` to `inputs[name]` and fires the action). With `"single"` / `"multiple"`, presses accumulate selection state and pair with a separate submit `button` (multi-select joins values with `|`); `on.press` is ignored.
102
+ - Events: `press` — fires on cell press, only when `select: "off"`; `inputs[name]` is set to the pressed cell's `value` (or `"row,col"` fallback) before the bound action runs
103
103
 
104
104
  ### Container Components
105
105
 
106
106
  **stack** — Layout container.
107
107
  - `direction` (optional): `"vertical"` | `"horizontal"`. Default: `"vertical"`
108
- - `gap` (optional): `"none"` | `"sm"` | `"md"` | `"lg"`. Default: `"md"`
108
+ - `gap` (optional): `"none"` | `"sm"` | `"md"` | `"lg"`. Vertical px: 0/8/16/24. Horizontal px: 0/4/8/16 (tighter, since children sit side-by-side). Default for vertical: `"md"`. Default for horizontal is column-aware: 2 cols → `"lg"` (16px), 3 cols → `"md"` (8px), 4+ cols → `"sm"` (4px), unknown → `"md"` (8px). An explicit value always wins — override the default when you have a deliberate visual reason (e.g. tighter toolbar, extra breathing room around a hero row).
109
109
  - `justify` (optional): `"start"` | `"center"` | `"end"` | `"between"` | `"around"`
110
110
  - `columns` (optional, horizontal only): `2`–`6` — CSS grid with equal columns (mixed children or layout that needs fixed column counts).
111
111
  - Children are element IDs
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@farcaster/snap",
3
- "version": "2.2.0",
3
+ "version": "2.3.1",
4
4
  "description": "Farcaster Snaps 🫰",
5
5
  "repository": {
6
6
  "type": "git",
package/src/colors.ts CHANGED
@@ -65,6 +65,28 @@ export function resolveSnapColorHex(
65
65
  return opts.accentHex;
66
66
  }
67
67
 
68
+ /**
69
+ * Pick a readable text color for a given hex background.
70
+ *
71
+ * Uses WCAG relative luminance with a 0.5 threshold. Returns `rgba(...)` so
72
+ * callers can soften the text against the background — defaults to 0.8 alpha
73
+ * to let a hint of the cell color bleed through.
74
+ */
75
+ export function readableTextOnHex(hex: string, alpha = 0.8): string {
76
+ const m = /^#([0-9a-fA-F]{6})$/.exec(hex.trim());
77
+ if (!m) return `rgba(0,0,0,${alpha})`;
78
+ const n = Number.parseInt(m[1], 16);
79
+ const toLin = (c: number) => {
80
+ const s = c / 255;
81
+ return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
82
+ };
83
+ const L =
84
+ 0.2126 * toLin((n >> 16) & 0xff) +
85
+ 0.7152 * toLin((n >> 8) & 0xff) +
86
+ 0.0722 * toLin(n & 0xff);
87
+ return L >= 0.5 ? `rgba(0,0,0,${alpha})` : `rgba(255,255,255,${alpha})`;
88
+ }
89
+
68
90
  /** Light-mode hex for each palette color (emulator / reference client). */
69
91
  export const PALETTE_LIGHT_HEX: Record<PaletteColor, string> = {
70
92
  gray: "#6E6A86",
package/src/index.ts CHANGED
@@ -25,6 +25,7 @@ export {
25
25
  PALETTE_LIGHT_HEX,
26
26
  PALETTE_DARK_HEX,
27
27
  isSnapHexColorString,
28
+ readableTextOnHex,
28
29
  resolveSnapColorHex,
29
30
  type PaletteColor,
30
31
  } from "./colors";
@@ -5,6 +5,7 @@ import { ExternalLink } from "lucide-react";
5
5
  import { Button } from "@neynar/ui/button";
6
6
  import { cn } from "@neynar/ui/utils";
7
7
  import { useSnapColors } from "../hooks/use-snap-colors";
8
+ import { useSnapStackDirection } from "../stack-direction-context";
8
9
  import { ICON_MAP } from "./icon";
9
10
 
10
11
  function isExternalLinkAction(
@@ -38,6 +39,7 @@ export function SnapActionButton({
38
39
 
39
40
  const Icon = iconName ? ICON_MAP[iconName] : undefined;
40
41
  const showExternalIcon = isExternalLinkAction(element.on);
42
+ const inHorizontalStack = useSnapStackDirection() === "horizontal";
41
43
 
42
44
  const style = {
43
45
  cursor: "pointer" as const,
@@ -57,7 +59,18 @@ export function SnapActionButton({
57
59
  };
58
60
 
59
61
  return (
60
- <div className="w-full min-w-0 flex-1">
62
+ /**
63
+ * In a horizontal stack, `flex-1` lets the wrapper share row width with peers.
64
+ * In a vertical stack, `flex-1` would silently grow the button to fill column
65
+ * height (1/N distribution when siblings also flex-grow); stick to `w-full`.
66
+ */
67
+ <div
68
+ className={
69
+ inHorizontalStack
70
+ ? "w-full min-w-0 flex-1"
71
+ : "w-full min-w-0"
72
+ }
73
+ >
61
74
  <Button
62
75
  type="button"
63
76
  variant={isPrimary ? "default" : "secondary"}
@@ -3,7 +3,7 @@
3
3
  import type { ReactNode } from "react";
4
4
  import { useStateStore } from "@json-render/react";
5
5
  import { cn } from "@neynar/ui/utils";
6
- import { POST_GRID_TAP_KEY } from "@farcaster/snap";
6
+ import { POST_GRID_TAP_KEY, readableTextOnHex } from "@farcaster/snap";
7
7
  import { useSnapColors } from "../hooks/use-snap-colors";
8
8
 
9
9
  export function SnapCellGrid({
@@ -32,38 +32,50 @@ export function SnapCellGrid({
32
32
  const tapPath = `/inputs/${name}`;
33
33
  const tapRaw = get(tapPath);
34
34
 
35
- // Parse selection single mode: "row,col" string; multi mode: "row,col|row,col|..." string
35
+ const cellMap = new Map<
36
+ string,
37
+ { color?: string; content?: string; value?: string }
38
+ >();
39
+ for (const c of cells) {
40
+ cellMap.set(`${Number(c.row)},${Number(c.col)}`, {
41
+ color: c.color as string | undefined,
42
+ content: c.content != null ? String(c.content) : undefined,
43
+ value: typeof c.value === "string" ? c.value : undefined,
44
+ });
45
+ }
46
+
47
+ // Each cell's wire value — its `value` if set, otherwise "row,col" fallback.
48
+ const cellWireValue = (r: number, c: number) =>
49
+ cellMap.get(`${r},${c}`)?.value ?? `${r},${c}`;
50
+
51
+ // Multi mode joins values with `|`; single mode is the value itself.
36
52
  const selectedSet = new Set<string>();
37
53
  if (typeof tapRaw === "string" && tapRaw.length > 0) {
38
- for (const part of tapRaw.split("|")) {
39
- if (part.includes(",")) selectedSet.add(part);
54
+ if (isMultiple) {
55
+ for (const part of tapRaw.split("|")) {
56
+ if (part.length > 0) selectedSet.add(part);
57
+ }
58
+ } else {
59
+ selectedSet.add(tapRaw);
40
60
  }
41
61
  }
42
62
 
43
63
  const isSelected = (r: number, c: number) =>
44
- isSelectable && selectedSet.has(`${r},${c}`);
64
+ isSelectable && selectedSet.has(cellWireValue(r, c));
45
65
 
46
66
  const handleTap = (r: number, c: number) => {
47
- const key = `${r},${c}`;
67
+ const wire = cellWireValue(r, c);
48
68
  if (isMultiple) {
49
69
  const next = new Set(selectedSet);
50
- if (next.has(key)) next.delete(key);
51
- else next.add(key);
70
+ if (next.has(wire)) next.delete(wire);
71
+ else next.add(wire);
52
72
  set(tapPath, [...next].join("|"));
53
73
  } else {
54
- set(tapPath, key);
74
+ set(tapPath, wire);
55
75
  }
56
76
  if (hasPressAction) emit("press");
57
77
  };
58
78
 
59
- const cellMap = new Map<string, { color?: string; content?: string }>();
60
- for (const c of cells) {
61
- cellMap.set(`${Number(c.row)},${Number(c.col)}`, {
62
- color: c.color as string | undefined,
63
- content: c.content != null ? String(c.content) : undefined,
64
- });
65
- }
66
-
67
79
  /** Cells without a palette `color` — subtle fill so empty slots read as tiles. */
68
80
  const emptyCellBg =
69
81
  colors.mode === "dark" ? "rgba(255, 255, 255, 0.05)" : "rgba(0, 0, 0, 0.05)";
@@ -73,7 +85,9 @@ export function SnapCellGrid({
73
85
  for (let c = 0; c < cols; c++) {
74
86
  const cell = cellMap.get(`${r},${c}`);
75
87
  const selected = interactive && isSelected(r, c);
76
- const bg = cell?.color ? colors.colorHex(cell.color) : emptyCellBg;
88
+ const bgHex = cell?.color ? colors.colorHex(cell.color) : null;
89
+ const bg = bgHex ?? emptyCellBg;
90
+ const textColor = bgHex ? readableTextOnHex(bgHex) : colors.text;
77
91
 
78
92
  cellEls.push(
79
93
  <div
@@ -98,6 +112,7 @@ export function SnapCellGrid({
98
112
  style={{
99
113
  height: rowHeight,
100
114
  background: bg,
115
+ color: textColor,
101
116
  boxShadow: selected
102
117
  ? `inset 0 0 0 1px ${colors.mode === "dark" ? "#000" : "#fff"}, inset 0 0 0 2px ${colors.mode === "dark" ? "#fff" : "#000"}`
103
118
  : undefined,
@@ -7,7 +7,9 @@ import {
7
7
  ItemDescription,
8
8
  ItemActions,
9
9
  } from "@neynar/ui/item";
10
+ import { cn } from "@neynar/ui/utils";
10
11
  import { useSnapColors } from "../hooks/use-snap-colors";
12
+ import { useSnapStackDirection } from "../stack-direction-context";
11
13
 
12
14
  export function SnapItem({
13
15
  element: { props, children: childIds },
@@ -19,9 +21,16 @@ export function SnapItem({
19
21
  const title = String(props.title ?? "");
20
22
  const description = props.description ? String(props.description) : undefined;
21
23
  const colors = useSnapColors();
24
+ const inHorizontalStack = useSnapStackDirection() === "horizontal";
22
25
 
23
26
  return (
24
- <Item className="flex-1 py-1.5 px-2.5">
27
+ <Item
28
+ className={cn(
29
+ "py-1.5 px-2.5",
30
+ /** Horizontal: share width with peers. Vertical: don't fill column height. */
31
+ inHorizontalStack && "flex-1",
32
+ )}
33
+ >
25
34
  <ItemContent className="gap-0.5">
26
35
  <ItemTitle style={{ color: colors.text }}>{title}</ItemTitle>
27
36
  {description && (
@@ -1,6 +1,8 @@
1
1
  "use client";
2
2
 
3
+ import { cn } from "@neynar/ui/utils";
3
4
  import { useSnapColors } from "../hooks/use-snap-colors";
5
+ import { useSnapStackDirection } from "../stack-direction-context";
4
6
 
5
7
  export function SnapProgress({
6
8
  element: { props },
@@ -12,9 +14,16 @@ export function SnapProgress({
12
14
  const max = Math.max(1, Number(props.max ?? 100));
13
15
  const percent = Math.min(100, Math.max(0, (value / max) * 100));
14
16
  const label = props.label ? String(props.label) : null;
17
+ const inHorizontalStack = useSnapStackDirection() === "horizontal";
15
18
 
16
19
  return (
17
- <div className="flex w-full flex-1 flex-col gap-1">
20
+ <div
21
+ className={cn(
22
+ "flex w-full flex-col gap-1",
23
+ /** Horizontal: share width with peers. Vertical: don't fill column height. */
24
+ inHorizontalStack && "flex-1",
25
+ )}
26
+ >
18
27
  {label && (
19
28
  <span className="text-xs" style={{ color: colors.textMuted }}>
20
29
  {label}
@@ -4,6 +4,7 @@ import type { ReactNode } from "react";
4
4
  import { cn } from "@neynar/ui/utils";
5
5
  import {
6
6
  countRenderableChildren,
7
+ defaultHorizontalGapSize,
7
8
  horizontalChildrenAreAllButtons,
8
9
  } from "../../stack-horizontal-utils.js";
9
10
  import {
@@ -22,7 +23,7 @@ const HGAP: Record<string, string> = {
22
23
  none: "gap-0",
23
24
  sm: "gap-1",
24
25
  md: "gap-2",
25
- lg: "gap-3",
26
+ lg: "gap-4",
26
27
  };
27
28
 
28
29
  const JUSTIFY_FLEX: Record<string, string> = {
@@ -52,11 +53,7 @@ export function SnapStack({
52
53
  }) {
53
54
  const parentDirection = useSnapStackDirection();
54
55
  const direction = String(props.direction ?? "vertical");
55
- const gapKey = String(props.gap ?? "md");
56
56
  const isHorizontal = direction === "horizontal";
57
- const gap = isHorizontal
58
- ? (HGAP[gapKey] ?? "gap-2")
59
- : (VGAP[gapKey] ?? "gap-4");
60
57
  const justifyKey = props.justify ? String(props.justify) : undefined;
61
58
  const justifyFlex = justifyKey ? JUSTIFY_FLEX[justifyKey] : undefined;
62
59
  const buttonRowGrid =
@@ -73,6 +70,25 @@ export function SnapStack({
73
70
  Number.isInteger(columnsRaw)
74
71
  ? columnsRaw
75
72
  : undefined;
73
+
74
+ // Horizontal default depends on column count: 2→lg, 3→md, 4+→sm. Vertical stays md.
75
+ // Count comes from explicit `columns`, then button-row inference, else direct children
76
+ // count (any horizontal stack is N columns wide regardless of child types).
77
+ const horizontalColumnCount = isHorizontal
78
+ ? (columns ??
79
+ (buttonRowGrid ? buttonRowCount : undefined) ??
80
+ countRenderableChildren(children))
81
+ : undefined;
82
+ const explicitGap =
83
+ typeof props.gap === "string" && props.gap in (isHorizontal ? HGAP : VGAP);
84
+ const gapKey = explicitGap
85
+ ? String(props.gap)
86
+ : isHorizontal
87
+ ? defaultHorizontalGapSize(horizontalColumnCount)
88
+ : "md";
89
+ const gap = isHorizontal
90
+ ? (HGAP[gapKey] ?? HGAP.md!)
91
+ : (VGAP[gapKey] ?? VGAP.md!);
76
92
  const explicitColumnGrid =
77
93
  isHorizontal && columns !== undefined && !buttonRowGrid;
78
94
  const columnGridClass =
@@ -30,8 +30,15 @@ export function SnapText({
30
30
  weight={weight}
31
31
  align={align}
32
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",
33
+ /**
34
+ * Row peers hug content like RN `wrapRow` — `min-w-0 shrink` lets text wrap
35
+ * inside a horizontal stack without forcing peers wide. In a vertical stack
36
+ * the `<p>` already fills its parent's width via `display: block`; avoid
37
+ * `flex-1` here because `flex-grow: 1` on a vertical-flex child fills the
38
+ * column's height, distributing siblings when the row is taller than its
39
+ * content (e.g. text next to a tall image).
40
+ */
41
+ inHorizontalStack ? "min-w-0 shrink" : "min-w-0",
35
42
  )}
36
43
  style={{ color: colors.text }}
37
44
  >
@@ -3,7 +3,7 @@ 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";
5
5
  import { useSnapTheme } from "../theme";
6
- import { POST_GRID_TAP_KEY } from "@farcaster/snap";
6
+ import { POST_GRID_TAP_KEY, readableTextOnHex } from "@farcaster/snap";
7
7
 
8
8
  export function SnapCellGrid({
9
9
  element,
@@ -32,37 +32,50 @@ export function SnapCellGrid({
32
32
  const tapPath = `/inputs/${name}`;
33
33
  const tapRaw = get(tapPath);
34
34
 
35
+ const cellMap = new Map<
36
+ string,
37
+ { color?: string; content?: string; value?: string }
38
+ >();
39
+ for (const c of cells) {
40
+ cellMap.set(`${Number(c.row)},${Number(c.col)}`, {
41
+ color: c.color as string | undefined,
42
+ content: c.content != null ? String(c.content) : undefined,
43
+ value: typeof c.value === "string" ? c.value : undefined,
44
+ });
45
+ }
46
+
47
+ // Each cell's wire value — its `value` if set, otherwise "row,col" fallback.
48
+ const cellWireValue = (r: number, c: number) =>
49
+ cellMap.get(`${r},${c}`)?.value ?? `${r},${c}`;
50
+
51
+ // Multi mode joins values with `|`; single mode is the value itself.
35
52
  const selectedSet = new Set<string>();
36
53
  if (typeof tapRaw === "string" && tapRaw.length > 0) {
37
- for (const part of tapRaw.split("|")) {
38
- if (part.includes(",")) selectedSet.add(part);
54
+ if (isMultiple) {
55
+ for (const part of tapRaw.split("|")) {
56
+ if (part.length > 0) selectedSet.add(part);
57
+ }
58
+ } else {
59
+ selectedSet.add(tapRaw);
39
60
  }
40
61
  }
41
62
 
42
63
  const isSelected = (r: number, c: number) =>
43
- isSelectable && selectedSet.has(`${r},${c}`);
64
+ isSelectable && selectedSet.has(cellWireValue(r, c));
44
65
 
45
66
  const handleTap = (r: number, c: number) => {
46
- const key = `${r},${c}`;
67
+ const wire = cellWireValue(r, c);
47
68
  if (isMultiple) {
48
69
  const next = new Set(selectedSet);
49
- if (next.has(key)) next.delete(key);
50
- else next.add(key);
70
+ if (next.has(wire)) next.delete(wire);
71
+ else next.add(wire);
51
72
  set(tapPath, [...next].join("|"));
52
73
  } else {
53
- set(tapPath, key);
74
+ set(tapPath, wire);
54
75
  }
55
76
  if (hasPressAction) emit("press");
56
77
  };
57
78
 
58
- const cellMap = new Map<string, { color?: string; content?: string }>();
59
- for (const c of cells) {
60
- cellMap.set(`${Number(c.row)},${Number(c.col)}`, {
61
- color: c.color as string | undefined,
62
- content: c.content != null ? String(c.content) : undefined,
63
- });
64
- }
65
-
66
79
  const ringOuter = appearance === "dark" ? "#fff" : "#000";
67
80
  const ringInner = appearance === "dark" ? "#000" : "#fff";
68
81
 
@@ -76,10 +89,12 @@ export function SnapCellGrid({
76
89
  for (let c = 0; c < cols; c++) {
77
90
  const cell = cellMap.get(`${r},${c}`);
78
91
  const selected = interactive && isSelected(r, c);
79
- const bg = cell?.color ? hex(cell.color) : emptyCellBg;
92
+ const bgHex = cell?.color ? hex(cell.color) : null;
93
+ const bg = bgHex ?? emptyCellBg;
94
+ const textColor = bgHex ? readableTextOnHex(bgHex) : colors.text;
80
95
 
81
96
  const cellContent = cell?.content ? (
82
- <Text style={[styles.cellText, { color: colors.textPrimary }]}>
97
+ <Text style={[styles.cellText, { color: textColor }]}>
83
98
  {cell.content}
84
99
  </Text>
85
100
  ) : null;
@@ -3,6 +3,7 @@ import { Children, type ReactNode } from "react";
3
3
  import { StyleSheet, View } from "react-native";
4
4
  import {
5
5
  countRenderableChildren,
6
+ defaultHorizontalGapSize,
6
7
  horizontalChildrenAreAllButtons,
7
8
  } from "../../stack-horizontal-utils.js";
8
9
  import {
@@ -21,7 +22,7 @@ const HGAP: Record<string, number> = {
21
22
  none: 0,
22
23
  sm: 4,
23
24
  md: 8,
24
- lg: 12,
25
+ lg: 16,
25
26
  };
26
27
 
27
28
  const JUSTIFY: Record<string, "flex-start" | "center" | "flex-end" | "space-between" | "space-around"> = {
@@ -53,12 +54,6 @@ export function SnapStack({
53
54
  const rawGap = props.gap;
54
55
  const isHorizontal = direction === "horizontal";
55
56
  const gapMap = isHorizontal ? HGAP : VGAP;
56
- const gap =
57
- typeof rawGap === "number"
58
- ? rawGap
59
- : typeof rawGap === "string" && rawGap in gapMap
60
- ? gapMap[rawGap]!
61
- : isHorizontal ? HGAP.md! : VGAP.md!;
62
57
  const buttonRowGrid =
63
58
  isHorizontal && horizontalChildrenAreAllButtons(children);
64
59
  const buttonRowCount = buttonRowGrid
@@ -73,6 +68,23 @@ export function SnapStack({
73
68
  Number.isInteger(columnsRaw)
74
69
  ? columnsRaw
75
70
  : undefined;
71
+
72
+ // Horizontal default depends on column count: 2→lg, 3→md, 4+→sm. Vertical stays md.
73
+ // Count comes from explicit `columns`, then button-row inference, else direct children
74
+ // count (any horizontal stack is N columns wide regardless of child types).
75
+ const horizontalColumnCount = isHorizontal
76
+ ? (columns ??
77
+ (buttonRowGrid ? buttonRowCount : undefined) ??
78
+ countRenderableChildren(children))
79
+ : undefined;
80
+ const gap =
81
+ typeof rawGap === "number"
82
+ ? rawGap
83
+ : typeof rawGap === "string" && rawGap in gapMap
84
+ ? gapMap[rawGap]!
85
+ : isHorizontal
86
+ ? gapMap[defaultHorizontalGapSize(horizontalColumnCount)]!
87
+ : VGAP.md!;
76
88
  const explicitColumnGrid =
77
89
  isHorizontal && columns !== undefined && !buttonRowGrid;
78
90
 
@@ -25,3 +25,17 @@ export function horizontalChildrenAreAllButtons(children: ReactNode): boolean {
25
25
  export function countRenderableChildren(children: ReactNode): number {
26
26
  return Children.toArray(children).filter(isRenderableChild).length;
27
27
  }
28
+
29
+ /**
30
+ * Default horizontal stack gap as a t-shirt size, chosen by column count:
31
+ * 2 cols → lg, 3 cols → md, 4+ cols → sm. Unknown count falls back to md.
32
+ * Tighter gaps for denser layouts; authors can always override via the `gap` prop.
33
+ */
34
+ export function defaultHorizontalGapSize(
35
+ columnCount: number | undefined,
36
+ ): "sm" | "md" | "lg" {
37
+ if (columnCount === undefined) return "md";
38
+ if (columnCount <= 2) return "lg";
39
+ if (columnCount === 3) return "md";
40
+ return "sm";
41
+ }
@@ -26,6 +26,7 @@ const cellGridCellSchema = z.object({
26
26
  col: z.number().int().nonnegative(),
27
27
  color: cellGridCellColorSchema.optional(),
28
28
  content: z.string().optional(),
29
+ value: z.string().min(1).max(30).optional(),
29
30
  });
30
31
 
31
32
  export const cellGridProps = z