@farcaster/snap 1.10.0 → 1.14.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 (79) hide show
  1. package/dist/constants.d.ts +8 -0
  2. package/dist/constants.js +10 -0
  3. package/dist/index.d.ts +1 -2
  4. package/dist/index.js +1 -2
  5. package/dist/react/catalog-renderer.js +4 -0
  6. package/dist/react/components/action-button.js +18 -10
  7. package/dist/react/components/badge.js +8 -6
  8. package/dist/react/components/bar-chart.d.ts +5 -0
  9. package/dist/react/components/bar-chart.js +26 -0
  10. package/dist/react/components/cell-grid.d.ts +5 -0
  11. package/dist/react/components/cell-grid.js +82 -0
  12. package/dist/react/components/icon.js +4 -10
  13. package/dist/react/components/input.js +12 -6
  14. package/dist/react/components/item-group.js +3 -1
  15. package/dist/react/components/item.d.ts +3 -3
  16. package/dist/react/components/item.js +4 -3
  17. package/dist/react/components/progress.js +3 -3
  18. package/dist/react/components/separator.js +3 -1
  19. package/dist/react/components/slider.js +14 -10
  20. package/dist/react/components/switch.js +10 -12
  21. package/dist/react/components/text.js +5 -7
  22. package/dist/react/components/toggle-group.js +20 -6
  23. package/dist/react/hooks/use-snap-colors.d.ts +38 -0
  24. package/dist/react/hooks/use-snap-colors.js +82 -0
  25. package/dist/react-native/catalog-renderer.js +4 -0
  26. package/dist/react-native/components/snap-action-button.js +8 -18
  27. package/dist/react-native/components/snap-bar-chart.d.ts +2 -0
  28. package/dist/react-native/components/snap-bar-chart.js +39 -0
  29. package/dist/react-native/components/snap-cell-grid.d.ts +2 -0
  30. package/dist/react-native/components/snap-cell-grid.js +96 -0
  31. package/dist/react-native/components/snap-switch.js +1 -1
  32. package/dist/react-native/components/snap-toggle-group.js +8 -10
  33. package/dist/react-native/theme.d.ts +6 -0
  34. package/dist/react-native/theme.js +12 -6
  35. package/dist/ui/bar-chart.d.ts +30 -0
  36. package/dist/ui/bar-chart.js +30 -0
  37. package/dist/ui/catalog.d.ts +65 -0
  38. package/dist/ui/catalog.js +10 -0
  39. package/dist/ui/cell-grid.d.ts +33 -0
  40. package/dist/ui/cell-grid.js +38 -0
  41. package/dist/ui/index.d.ts +4 -0
  42. package/dist/ui/index.js +2 -0
  43. package/llms.txt +16 -1
  44. package/package.json +1 -1
  45. package/src/constants.ts +12 -0
  46. package/src/index.ts +6 -2
  47. package/src/react/catalog-renderer.tsx +4 -0
  48. package/src/react/components/action-button.tsx +24 -17
  49. package/src/react/components/badge.tsx +14 -17
  50. package/src/react/components/bar-chart.tsx +69 -0
  51. package/src/react/components/cell-grid.tsx +124 -0
  52. package/src/react/components/icon.tsx +5 -18
  53. package/src/react/components/input.tsx +20 -9
  54. package/src/react/components/item-group.tsx +4 -1
  55. package/src/react/components/item.tsx +13 -10
  56. package/src/react/components/progress.tsx +12 -7
  57. package/src/react/components/separator.tsx +8 -1
  58. package/src/react/components/slider.tsx +18 -15
  59. package/src/react/components/switch.tsx +12 -16
  60. package/src/react/components/text.tsx +11 -8
  61. package/src/react/components/toggle-group.tsx +26 -9
  62. package/src/react/hooks/use-snap-colors.ts +129 -0
  63. package/src/react-native/catalog-renderer.tsx +4 -0
  64. package/src/react-native/components/snap-action-button.tsx +8 -20
  65. package/src/react-native/components/snap-bar-chart.tsx +73 -0
  66. package/src/react-native/components/snap-cell-grid.tsx +152 -0
  67. package/src/react-native/components/snap-switch.tsx +1 -1
  68. package/src/react-native/components/snap-toggle-group.tsx +8 -10
  69. package/src/react-native/theme.tsx +18 -6
  70. package/src/ui/bar-chart.ts +38 -0
  71. package/src/ui/catalog.ts +12 -0
  72. package/src/ui/cell-grid.ts +48 -0
  73. package/src/ui/index.ts +6 -0
  74. package/dist/middleware.d.ts +0 -3
  75. package/dist/middleware.js +0 -3
  76. package/dist/react/hooks/use-snap-accent.d.ts +0 -13
  77. package/dist/react/hooks/use-snap-accent.js +0 -32
  78. package/src/middleware.ts +0 -7
  79. package/src/react/hooks/use-snap-accent.ts +0 -45
