@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 +21 -0
- package/dist/colors.d.ts +8 -0
- package/dist/colors.js +21 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/react/components/action-button.js +11 -1
- package/dist/react/components/cell-grid.js +30 -19
- package/dist/react/components/item.js +6 -1
- package/dist/react/components/progress.js +6 -1
- package/dist/react/components/stack.js +19 -6
- package/dist/react/components/text.js +9 -2
- package/dist/react-native/components/snap-cell-grid.js +30 -19
- package/dist/react-native/components/snap-stack.js +17 -7
- package/dist/stack-horizontal-utils.d.ts +6 -0
- package/dist/stack-horizontal-utils.js +14 -0
- package/dist/ui/catalog.d.ts +1 -0
- package/dist/ui/cell-grid.d.ts +1 -0
- package/dist/ui/cell-grid.js +1 -0
- package/llms.txt +4 -4
- package/package.json +1 -1
- package/src/colors.ts +22 -0
- package/src/index.ts +1 -0
- package/src/react/components/action-button.tsx +14 -1
- package/src/react/components/cell-grid.tsx +33 -18
- package/src/react/components/item.tsx +10 -1
- package/src/react/components/progress.tsx +10 -1
- package/src/react/components/stack.tsx +21 -5
- package/src/react/components/text.tsx +9 -2
- package/src/react-native/components/snap-cell-grid.tsx +33 -18
- package/src/react-native/components/snap-stack.tsx +19 -7
- package/src/stack-horizontal-utils.ts +14 -0
- package/src/ui/cell-grid.ts +1 -0
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 (
|
|
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
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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(
|
|
48
|
+
const isSelected = (r, c) => isSelectable && selectedSet.has(cellWireValue(r, c));
|
|
34
49
|
const handleTap = (r, c) => {
|
|
35
|
-
const
|
|
50
|
+
const wire = cellWireValue(r, c);
|
|
36
51
|
if (isMultiple) {
|
|
37
52
|
const next = new Set(selectedSet);
|
|
38
|
-
if (next.has(
|
|
39
|
-
next.delete(
|
|
53
|
+
if (next.has(wire))
|
|
54
|
+
next.delete(wire);
|
|
40
55
|
else
|
|
41
|
-
next.add(
|
|
56
|
+
next.add(wire);
|
|
42
57
|
set(tapPath, [...next].join("|"));
|
|
43
58
|
}
|
|
44
59
|
else {
|
|
45
|
-
set(tapPath,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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-
|
|
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
|
-
/**
|
|
22
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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(
|
|
51
|
+
const isSelected = (r, c) => isSelectable && selectedSet.has(cellWireValue(r, c));
|
|
36
52
|
const handleTap = (r, c) => {
|
|
37
|
-
const
|
|
53
|
+
const wire = cellWireValue(r, c);
|
|
38
54
|
if (isMultiple) {
|
|
39
55
|
const next = new Set(selectedSet);
|
|
40
|
-
if (next.has(
|
|
41
|
-
next.delete(
|
|
56
|
+
if (next.has(wire))
|
|
57
|
+
next.delete(wire);
|
|
42
58
|
else
|
|
43
|
-
next.add(
|
|
59
|
+
next.add(wire);
|
|
44
60
|
set(tapPath, [...next].join("|"));
|
|
45
61
|
}
|
|
46
62
|
else {
|
|
47
|
-
set(tapPath,
|
|
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
|
|
70
|
-
const
|
|
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:
|
|
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
|
+
}
|
package/dist/ui/catalog.d.ts
CHANGED
|
@@ -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";
|
package/dist/ui/cell-grid.d.ts
CHANGED
package/dist/ui/cell-grid.js
CHANGED
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
|
|
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
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
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
39
|
-
|
|
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(
|
|
64
|
+
isSelectable && selectedSet.has(cellWireValue(r, c));
|
|
45
65
|
|
|
46
66
|
const handleTap = (r: number, c: number) => {
|
|
47
|
-
const
|
|
67
|
+
const wire = cellWireValue(r, c);
|
|
48
68
|
if (isMultiple) {
|
|
49
69
|
const next = new Set(selectedSet);
|
|
50
|
-
if (next.has(
|
|
51
|
-
else next.add(
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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-
|
|
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
|
-
/**
|
|
34
|
-
|
|
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
|
-
|
|
38
|
-
|
|
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(
|
|
64
|
+
isSelectable && selectedSet.has(cellWireValue(r, c));
|
|
44
65
|
|
|
45
66
|
const handleTap = (r: number, c: number) => {
|
|
46
|
-
const
|
|
67
|
+
const wire = cellWireValue(r, c);
|
|
47
68
|
if (isMultiple) {
|
|
48
69
|
const next = new Set(selectedSet);
|
|
49
|
-
if (next.has(
|
|
50
|
-
else next.add(
|
|
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,
|
|
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
|
|
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:
|
|
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:
|
|
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
|
+
}
|
package/src/ui/cell-grid.ts
CHANGED