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