@farcaster/snap 1.9.0 → 1.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/dist/constants.d.ts +8 -0
  2. package/dist/constants.js +10 -0
  3. package/dist/index.d.ts +4 -6
  4. package/dist/index.js +2 -4
  5. package/dist/react/catalog-renderer.js +4 -0
  6. package/dist/react/components/bar-chart.d.ts +5 -0
  7. package/dist/react/components/bar-chart.js +31 -0
  8. package/dist/react/components/cell-grid.d.ts +5 -0
  9. package/dist/react/components/cell-grid.js +86 -0
  10. package/dist/react-native/catalog-renderer.js +4 -0
  11. package/dist/react-native/components/snap-bar-chart.d.ts +2 -0
  12. package/dist/react-native/components/snap-bar-chart.js +39 -0
  13. package/dist/react-native/components/snap-cell-grid.d.ts +2 -0
  14. package/dist/react-native/components/snap-cell-grid.js +96 -0
  15. package/dist/schemas.d.ts +45 -3
  16. package/dist/schemas.js +2 -2
  17. package/dist/ui/bar-chart.d.ts +30 -0
  18. package/dist/ui/bar-chart.js +30 -0
  19. package/dist/ui/catalog.d.ts +65 -0
  20. package/dist/ui/catalog.js +10 -0
  21. package/dist/ui/cell-grid.d.ts +33 -0
  22. package/dist/ui/cell-grid.js +38 -0
  23. package/dist/ui/index.d.ts +4 -0
  24. package/dist/ui/index.js +2 -0
  25. package/llms.txt +19 -4
  26. package/package.json +1 -1
  27. package/src/constants.ts +12 -0
  28. package/src/index.ts +8 -12
  29. package/src/react/catalog-renderer.tsx +4 -0
  30. package/src/react/components/bar-chart.tsx +67 -0
  31. package/src/react/components/cell-grid.tsx +131 -0
  32. package/src/react-native/catalog-renderer.tsx +4 -0
  33. package/src/react-native/components/snap-bar-chart.tsx +73 -0
  34. package/src/react-native/components/snap-cell-grid.tsx +152 -0
  35. package/src/schemas.ts +37 -11
  36. package/src/ui/bar-chart.ts +38 -0
  37. package/src/ui/catalog.ts +12 -0
  38. package/src/ui/cell-grid.ts +48 -0
  39. package/src/ui/index.ts +6 -0
  40. package/dist/dataStore.d.ts +0 -9
  41. package/dist/dataStore.js +0 -22
  42. package/dist/middleware.d.ts +0 -3
  43. package/dist/middleware.js +0 -3
  44. package/src/dataStore.ts +0 -38
  45. package/src/middleware.ts +0 -7
@@ -0,0 +1,38 @@
1
+ import { z } from "zod";
2
+ import { PALETTE_COLOR_VALUES } from "../colors.js";
3
+ import { GRID_MIN_COLS, GRID_MAX_COLS, GRID_MIN_ROWS, GRID_MAX_ROWS, GRID_GAP_VALUES, } from "../constants.js";
4
+ const cellGridCellSchema = z.object({
5
+ row: z.number().int().nonnegative(),
6
+ col: z.number().int().nonnegative(),
7
+ color: z.enum(PALETTE_COLOR_VALUES).optional(),
8
+ content: z.string().optional(),
9
+ });
10
+ export const cellGridProps = z
11
+ .object({
12
+ name: z.string().min(1).optional(),
13
+ cols: z.number().int().min(GRID_MIN_COLS).max(GRID_MAX_COLS),
14
+ rows: z.number().int().min(GRID_MIN_ROWS).max(GRID_MAX_ROWS),
15
+ cells: z.array(cellGridCellSchema),
16
+ gap: z.enum(GRID_GAP_VALUES).optional(),
17
+ select: z.enum(["off", "single", "multiple"]).optional(),
18
+ })
19
+ .superRefine((val, ctx) => {
20
+ const { cols, rows, cells } = val;
21
+ for (let i = 0; i < cells.length; i++) {
22
+ const c = cells[i];
23
+ if (c.row < 0 || c.row >= rows) {
24
+ ctx.addIssue({
25
+ code: "custom",
26
+ message: `cell_grid cell row ${c.row} out of bounds (0–${rows - 1})`,
27
+ path: ["cells", i, "row"],
28
+ });
29
+ }
30
+ if (c.col < 0 || c.col >= cols) {
31
+ ctx.addIssue({
32
+ code: "custom",
33
+ message: `cell_grid cell col ${c.col} out of bounds (0–${cols - 1})`,
34
+ path: ["cells", i, "col"],
35
+ });
36
+ }
37
+ }
38
+ });
@@ -28,3 +28,7 @@ export { stackProps } from "./stack.js";
28
28
  export type { StackProps } from "./stack.js";