@@ -2,10 +2,7 @@
2
2
 
3
3
  import { useStateStore } from "@json-render/react";
4
4
  import { Label } from "@neynar/ui/label";
5
- import { useSnapAccentScopeStyle } from "../hooks/use-snap-accent";
6
-
7
- // TODO: switch back to @neynar/ui/slider once Base UI fixes the inline
8
- // <script> tag that triggers a React console warning on client render.
5
+ import { useSnapColors } from "../hooks/use-snap-colors";
9
6
 
10
7
  export function SnapSlider({
11
8
  element: { props },
@@ -13,21 +10,24 @@ export function SnapSlider({
13
10
  element: { props: Record<string, unknown> };
14
11
  }) {
15
12
  const { get, set } = useStateStore();
16
- const accentStyle = useSnapAccentScopeStyle();
17
-
13
+ const colors = useSnapColors();
18
14
  const name = String(props.name ?? "slider");
19
- const path = `/inputs/${name}`;
20
- const label = props.label ? String(props.label) : undefined;
21
15
  const min = Number(props.min ?? 0);
22
16
  const max = Number(props.max ?? 100);
23
- const step = props.step != null ? Number(props.step) : 1;
24
- const fallback = props.defaultValue != null ? Number(props.defaultValue) : (min + max) / 2;
17
+ const step = Number(props.step ?? 1);
18
+ const label = props.label ? String(props.label) : undefined;
19
+ const path = `/inputs/${name}`;
25
20
  const raw = get(path);
26
- const value = raw === undefined || raw === null ? fallback : Number(raw);
21
+ const value =
22
+ raw !== undefined
23
+ ? Number(raw)
24
+ : props.defaultValue !== undefined
25
+ ? Number(props.defaultValue)
26
+ : (min + max) / 2;
27
27
 
28
28
  return (
29
- <div className="flex w-full flex-col gap-1.5" style={accentStyle}>
30
- {label && <Label>{label}</Label>}
29
+ <div className="flex w-full flex-col gap-1.5">
30
+ {label && <Label style={{ color: colors.text }}>{label}</Label>}
31
31
  <input
32
32
  type="range"
33
33
  min={min}
@@ -35,8 +35,11 @@ export function SnapSlider({
35
35
  step={step}
36
36
  value={value}
37
37
  onChange={(e) => set(path, Number(e.target.value))}
38
- className="w-full h-2.5 rounded-full appearance-none bg-muted cursor-pointer"
39
- style={{ accentColor: "var(--primary)" }}
38
+ className="w-full h-2.5 rounded-full appearance-none cursor-pointer"
39
+ style={{
40
+ backgroundColor: colors.muted,
41
+ accentColor: colors.accent,
42
+ }}
40
43
  />
41
44
  </div>
42
45
  );
@@ -2,32 +2,29 @@
2
2
 
3
3
  import { useId } from "react";
4
4
  import { useStateStore } from "@json-render/react";
5
- import { Label } from "@neynar/ui/label";
6
5
  import { Switch } from "@neynar/ui/switch";
7
- import { useColorMode } from "@neynar/ui/color-mode";
8
- import { cn } from "@neynar/ui/utils";
9
- import { useSnapAccentScopeStyle } from "../hooks/use-snap-accent";
6
+ import { Label } from "@neynar/ui/label";
7
+ import { useSnapColors } from "../hooks/use-snap-colors";
10
8
 
11
9
  export function SnapSwitch({
12
10
  element: { props },
13
11
  }: {
14
12
  element: { props: Record<string, unknown> };
15
13
  }) {
16
- const id = useId();
17
14
  const { get, set } = useStateStore();
18
- const { mode } = useColorMode();
19
- const accentStyle = useSnapAccentScopeStyle();
15
+ const colors = useSnapColors();
20
16
  const name = String(props.name ?? "switch");
21
- const path = `/inputs/${name}`;
22
17
  const label = props.label ? String(props.label) : undefined;
23
- const fallback = Boolean(props.defaultChecked ?? false);
18
+ const path = `/inputs/${name}`;
24
19
  const raw = get(path);
25
- const checked = raw === undefined || raw === null ? fallback : Boolean(raw);
20
+ const checked =
21
+ raw !== undefined ? Boolean(raw) : Boolean(props.defaultChecked);
22
+ const id = useId();
26
23
 
27
24
  return (
28
25
  <div className="flex items-center justify-between gap-3">
29
26
  {label && (
30
- <Label htmlFor={id} className="text-foreground font-normal">
27
+ <Label htmlFor={id} className="font-normal" style={{ color: colors.text }}>
31
28
  {label}
32
29
  </Label>
33
30
  )}
@@ -35,11 +32,10 @@ export function SnapSwitch({
35
32
  id={id}
36
33
  checked={checked}
37
34
  onCheckedChange={(v) => set(path, v)}
38
- style={accentStyle}
39
- className={cn(
40
- mode === "light" &&
41
- "data-unchecked:!bg-border data-unchecked:!border-(--input-border)",
42
- )}
35
+ style={{
36
+ backgroundColor: checked ? colors.accent : colors.muted,
37
+ borderColor: checked ? colors.accent : colors.inputBorder,
38
+ }}
43
39
  />
44
40
  </div>
45
41
  );
@@ -1,15 +1,11 @@
1
1
  "use client";
2
2
 
3
3
  import { Text } from "@neynar/ui/typography";
4
+ import { useSnapColors } from "../hooks/use-snap-colors";
4
5
 
5
6
  const SIZE_MAP = {
6
- md: { component: "text", textSize: "base" as const },
7
- sm: { component: "text", textSize: "sm" as const },
8
- } as const;
9
-
10
- const WEIGHT_MAP = {
11
- bold: "bold",
12
- normal: "normal",
7
+ md: { textSize: "base" as const },
8
+ sm: { textSize: "sm" as const },
13
9
  } as const;
14
10
 
15
11
  export function SnapText({
@@ -22,9 +18,16 @@ export function SnapText({
22
18
  const weight = props.weight ? String(props.weight) as "bold" | "normal" : undefined;
23
19
  const align = (props.align as "left" | "center" | "right") ?? undefined;
24
20
  const config = SIZE_MAP[size] ?? SIZE_MAP.md;
21
+ const colors = useSnapColors();
25
22
 
26
23
  return (
27
- <Text size={config.textSize} weight={weight} align={align} className="flex-1">
24
+ <Text
25
+ size={config.textSize}
26
+ weight={weight}
27
+ align={align}
28
+ className="flex-1"
29
+ style={{ color: colors.text }}
30
+ >
28
31
  {content}
29
32
  </Text>
30
33
  );
@@ -1,9 +1,10 @@
1
1
  "use client";
2
2
 
3
+ import { useState } from "react";
3
4
  import { useStateStore } from "@json-render/react";
4
5
  import { Label } from "@neynar/ui/label";
5
6
  import { cn } from "@neynar/ui/utils";
6
- import { useSnapAccentScopeStyle } from "../hooks/use-snap-accent";
7
+ import { useSnapColors } from "../hooks/use-snap-colors";
7
8
 
8
9
  export function SnapToggleGroup({
9
10
  element: { props },
@@ -11,7 +12,7 @@ export function SnapToggleGroup({
11
12
  element: { props: Record<string, unknown> };
12
13
  }) {
13
14
  const { get, set } = useStateStore();
14
- const accentStyle = useSnapAccentScopeStyle();
15
+ const colors = useSnapColors();
15
16
  const name = String(props.name ?? "toggle_group");
16
17
  const path = `/inputs/${name}`;
17
18
  const label = props.label ? String(props.label) : undefined;
@@ -50,30 +51,46 @@ export function SnapToggleGroup({
50
51
  };
51
52
 
52
53
  const isVertical = orientation === "vertical";
54
+ const [hoveredIdx, setHoveredIdx] = useState<number | null>(null);
53
55
 
54
56
  return (
55
- <div className="w-full space-y-1.5" style={accentStyle}>
56
- {label && <Label>{label}</Label>}
57
+ <div className="w-full space-y-1.5">
58
+ {label && <Label style={{ color: colors.text }}>{label}</Label>}
57
59
  <div
58
60
  className={cn(
59
- "flex gap-1 rounded-lg bg-border/20 p-1",
61
+ "flex gap-1 rounded-lg p-1",
60
62
  isVertical ? "flex-col" : "flex-row",
61
63
  )}
64
+ style={{ backgroundColor: colors.muted }}
62
65
  >
63
- {options.map((opt) => {
66
+ {options.map((opt, i) => {
64
67
  const isSelected = selected.includes(opt);
68
+ const isHovered = hoveredIdx === i && !isSelected;
65
69
  return (
66
70
  <button
67
71
  key={opt}
68
72
  type="button"
69
73
  onClick={() => toggle(opt)}
74
+ onPointerEnter={() => setHoveredIdx(i)}
75
+ onPointerLeave={() => setHoveredIdx(null)}
70
76
  className={cn(
71
77
  "rounded-md px-3 py-2 text-sm font-medium transition-colors",
72
78
  isVertical ? "w-full" : "flex-1",
73
- isSelected
74
- ? "bg-primary text-primary-foreground"
75
- : "text-foreground hover:bg-border/30",
76
79
  )}
80
+ style={{
81
+ transition: "background-color 0.15s, color 0.15s",
82
+ ...(isSelected
83
+ ? {
84
+ backgroundColor: colors.mode === "dark" ? "rgba(255,255,255,0.10)" : "rgba(0,0,0,0.08)",
85
+ color: colors.text,
86
+ }
87
+ : {
88
+ color: colors.text,
89
+ backgroundColor: isHovered
90
+ ? (colors.mode === "dark" ? "rgba(255,255,255,0.04)" : "rgba(0,0,0,0.04)")
91
+ : (colors.mode === "dark" ? "rgba(255,255,255,0.02)" : "rgba(0,0,0,0.02)"),
92
+ }),
93
+ }}
77
94
  >
78
95
  {opt}
79
96
  </button>
@@ -0,0 +1,129 @@
1
+ "use client";
2
+
3
+ import { useMemo } from "react";
4
+ import { useStateStore } from "@json-render/react";
5
+ import { useColorMode } from "@neynar/ui/color-mode";
6
+ import { resolveSnapPaletteHex } from "../lib/resolve-palette-hex";
7
+ import { useSnapPreviewPageAccent } from "../accent-context";
8
+ import type { PaletteColor } from "@farcaster/snap";
9
+ import { PALETTE_DARK_HEX, PALETTE_LIGHT_HEX } from "@farcaster/snap";
10
+
11
+ /** Readable foreground color (black or white) for a given hex background. */
12
+ export function pickForegroundForBg(hex: string): string {
13
+ const h = hex.replace(/^#/, "");
14
+ if (h.length !== 6) return "#ffffff";
15
+ const r = Number.parseInt(h.slice(0, 2), 16);
16
+ const g = Number.parseInt(h.slice(2, 4), 16);
17
+ const b = Number.parseInt(h.slice(4, 6), 16);
18
+ const yiq = (r * 299 + g * 587 + b * 114) / 1000;
19
+ return yiq >= 180 ? "#0a0a0a" : "#ffffff";
20
+ }
21
+
22
+ const NEUTRAL_LIGHT = {
23
+ text: "#111111",
24
+ textMuted: "#6B7280",
25
+ border: "#E5E7EB",
26
+ muted: "rgba(0,0,0,0.06)",
27
+ surface: "#ffffff",
28
+ inputBorder: "#E5E7EB",
29
+ inputBg: "rgba(0,0,0,0.06)",
30
+ } as const;
31
+
32
+ const NEUTRAL_DARK = {
33
+ text: "#FAFAFA",
34
+ textMuted: "#A1A1AA",
35
+ border: "#2D2D44",
36
+ muted: "rgba(255,255,255,0.03)",
37
+ surface: "#23262f",
38
+ inputBorder: "#3F3F46",
39
+ inputBg: "rgba(255,255,255,0.03)",
40
+ } as const;
41
+
42
+ export type SnapColors = {
43
+ /** Resolved accent hex */
44
+ accent: string;
45
+ /** Readable foreground for accent bg (black or white) */
46
+ accentFg: string;
47
+ /** Primary button hover color */
48
+ accentHover: string;
49
+ /** Secondary/outline button hover color */
50
+ outlineHover: string;
51
+ /** Primary text color */
52
+ text: string;
53
+ /** Muted/secondary text color */
54
+ textMuted: string;
55
+ /** Border color */
56
+ border: string;
57
+ /** Muted background (tracks, containers) */
58
+ muted: string;
59
+ /** Surface/card background */
60
+ surface: string;
61
+ /** Input border */
62
+ inputBorder: string;
63
+ /** Input background */
64
+ inputBg: string;
65
+ /** Current color mode */
66
+ mode: "light" | "dark";
67
+ /** Resolve a palette color name to hex */
68
+ paletteHex: (name: string) => string;
69
+ /** Resolve a palette color name to hex, with accent fallback */
70
+ colorHex: (name: string | undefined) => string;
71
+ };
72
+
73
+ function buildSnapColors(
74
+ accentName: string,
75
+ mode: "light" | "dark",
76
+ ): SnapColors {
77
+ const accent = resolveSnapPaletteHex(accentName, mode);
78
+ const accentFg = pickForegroundForBg(accent);
79
+ const neutrals = mode === "dark" ? NEUTRAL_DARK : NEUTRAL_LIGHT;
80
+ const paletteMap = mode === "dark" ? PALETTE_DARK_HEX : PALETTE_LIGHT_HEX;
81
+
82
+ const accentHover =
83
+ mode === "light"
84
+ ? `color-mix(in srgb, ${accent} 82%, #000000)`
85
+ : `color-mix(in srgb, ${accent} 78%, #ffffff)`;
86
+
87
+ const outlineHover = `color-mix(in srgb, ${accent} 14%, ${neutrals.surface})`;
88
+
89
+ const paletteHex = (name: string) => resolveSnapPaletteHex(name, mode);
90
+
91
+ const colorHex = (name: string | undefined) => {
92
+ if (!name || name === "accent") return accent;
93
+ if (Object.hasOwn(paletteMap, name)) return paletteMap[name as PaletteColor];
94
+ return accent;
95
+ };
96
+
97
+ return {
98
+ accent,
99
+ accentFg,
100
+ accentHover,
101
+ outlineHover,
102
+ ...neutrals,
103
+ mode,
104
+ paletteHex,
105
+ colorHex,
106
+ };
107
+ }
108
+
109
+ /**
110
+ * Returns fully resolved color values for snap components.
111
+ * All colors are concrete hex values (or color-mix expressions for hover states)
112
+ * so they can be used as inline styles, independent of host app CSS.
113
+ */
114
+ export function useSnapColors(): SnapColors {
115
+ const { get } = useStateStore();
116
+ const { mode } = useColorMode();
117
+ const pageAccent = useSnapPreviewPageAccent();
118
+ const fromState = get("/theme/accent");
119
+ const accentRaw =
120
+ (typeof pageAccent === "string" && pageAccent.length > 0
121
+ ? pageAccent
122
+ : fromState) ?? undefined;
123
+ const accentName =
124
+ typeof accentRaw === "string" && accentRaw.length > 0
125
+ ? accentRaw
126
+ : "purple";
127
+
128
+ return useMemo(() => buildSnapColors(accentName, mode), [accentName, mode]);
129
+ }
@@ -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
  });
@@ -6,11 +6,6 @@ import { useSnapPalette } from "../use-snap-palette";
6
6
  import { useSnapTheme } from "../theme";
7
7
  import { ICON_MAP } from "./snap-icon";
8
8
 
9
- const VARIANT_MAP: Record<string, "primary" | "secondary"> = {
10
- primary: "primary",
11
- secondary: "secondary",
12
- };
13
-
14
9
  export function SnapActionButton({
15
10
  element: { props },
16
11
  emit,
@@ -18,28 +13,22 @@ export function SnapActionButton({
18
13
  const { accentHex } = useSnapPalette();
19
14
  const { colors } = useSnapTheme();
20
15
  const label = String(props.label ?? "Action");
21
- const variant = VARIANT_MAP[String(props.variant ?? "secondary")] ?? "secondary";
16
+ const variant = String(props.variant ?? "secondary");
17
+ const isPrimary = variant === "primary";
22
18
  const iconName = props.icon ? String(props.icon) : undefined;
23
19
 
24
- const variantStyle = (() => {
25
- switch (variant) {
26
- case "primary":
27
- return { backgroundColor: accentHex };
28
- case "secondary":
29
- return { backgroundColor: "transparent", borderWidth: 1.5, borderColor: accentHex };
30
- }
31
- })();
32
-
33
- const textColor = variant === "primary" ? "#fff" : accentHex;
34
- const iconColor = variant === "primary" ? "#fff" : accentHex;
20
+ const textColor = isPrimary ? "#fff" : colors.text;
21
+ const iconColor = isPrimary ? "#fff" : colors.text;
35
22
 
36
23
  return (
37
24
  <View style={styles.outer}>
38
25
  <Pressable
39
26
  style={({ pressed }) => [
40
27
  styles.btn,
41
- variant === "primary" ? styles.btnDefault : styles.btnOther,
42
- variantStyle,
28
+ isPrimary ? styles.btnDefault : styles.btnOther,
29
+ isPrimary
30
+ ? { backgroundColor: pressed ? accentHex + "DD" : accentHex }
31
+ : { backgroundColor: pressed ? colors.mutedHover : colors.muted },
43
32
  pressed && styles.pressed,
44
33
  ]}
45
34
  onPress={() => {
@@ -48,7 +37,6 @@ export function SnapActionButton({
48
37
  await emit("press");
49
38
  } catch (err: unknown) {
50
39
  if (typeof __DEV__ !== "undefined" && __DEV__) {
51
- // eslint-disable-next-line no-console
52
40
  console.error("[snap] action failed", err);
53
41
  }
54
42
  }
@@ -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, backgroundColor: colors.muted, padding: 4, borderRadius: 8 }]}>
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
+ });
@@ -23,7 +23,7 @@ export function SnapSwitch({
23
23
  <Switch
24
24
  value={checked}
25
25
  onValueChange={(v) => set(path, v)}
26
- trackColor={{ false: colors.border, true: accentHex }}
26
+ trackColor={{ false: colors.muted, true: accentHex }}
27
27
  thumbColor="#fff"
28
28
  />
29
29
  </View>