29
29
  export { textProps } from "./text.js";
30
30
  export type { TextProps } from "./text.js";
31
+ export { barChartProps } from "./bar-chart.js";
32
+ export type { BarChartProps } from "./bar-chart.js";
33
+ export { cellGridProps } from "./cell-grid.js";
34
+ export type { CellGridProps } from "./cell-grid.js";
package/dist/ui/index.js CHANGED
@@ -14,3 +14,5 @@ export { separatorProps } from "./separator.js";
14
14
  export { sliderProps } from "./slider.js";
15
15
  export { stackProps } from "./stack.js";
16
16
  export { textProps } from "./text.js";
17
+ export { barChartProps } from "./bar-chart.js";
18
+ export { cellGridProps } from "./cell-grid.js";
package/llms.txt CHANGED
@@ -30,7 +30,7 @@ Top-level fields: `version` (required, `"1.0"`), `theme` (optional, `{ accent: P
30
30
 
31
31
  `ui.root` is the ID of the root element. `ui.elements` is a flat map of element ID to element definition.
32
32
 
33
- ## Components (14 total)
33
+ ## Components (16 total)
34
34
 
35
35
  ### Display Components
36
36
 
@@ -75,6 +75,21 @@ Top-level fields: `version` (required, `"1.0"`), `theme` (optional, `{ accent: P
75
75
  - `weight` (optional): `"bold"` | `"normal"`. Default: `"normal"`
76
76
  - `align` (optional): `"left"` | `"center"` | `"right"`. Default: `"left"`
77
77
 
78
+ ### Data Components
79
+
80
+ **bar_chart** — Horizontal bar chart with labeled bars.
81
+ - `bars` (array, required, 1–6 items): each `{ label: string (max 40), value: number (≥0), color?: PaletteColor }`
82
+ - `max` (number, optional, ≥0): ceiling value; defaults to max bar value
83
+ - `color` (optional): PaletteColor. Default bar color. Default: `"accent"`
84
+
85
+ **cell_grid** — Colored cell grid, optionally interactive.
86
+ - `name` (string, optional): POST inputs key. Default: `"grid_tap"`
87
+ - `cols` (number, required, 2–32)
88
+ - `rows` (number, required, 2–16)
89
+ - `cells` (array, required): sparse list of `{ row, col, color?: PaletteColor, content?: string }`
90
+ - `gap` (optional): `"none"` (0px) | `"sm"` (1px) | `"md"` (2px) | `"lg"` (4px). Default: `"sm"`
91
+ - `select` (optional): `"off"` | `"single"` | `"multiple"`. Default: `"off"`. Taps write to `inputs[name]`
92
+
78
93
  ### Container Components
79
94
 
80
95
  **stack** — Layout container.
@@ -158,14 +173,14 @@ Plus the special value `"accent"` which references `theme.accent`.
158
173
  ```ts
159
174
  import { snapJsonRenderCatalog } from "@farcaster/snap/ui";
160
175
  import { parseRequest, verifyJFSRequestBody } from "@farcaster/snap/server";
161
- import { createInMemoryDataStore } from "@farcaster/snap";
176
+ import { withTursoServerless, createInMemoryDataStore } from "@farcaster/snap-turso";
162
177
  ```
163
178
 
164
- - `@farcaster/snap` — schemas, types, validation, data store
179
+ - `@farcaster/snap` — schemas, types, validation
165
180
  - `@farcaster/snap/ui` — json-render catalog, component schemas
166
181
  - `@farcaster/snap/server` — request parsing, JFS verification
167
182
  - `@farcaster/snap-hono` — Hono adapter (`registerSnapHandler`)
168
- - `@farcaster/snap-turso` — Turso data store middleware
183
+ - `@farcaster/snap-turso` — `withTursoServerless`, `DataStore` / `DataStoreValue`, in-memory and Turso helpers
169
184
 
170
185
  ## Full Documentation
171
186
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@farcaster/snap",
3
- "version": "1.9.0",
3
+ "version": "1.13.0",
4
4
  "description": "Farcaster Snaps 🫰",
5
5
  "repository": {
6
6
  "type": "git",
package/src/constants.ts CHANGED
@@ -3,3 +3,15 @@ export const SPEC_VERSION = "1.0" as const;
3
3
  export const MEDIA_TYPE = "application/vnd.farcaster.snap+json" as const;
4
4
 
5
5
  export const EFFECT_VALUES = ["confetti"] as const;
6
+
7
+ // ─── Pixel grid ────────────────────────────────────────
8
+ export const POST_GRID_TAP_KEY = "grid_tap" as const;
9
+ export const GRID_MIN_COLS = 2;
10
+ export const GRID_MAX_COLS = 32;
11
+ export const GRID_MIN_ROWS = 2;
12
+ export const GRID_MAX_ROWS = 16;
13
+ export const GRID_GAP_VALUES = ["none", "sm", "md", "lg"] as const;
14
+
15
+ // ─── Bar chart ─────────────────────────────────────────
16
+ export const BAR_CHART_MAX_BARS = 6;
17
+ export const BAR_CHART_LABEL_MAX_CHARS = 40;
package/src/index.ts CHANGED
@@ -1,8 +1,12 @@
1
- export type { Spec as SnapSpec, UIElement as SnapUIElement } from "@json-render/core";
1
+ export type {
2
+ Spec as SnapSpec,
3
+ UIElement as SnapUIElement,
4
+ } from "@json-render/core";
2
5
  export {
3
6
  SPEC_VERSION,
4
7
  MEDIA_TYPE,
5
8
  EFFECT_VALUES,
9
+ POST_GRID_TAP_KEY,
6
10
  } from "./constants";
7
11
  export {
8
12
  DEFAULT_THEME_ACCENT,
@@ -22,17 +26,9 @@ export {
22
26
  type SnapContext,
23
27
  type SnapResponse,
24
28
  type SnapHandlerResult,
29
+ type SnapElementInput,
30
+ type SnapSpecInput,
25
31
  type SnapFunction,
26
32
  type SnapPayload,
27
33
  } from "./schemas";
28
- export {
29
- validateSnapResponse,
30
- type ValidationResult,
31
- } from "./validator";
32
- export {
33
- type DataStoreValue,
34
- type SnapDataStore,
35
- createDefaultDataStore,
36
- createInMemoryDataStore,
37
- } from "./dataStore";
38
- export { type Middleware, useMiddleware } from "./middleware";
34
+ export { validateSnapResponse, type ValidationResult } from "./validator";
@@ -16,6 +16,8 @@ import { SnapStack } from "./components/stack";
16
16
  import { SnapSwitch } from "./components/switch";
17
17
  import { SnapText } from "./components/text";
18
18
  import { SnapToggleGroup } from "./components/toggle-group";
19
+ import { SnapBarChart } from "./components/bar-chart";
20
+ import { SnapCellGrid } from "./components/cell-grid";
19
21
 
20
22
  /**
21
23
  * Maps snap json-render catalog types to React components.
@@ -36,4 +38,6 @@ export const SnapCatalogView = createRenderer(snapJsonRenderCatalog, {
36
38
  switch: SnapSwitch,
37
39
  text: SnapText,
38
40
  toggle_group: SnapToggleGroup,
41
+ bar_chart: SnapBarChart,
42
+ cell_grid: SnapCellGrid,
39
43
  });
@@ -0,0 +1,67 @@
1
+ "use client";
2
+
3
+ import type { PaletteColor } from "@farcaster/snap";
4
+ import { PALETTE_LIGHT_HEX } from "@farcaster/snap";
5
+ import { useSnapAccentScopeStyle } from "../hooks/use-snap-accent";
6
+
7
+ export function SnapBarChart({
8
+ element: { props },
9
+ }: {
10
+ element: { props: Record<string, unknown> };
11
+ }) {
12
+ const accentStyle = useSnapAccentScopeStyle();
13
+ const bars = Array.isArray(props.bars) ? props.bars : [];
14
+ const chartColor = String(props.color ?? "accent");
15
+ const maxVal =
16
+ props.max != null
17
+ ? Number(props.max)
18
+ : Math.max(
19
+ ...bars.map((b: { value?: number }) => Number(b.value ?? 0)),
20
+ 1,
21
+ );
22
+
23
+ function barColor(bar: { color?: string }): string {
24
+ if (bar.color && bar.color in PALETTE_LIGHT_HEX) {
25
+ return `var(--snap-color-${bar.color}, ${PALETTE_LIGHT_HEX[bar.color as PaletteColor]})`;
26
+ }
27
+ if (chartColor !== "accent" && chartColor in PALETTE_LIGHT_HEX) {
28
+ return `var(--snap-color-${chartColor}, ${PALETTE_LIGHT_HEX[chartColor as PaletteColor]})`;
29
+ }
30
+ return "var(--primary)";
31
+ }
32
+
33
+ return (
34
+ <div className="flex w-full flex-col gap-2" style={accentStyle}>
35
+ {bars.map(
36
+ (
37
+ bar: { label?: string; value?: number; color?: string },
38
+ i: number,
39
+ ) => {
40
+ const value = Number(bar.value ?? 0);
41
+ const pct = maxVal > 0 ? Math.min(100, (value / maxVal) * 100) : 0;
42
+ const fill = barColor(bar);
43
+ return (
44
+ <div key={i} className="flex w-full items-center gap-2">
45
+ <span className="text-muted-foreground w-20 shrink-0 truncate text-right text-xs">
46
+ {String(bar.label ?? "")}
47
+ </span>
48
+ <div className="bg-muted h-2.5 flex-1 overflow-hidden rounded-full">
49
+ <div
50
+ className="h-full rounded-full transition-all"
51
+ style={{
52
+ width: `${pct}%`,
53
+ minWidth: pct > 0 ? 4 : 0,
54
+ background: fill,
55
+ }}
56
+ />
57
+ </div>
58
+ <span className="text-muted-foreground w-8 shrink-0 text-xs tabular-nums">
59
+ {value}
60
+ </span>
61
+ </div>
62
+ );
63
+ },
64
+ )}
65
+ </div>
66
+ );
67
+ }
@@ -0,0 +1,131 @@
1
+ "use client";
2
+
3
+ import type { ReactNode } from "react";
4
+ import { useStateStore } from "@json-render/react";
5
+ import { cn } from "@neynar/ui/utils";
6
+ import { POST_GRID_TAP_KEY, PALETTE_LIGHT_HEX } from "@farcaster/snap";
7
+ import type { PaletteColor } from "@farcaster/snap";
8
+ import { useSnapAccentScopeStyle } from "../hooks/use-snap-accent";
9
+ import { useColorMode } from "@neynar/ui/color-mode";
10
+
11
+ export function SnapCellGrid({
12
+ element: { props },
13
+ }: {
14
+ element: { props: Record<string, unknown> };
15
+ }) {
16
+ const { get, set } = useStateStore();
17
+ const accentStyle = useSnapAccentScopeStyle();
18
+ const { mode: appearance } = useColorMode();
19
+ const cols = Number(props.cols ?? 2);
20
+ const rows = Number(props.rows ?? 2);
21
+ const select = String(props.select ?? "off");
22
+ const interactive = select !== "off";
23
+ const isMultiple = select === "multiple";
24
+ const cells = Array.isArray(props.cells) ? props.cells : [];
25
+ const gap = String(props.gap ?? "sm");
26
+ const gapMap: Record<string, number> = { none: 0, sm: 1, md: 2, lg: 4 };
27
+ const gapPx = gapMap[gap] ?? 1;
28
+
29
+ const name = props.name ? String(props.name) : POST_GRID_TAP_KEY;
30
+ const tapPath = `/inputs/${name}`;
31
+ const tapRaw = get(tapPath);
32
+
33
+ // Parse selection — single mode: "row,col" string; multi mode: "row,col|row,col|..." string
34
+ const selectedSet = new Set<string>();
35
+ if (typeof tapRaw === "string" && tapRaw.length > 0) {
36
+ for (const part of tapRaw.split("|")) {
37
+ if (part.includes(",")) selectedSet.add(part);
38
+ }
39
+ }
40
+
41
+ const isSelected = (r: number, c: number) => selectedSet.has(`${r},${c}`);
42
+
43
+ const handleTap = (r: number, c: number) => {
44
+ const key = `${r},${c}`;
45
+ if (isMultiple) {
46
+ const next = new Set(selectedSet);
47
+ if (next.has(key)) next.delete(key);
48
+ else next.add(key);
49
+ set(tapPath, [...next].join("|"));
50
+ } else {
51
+ set(tapPath, key);
52
+ }
53
+ };
54
+
55
+ const cellMap = new Map<string, { color?: string; content?: string }>();
56
+ for (const c of cells) {
57
+ cellMap.set(`${Number(c.row)},${Number(c.col)}`, {
58
+ color: c.color as string | undefined,
59
+ content: c.content != null ? String(c.content) : undefined,
60
+ });
61
+ }
62
+
63
+ const ringColor = appearance === "dark" ? "#fff" : "#000";
64
+
65
+ const cellEls: ReactNode[] = [];
66
+ for (let r = 0; r < rows; r++) {
67
+ for (let c = 0; c < cols; c++) {
68
+ const cell = cellMap.get(`${r},${c}`);
69
+ const selected = interactive && isSelected(r, c);
70
+ const bg =
71
+ cell?.color && cell.color in PALETTE_LIGHT_HEX
72
+ ? `var(--snap-color-${cell.color}, ${PALETTE_LIGHT_HEX[cell.color as PaletteColor]})`
73
+ : "transparent";
74
+
75
+ cellEls.push(
76
+ <div
77
+ key={`${r}-${c}`}
78
+ role={interactive ? "button" : undefined}
79
+ tabIndex={interactive ? 0 : undefined}
80
+ onClick={interactive ? () => handleTap(r, c) : undefined}
81
+ onKeyDown={
82
+ interactive
83
+ ? (e) => {
84
+ if (e.key === "Enter" || e.key === " ") {
85
+ e.preventDefault();
86
+ handleTap(r, c);
87
+ }
88
+ }
89
+ : undefined
90
+ }
91
+ className={cn(
92
+ "flex min-h-7 items-center justify-center rounded text-xs font-semibold",
93
+ interactive ? "cursor-pointer select-none" : "cursor-default",
94
+ )}
95
+ style={{
96
+ background: bg,
97
+ // Two-layer ring: 1px white/black inner + 2px accent outer
98
+ boxShadow: selected
99
+ ? `inset 0 0 0 1px ${appearance === "dark" ? "#000" : "#fff"}, inset 0 0 0 2px ${appearance === "dark" ? "#fff" : "#000"}`
100
+ : undefined,
101
+ }}
102
+ >
103
+ {cell?.content ?? ""}
104
+ </div>,
105
+ );
106
+ }
107
+ }
108
+
109
+ const selectionLabel = interactive && selectedSet.size > 0
110
+ ? `inputs.${name}: ${[...selectedSet].join(isMultiple ? " | " : "")}`
111
+ : null;
112
+
113
+ return (
114
+ <div style={accentStyle}>
115
+ <div
116
+ className="grid w-full"
117
+ style={{
118
+ gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))`,
119
+ gap: gapPx,
120
+ }}
121
+ >
122
+ {cellEls}
123
+ </div>
124
+ {selectionLabel && (
125
+ <div className="text-muted-foreground mt-1.5 truncate text-xs font-mono">
126
+ {selectionLabel}
127
+ </div>
128
+ )}
129
+ </div>
130
+ );
131
+ }
@@ -14,6 +14,8 @@ import { SnapStack } from "./components/snap-stack";
14
14
  import { SnapSwitch } from "./components/snap-switch";
15
15
  import { SnapText } from "./components/snap-text";
16
16
  import { SnapToggleGroup } from "./components/snap-toggle-group";
17
+ import { SnapBarChart } from "./components/snap-bar-chart";
18
+ import { SnapCellGrid } from "./components/snap-cell-grid";
17
19
 
18
20
  /**
19
21
  * Maps snap json-render catalog types to React Native primitives.
@@ -34,4 +36,6 @@ export const SnapCatalogView = createRenderer(snapJsonRenderCatalog, {
34
36
  switch: SnapSwitch,
35
37
  text: SnapText,
36
38
  toggle_group: SnapToggleGroup,
39
+ bar_chart: SnapBarChart,
40
+ cell_grid: SnapCellGrid,
37
41
  });
@@ -0,0 +1,73 @@
1
+ import type { ComponentRenderProps } from "@json-render/react-native";
2
+ import { StyleSheet, Text, View } from "react-native";
3
+ import { useSnapPalette } from "../use-snap-palette";
4
+ import { useSnapTheme } from "../theme";
5
+
6
+ export function SnapBarChart({
7
+ element: { props },
8
+ }: ComponentRenderProps<Record<string, unknown>>) {
9
+ const { accentHex, hex } = useSnapPalette();
10
+ const { colors } = useSnapTheme();
11
+ const bars = Array.isArray(props.bars) ? props.bars : [];
12
+ const chartColor = String(props.color ?? "accent");
13
+ const maxVal =
14
+ props.max != null
15
+ ? Number(props.max)
16
+ : Math.max(
17
+ ...bars.map((b: { value?: number }) => Number(b.value ?? 0)),
18
+ 1,
19
+ );
20
+
21
+ function barFill(bar: { color?: string }): string {
22
+ if (bar.color) return hex(bar.color);
23
+ if (chartColor !== "accent") return hex(chartColor);
24
+ return accentHex;
25
+ }
26
+
27
+ return (
28
+ <View style={styles.wrap}>
29
+ {bars.map(
30
+ (
31
+ bar: { label?: string; value?: number; color?: string },
32
+ i: number,
33
+ ) => {
34
+ const value = Number(bar.value ?? 0);
35
+ const pct = maxVal > 0 ? Math.min(100, (value / maxVal) * 100) : 0;
36
+ return (
37
+ <View key={i} style={styles.row}>
38
+ <Text
39
+ style={[styles.label, { color: colors.textSecondary }]}
40
+ numberOfLines={1}
41
+ >
42
+ {String(bar.label ?? "")}
43
+ </Text>
44
+ <View style={[styles.track, { backgroundColor: colors.muted }]}>
45
+ <View
46
+ style={[
47
+ styles.fill,
48
+ {
49
+ width: `${pct}%`,
50
+ backgroundColor: barFill(bar),
51
+ },
52
+ ]}
53
+ />
54
+ </View>
55
+ <Text style={[styles.value, { color: colors.textSecondary }]}>
56
+ {value}
57
+ </Text>
58
+ </View>
59
+ );
60
+ },
61
+ )}
62
+ </View>
63
+ );
64
+ }
65
+
66
+ const styles = StyleSheet.create({
67
+ wrap: { flex: 1, width: "100%", gap: 8 },
68
+ row: { flexDirection: "row", alignItems: "center", gap: 8 },
69
+ label: { width: 80, fontSize: 12, textAlign: "right" },
70
+ track: { flex: 1, height: 10, borderRadius: 9999, overflow: "hidden" },
71
+ fill: { height: "100%", borderRadius: 9999 },
72
+ value: { width: 32, fontSize: 12, fontVariant: ["tabular-nums"] },
73
+ });
@@ -0,0 +1,152 @@
1
+ import type { ComponentRenderProps } from "@json-render/react-native";
2
+ import { StyleSheet, Text, View, Pressable } from "react-native";
3
+ import { useStateStore } from "@json-render/react-native";
4
+ import { useSnapPalette } from "../use-snap-palette";
5
+ import { useSnapTheme } from "../theme";
6
+ import { POST_GRID_TAP_KEY } from "@farcaster/snap";
7
+
8
+ export function SnapCellGrid({
9
+ element: { props },
10
+ }: ComponentRenderProps<Record<string, unknown>>) {
11
+ const { hex, appearance } = useSnapPalette();
12
+ const { colors } = useSnapTheme();
13
+ const { get, set } = useStateStore();
14
+ const cols = Number(props.cols ?? 2);
15
+ const rows = Number(props.rows ?? 2);
16
+ const cells = Array.isArray(props.cells) ? props.cells : [];
17
+ const gap = String(props.gap ?? "sm");
18
+ const gapMap: Record<string, number> = { none: 0, sm: 1, md: 2, lg: 4 };
19
+ const gapPx = gapMap[gap] ?? 1;
20
+
21
+ const select = String(props.select ?? "off");
22
+ const interactive = select !== "off";
23
+ const isMultiple = select === "multiple";
24
+
25
+ const name = props.name ? String(props.name) : POST_GRID_TAP_KEY;
26
+ const tapPath = `/inputs/${name}`;
27
+ const tapRaw = get(tapPath);
28
+
29
+ const selectedSet = new Set<string>();
30
+ if (typeof tapRaw === "string" && tapRaw.length > 0) {
31
+ for (const part of tapRaw.split("|")) {
32
+ if (part.includes(",")) selectedSet.add(part);
33
+ }
34
+ }
35
+
36
+ const isSelected = (r: number, c: number) => selectedSet.has(`${r},${c}`);
37
+
38
+ const handleTap = (r: number, c: number) => {
39
+ const key = `${r},${c}`;
40
+ if (isMultiple) {
41
+ const next = new Set(selectedSet);
42
+ if (next.has(key)) next.delete(key);
43
+ else next.add(key);
44
+ set(tapPath, [...next].join("|"));
45
+ } else {
46
+ set(tapPath, key);
47
+ }
48
+ };
49
+
50
+ const cellMap = new Map<string, { color?: string; content?: string }>();
51
+ for (const c of cells) {
52
+ cellMap.set(`${Number(c.row)},${Number(c.col)}`, {
53
+ color: c.color as string | undefined,
54
+ content: c.content != null ? String(c.content) : undefined,
55
+ });
56
+ }
57
+
58
+ const ringOuter = appearance === "dark" ? "#fff" : "#000";
59
+ const ringInner = appearance === "dark" ? "#000" : "#fff";
60
+
61
+ const rowEls = [];
62
+ for (let r = 0; r < rows; r++) {
63
+ const rowCells = [];
64
+ for (let c = 0; c < cols; c++) {
65
+ const cell = cellMap.get(`${r},${c}`);
66
+ const selected = interactive && isSelected(r, c);
67
+ const bg = cell?.color ? hex(cell.color) : "transparent";
68
+
69
+ const cellContent = cell?.content ? (
70
+ <Text style={[styles.cellText, { color: colors.textPrimary }]}>
71
+ {cell.content}
72
+ </Text>
73
+ ) : null;
74
+
75
+ // Two-tone ring: outer View with contrasting border, inner View with inverse border
76
+ const cellView = selected ? (
77
+ <View style={[styles.cell, { borderWidth: 1, borderColor: ringOuter, borderRadius: 4 }]}>
78
+ <View
79
+ style={[
80
+ styles.innerCell,
81
+ { backgroundColor: bg, borderWidth: 1, borderColor: ringInner, borderRadius: 3 },
82
+ ]}
83
+ >
84
+ {cellContent}
85
+ </View>
86
+ </View>
87
+ ) : (
88
+ <View style={[styles.cell, { backgroundColor: bg }]}>
89
+ {cellContent}
90
+ </View>
91
+ );
92
+
93
+ rowCells.push(
94
+ interactive ? (
95
+ <Pressable
96
+ key={`${r}-${c}`}
97
+ onPress={() => handleTap(r, c)}
98
+ style={styles.cellWrap}
99
+ >
100
+ {cellView}
101
+ </Pressable>
102
+ ) : (
103
+ <View key={`${r}-${c}`} style={styles.cellWrap}>
104
+ {cellView}
105
+ </View>
106
+ ),
107
+ );
108
+ }
109
+ rowEls.push(
110
+ <View key={r} style={[styles.gridRow, { gap: gapPx }]}>
111
+ {rowCells}
112
+ </View>,
113
+ );
114
+ }
115
+
116
+ const selectionLabel = interactive && selectedSet.size > 0
117
+ ? `inputs.${name}: ${[...selectedSet].join(isMultiple ? " | " : "")}`
118
+ : null;
119
+
120
+ return (
121
+ <View style={[styles.wrap, { gap: gapPx }]}>
122
+ {rowEls}
123
+ {selectionLabel ? (
124
+ <Text style={[styles.selectionText, { color: colors.textSecondary }]}>
125
+ {selectionLabel}
126
+ </Text>
127
+ ) : null}
128
+ </View>
129
+ );
130
+ }
131
+
132
+ const styles = StyleSheet.create({
133
+ wrap: { width: "100%" },
134
+ gridRow: { flexDirection: "row" },
135
+ cellWrap: { flex: 1 },
136
+ cell: {
137
+ flex: 1,
138
+ minHeight: 28,
139
+ borderRadius: 4,
140
+ alignItems: "center",
141
+ justifyContent: "center",
142
+ },
143
+ innerCell: {
144
+ flex: 1,
145
+ width: "100%",
146
+ minHeight: 26,
147
+ alignItems: "center",
148
+ justifyContent: "center",
149
+ },
150
+ cellText: { fontSize: 12, fontWeight: "600" },
151
+ selectionText: { fontSize: 11, fontFamily: "monospace", marginTop: 6 },
152
+ });