@farcaster/snap 1.13.0 → 1.15.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 (56) hide show
  1. package/dist/react/components/action-button.js +18 -10
  2. package/dist/react/components/badge.js +8 -6
  3. package/dist/react/components/bar-chart.js +12 -17
  4. package/dist/react/components/cell-grid.js +16 -15
  5. package/dist/react/components/icon.js +4 -10
  6. package/dist/react/components/input.js +12 -6
  7. package/dist/react/components/item-group.js +3 -1
  8. package/dist/react/components/item.d.ts +3 -3
  9. package/dist/react/components/item.js +4 -3
  10. package/dist/react/components/progress.js +3 -3
  11. package/dist/react/components/separator.js +3 -1
  12. package/dist/react/components/slider.js +14 -10
  13. package/dist/react/components/switch.js +10 -12
  14. package/dist/react/components/text.js +5 -7
  15. package/dist/react/components/toggle-group.js +20 -6
  16. package/dist/react/hooks/use-snap-colors.d.ts +38 -0
  17. package/dist/react/hooks/use-snap-colors.js +82 -0
  18. package/dist/react/index.js +8 -5
  19. package/dist/react-native/components/snap-action-button.js +8 -18
  20. package/dist/react-native/components/snap-cell-grid.js +1 -1
  21. package/dist/react-native/components/snap-switch.js +1 -1
  22. package/dist/react-native/components/snap-toggle-group.js +8 -10
  23. package/dist/react-native/index.js +1 -1
  24. package/dist/react-native/theme.d.ts +6 -0
  25. package/dist/react-native/theme.js +12 -6
  26. package/dist/ui/catalog.d.ts +1 -0
  27. package/dist/ui/cell-grid.d.ts +1 -0
  28. package/dist/ui/cell-grid.js +1 -0
  29. package/llms.txt +1 -0
  30. package/package.json +1 -1
  31. package/src/react/components/action-button.tsx +24 -17
  32. package/src/react/components/badge.tsx +14 -17
  33. package/src/react/components/bar-chart.tsx +21 -19
  34. package/src/react/components/cell-grid.tsx +16 -19
  35. package/src/react/components/icon.tsx +5 -18
  36. package/src/react/components/input.tsx +20 -9
  37. package/src/react/components/item-group.tsx +4 -1
  38. package/src/react/components/item.tsx +13 -10
  39. package/src/react/components/progress.tsx +12 -7
  40. package/src/react/components/separator.tsx +8 -1
  41. package/src/react/components/slider.tsx +18 -15
  42. package/src/react/components/switch.tsx +12 -16
  43. package/src/react/components/text.tsx +11 -8
  44. package/src/react/components/toggle-group.tsx +26 -9
  45. package/src/react/hooks/use-snap-colors.ts +129 -0
  46. package/src/react/index.tsx +19 -18
  47. package/src/react-native/components/snap-action-button.tsx +8 -20
  48. package/src/react-native/components/snap-cell-grid.tsx +1 -1
  49. package/src/react-native/components/snap-switch.tsx +1 -1
  50. package/src/react-native/components/snap-toggle-group.tsx +8 -10
  51. package/src/react-native/index.tsx +1 -1
  52. package/src/react-native/theme.tsx +18 -6
  53. package/src/ui/cell-grid.ts +1 -0
  54. package/dist/react/hooks/use-snap-accent.d.ts +0 -13
  55. package/dist/react/hooks/use-snap-accent.js +0 -32
  56. package/src/react/hooks/use-snap-accent.ts +0 -45
@@ -1,20 +1,28 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useState } from "react";
3
4
  import { Button } from "@neynar/ui/button";
4
5
  import { cn } from "@neynar/ui/utils";
5
- import { useSnapAccentScopeStyle } from "../hooks/use-snap-accent.js";
6
+ import { useSnapColors } from "../hooks/use-snap-colors.js";
6
7
  import { ICON_MAP } from "./icon.js";
7
- const VARIANT_MAP = {
8
- primary: "default",
9
- secondary: "secondary",
10
- };
11
8
  export function SnapActionButton({ element: { props }, emit, }) {
12
9
  const label = String(props.label ?? "Action");
13
- const variant = VARIANT_MAP[String(props.variant ?? "secondary")] ?? "secondary";
10
+ const variant = String(props.variant ?? "secondary");
11
+ const isPrimary = variant === "primary";
14
12
  const iconName = props.icon ? String(props.icon) : undefined;
15
- const accentStyle = useSnapAccentScopeStyle();
13
+ const colors = useSnapColors();
14
+ const [hovered, setHovered] = useState(false);
16
15
  const Icon = iconName ? ICON_MAP[iconName] : undefined;
17
- return (_jsx("div", { className: "w-full min-w-0 flex-1", style: accentStyle, children: _jsxs(Button, { type: "button", variant: variant, className: cn("w-full gap-2", variant === "default" &&
18
- "hover:!bg-[var(--snap-action-primary-hover)]", variant !== "default" &&
19
- "hover:!bg-[var(--snap-action-outline-hover)]"), onClick: () => emit("press"), children: [Icon && _jsx(Icon, { size: 16 }), label] }) }));
16
+ const style = isPrimary
17
+ ? {
18
+ backgroundColor: hovered ? colors.accentHover : colors.accent,
19
+ color: colors.accentFg,
20
+ borderColor: "transparent",
21
+ }
22
+ : {
23
+ backgroundColor: hovered ? `color-mix(in srgb, ${colors.accent} 15%, transparent)` : colors.muted,
24
+ color: colors.text,
25
+ borderColor: "transparent",
26
+ };
27
+ return (_jsx("div", { className: "w-full min-w-0 flex-1", children: _jsxs(Button, { type: "button", variant: isPrimary ? "default" : "secondary", className: cn("w-full gap-2"), style: style, onClick: () => emit("press"), onPointerEnter: () => setHovered(true), onPointerLeave: () => setHovered(false), children: [Icon && _jsx(Icon, { size: 16 }), label] }) }));
20
28
  }
@@ -1,17 +1,19 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { Badge } from "@neynar/ui/badge";
4
- import { useSnapAccentScopeStyle } from "../hooks/use-snap-accent.js";
4
+ import { useSnapColors, pickForegroundForBg } from "../hooks/use-snap-colors.js";
5
5
  import { ICON_MAP } from "./icon.js";
6
6
  export function SnapBadge({ element: { props }, }) {
7
7
  const content = String(props.label ?? "");
8
8
  const variant = String(props.variant ?? "default");
9
9
  const color = props.color ? String(props.color) : undefined;
10
10
  const iconName = props.icon ? String(props.icon) : undefined;
11
- const accentStyle = useSnapAccentScopeStyle();
12
- const isAccent = !color || color === "accent";
11
+ const colors = useSnapColors();
12
+ const badgeColor = colors.colorHex(color);
13
+ const badgeFg = variant === "default" ? pickForegroundForBg(badgeColor) : badgeColor;
13
14
  const Icon = iconName ? ICON_MAP[iconName] : undefined;
14
- return (_jsx("span", { style: isAccent ? accentStyle : undefined, children: _jsxs(Badge, { variant: variant, className: "gap-1", style: variant === "outline" && !isAccent
15
- ? { borderColor: `var(--snap-color-${color})`, color: `var(--snap-color-${color})` }
16
- : undefined, children: [Icon && _jsx(Icon, { size: 12 }), content] }) }));
15
+ const style = variant === "outline"
16
+ ? { borderColor: badgeColor, color: badgeColor, backgroundColor: "transparent" }
17
+ : { backgroundColor: badgeColor, color: badgeFg, borderColor: "transparent" };
18
+ return (_jsxs(Badge, { variant: variant, className: "gap-1", style: style, children: [Icon && _jsx(Icon, { size: 12 }), content] }));
17
19
  }
@@ -1,31 +1,26 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { PALETTE_LIGHT_HEX } from "@farcaster/snap";
4
- import { useSnapAccentScopeStyle } from "../hooks/use-snap-accent.js";
3
+ import { useSnapColors } from "../hooks/use-snap-colors.js";
5
4
  export function SnapBarChart({ element: { props }, }) {
6
- const accentStyle = useSnapAccentScopeStyle();
5
+ const colors = useSnapColors();
7
6
  const bars = Array.isArray(props.bars) ? props.bars : [];
8
- const chartColor = String(props.color ?? "accent");
7
+ const chartColor = props.color ? String(props.color) : undefined;
9
8
  const maxVal = props.max != null
10
9
  ? Number(props.max)
11
10
  : Math.max(...bars.map((b) => Number(b.value ?? 0)), 1);
12
- function barColor(bar) {
13
- if (bar.color && bar.color in PALETTE_LIGHT_HEX) {
14
- return `var(--snap-color-${bar.color}, ${PALETTE_LIGHT_HEX[bar.color]})`;
15
- }
16
- if (chartColor !== "accent" && chartColor in PALETTE_LIGHT_HEX) {
17
- return `var(--snap-color-${chartColor}, ${PALETTE_LIGHT_HEX[chartColor]})`;
18
- }
19
- return "var(--primary)";
11
+ function barFill(bar) {
12
+ if (bar.color)
13
+ return colors.colorHex(bar.color);
14
+ return colors.colorHex(chartColor);
20
15
  }
21
- return (_jsx("div", { className: "flex w-full flex-col gap-2", style: accentStyle, children: bars.map((bar, i) => {
16
+ return (_jsx("div", { className: "flex w-full flex-col gap-2", children: bars.map((bar, i) => {
22
17
  const value = Number(bar.value ?? 0);
23
18
  const pct = maxVal > 0 ? Math.min(100, (value / maxVal) * 100) : 0;
24
- const fill = barColor(bar);
25
- return (_jsxs("div", { className: "flex w-full items-center gap-2", children: [_jsx("span", { className: "text-muted-foreground w-20 shrink-0 truncate text-right text-xs", children: String(bar.label ?? "") }), _jsx("div", { className: "bg-muted h-2.5 flex-1 overflow-hidden rounded-full", children: _jsx("div", { className: "h-full rounded-full transition-all", style: {
19
+ const fill = barFill(bar);
20
+ return (_jsxs("div", { className: "flex w-full items-center gap-2", children: [_jsx("span", { className: "w-20 shrink-0 truncate text-right text-xs", style: { color: colors.textMuted }, children: String(bar.label ?? "") }), _jsx("div", { className: "h-2.5 flex-1 overflow-hidden rounded-full", style: { backgroundColor: colors.muted }, children: _jsx("div", { className: "h-full rounded-full transition-all", style: {
26
21
  width: `${pct}%`,
27
22
  minWidth: pct > 0 ? 4 : 0,
28
- background: fill,
29
- } }) }), _jsx("span", { className: "text-muted-foreground w-8 shrink-0 text-xs tabular-nums", children: value })] }, i));
23
+ backgroundColor: fill,
24
+ } }) }), _jsx("span", { className: "w-8 shrink-0 text-xs tabular-nums", style: { color: colors.textMuted }, children: value })] }, i));
30
25
  }) }));
31
26
  }
@@ -2,13 +2,11 @@
2
2
  import { jsx as _jsx, jsxs as _jsxs } 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, PALETTE_LIGHT_HEX } from "@farcaster/snap";
6
- import { useSnapAccentScopeStyle } from "../hooks/use-snap-accent.js";
7
- import { useColorMode } from "@neynar/ui/color-mode";
5
+ import { POST_GRID_TAP_KEY } from "@farcaster/snap";
6
+ import { useSnapColors } from "../hooks/use-snap-colors.js";
8
7
  export function SnapCellGrid({ element: { props }, }) {
9
8
  const { get, set } = useStateStore();
10
- const accentStyle = useSnapAccentScopeStyle();
11
- const { mode: appearance } = useColorMode();
9
+ const colors = useSnapColors();
12
10
  const cols = Number(props.cols ?? 2);
13
11
  const rows = Number(props.rows ?? 2);
14
12
  const select = String(props.select ?? "off");
@@ -18,6 +16,7 @@ export function SnapCellGrid({ element: { props }, }) {
18
16
  const gap = String(props.gap ?? "sm");
19
17
  const gapMap = { none: 0, sm: 1, md: 2, lg: 4 };
20
18
  const gapPx = gapMap[gap] ?? 1;
19
+ const rowHeight = typeof props.rowHeight === "number" ? props.rowHeight : 28;
21
20
  const name = props.name ? String(props.name) : POST_GRID_TAP_KEY;
22
21
  const tapPath = `/inputs/${name}`;
23
22
  const tapRaw = get(tapPath);
@@ -51,15 +50,12 @@ export function SnapCellGrid({ element: { props }, }) {
51
50
  content: c.content != null ? String(c.content) : undefined,
52
51
  });
53
52
  }
54
- const ringColor = appearance === "dark" ? "#fff" : "#000";
55
53
  const cellEls = [];
56
54
  for (let r = 0; r < rows; r++) {
57
55
  for (let c = 0; c < cols; c++) {
58
56
  const cell = cellMap.get(`${r},${c}`);
59
57
  const selected = interactive && isSelected(r, c);
60
- const bg = cell?.color && cell.color in PALETTE_LIGHT_HEX
61
- ? `var(--snap-color-${cell.color}, ${PALETTE_LIGHT_HEX[cell.color]})`
62
- : "transparent";
58
+ const bg = cell?.color ? colors.colorHex(cell.color) : "transparent";
63
59
  cellEls.push(_jsx("div", { role: interactive ? "button" : undefined, tabIndex: interactive ? 0 : undefined, onClick: interactive ? () => handleTap(r, c) : undefined, onKeyDown: interactive
64
60
  ? (e) => {
65
61
  if (e.key === "Enter" || e.key === " ") {
@@ -67,11 +63,11 @@ export function SnapCellGrid({ element: { props }, }) {
67
63
  handleTap(r, c);
68
64
  }
69
65
  }
70
- : undefined, className: cn("flex min-h-7 items-center justify-center rounded text-xs font-semibold", interactive ? "cursor-pointer select-none" : "cursor-default"), style: {
66
+ : undefined, className: cn("flex items-center justify-center rounded text-xs font-semibold", interactive ? "cursor-pointer select-none" : "cursor-default"), style: {
67
+ height: rowHeight,
71
68
  background: bg,
72
- // Two-layer ring: 1px white/black inner + 2px accent outer
73
69
  boxShadow: selected
74
- ? `inset 0 0 0 1px ${appearance === "dark" ? "#000" : "#fff"}, inset 0 0 0 2px ${appearance === "dark" ? "#fff" : "#000"}`
70
+ ? `inset 0 0 0 1px ${colors.mode === "dark" ? "#000" : "#fff"}, inset 0 0 0 2px ${colors.mode === "dark" ? "#fff" : "#000"}`
75
71
  : undefined,
76
72
  }, children: cell?.content ?? "" }, `${r}-${c}`));
77
73
  }
@@ -79,8 +75,13 @@ export function SnapCellGrid({ element: { props }, }) {
79
75
  const selectionLabel = interactive && selectedSet.size > 0
80
76
  ? `inputs.${name}: ${[...selectedSet].join(isMultiple ? " | " : "")}`
81
77
  : null;
82
- return (_jsxs("div", { style: accentStyle, children: [_jsx("div", { className: "grid w-full", style: {
83
- gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))`,
78
+ return (_jsxs("div", { children: [_jsx("div", { style: {
79
+ display: "grid",
80
+ width: "100%",
81
+ gridTemplateColumns: `repeat(${cols}, 1fr)`,
84
82
  gap: gapPx,
85
- }, children: cellEls }), selectionLabel && (_jsx("div", { className: "text-muted-foreground mt-1.5 truncate text-xs font-mono", children: selectionLabel }))] }));
83
+ padding: 4,
84
+ borderRadius: 8,
85
+ backgroundColor: colors.muted,
86
+ }, children: cellEls }), selectionLabel && (_jsx("div", { className: "mt-1.5 truncate text-xs font-mono", style: { color: colors.textMuted }, children: selectionLabel }))] }));
86
87
  }
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
  import { jsx as _jsx } from "react/jsx-runtime";
3
3
  import { ArrowRight, ArrowLeft, ExternalLink, ChevronRight, Check, X, AlertTriangle, Info, Clock, Heart, MessageCircle, Repeat, Share, User, Users, Star, Trophy, Zap, Flame, Gift, ImageIcon, Play, Pause, Wallet, Coins, Plus, Minus, RefreshCw, Bookmark, ThumbsUp, ThumbsDown, TrendingUp, TrendingDown, } from "lucide-react";
4
- import { useSnapAccentScopeStyle } from "../hooks/use-snap-accent.js";
4
+ import { useSnapColors } from "../hooks/use-snap-colors.js";
5
5
  export const ICON_MAP = {
6
6
  "arrow-right": ArrowRight,
7
7
  "arrow-left": ArrowLeft,
@@ -45,16 +45,10 @@ export function SnapIcon({ element: { props }, }) {
45
45
  const name = String(props.name ?? "info");
46
46
  const size = SIZE_PX[String(props.size ?? "md")] ?? 20;
47
47
  const color = props.color ? String(props.color) : undefined;
48
- const accentStyle = useSnapAccentScopeStyle();
48
+ const colors = useSnapColors();
49
49
  const Icon = ICON_MAP[name];
50
50
  if (!Icon)
51
51
  return null;
52
- const isAccent = !color || color === "accent";
53
- return (_jsx("span", { style: {
54
- display: "inline-flex",
55
- alignItems: "center",
56
- ...(isAccent ? accentStyle : {}),
57
- }, children: _jsx(Icon, { size: size, style: isAccent
58
- ? { color: "var(--snap-accent, currentColor)" }
59
- : { color: `var(--snap-color-${color}, currentColor)` } }) }));
52
+ const iconColor = colors.colorHex(color);
53
+ return (_jsx("span", { style: { display: "inline-flex", alignItems: "center" }, children: _jsx(Icon, { size: size, style: { color: iconColor } }) }));
60
54
  }
@@ -4,15 +4,21 @@ import { useId } from "react";
4
4
  import { useStateStore } from "@json-render/react";
5
5
  import { Input } from "@neynar/ui/input";
6
6
  import { Label } from "@neynar/ui/label";
7
+ import { useSnapColors } from "../hooks/use-snap-colors.js";
7
8
  export function SnapInput({ element: { props }, }) {
8
- const id = useId();
9
9
  const { get, set } = useStateStore();
10
+ const colors = useSnapColors();
10
11
  const name = String(props.name ?? "input");
11
- const path = `/inputs/${name}`;
12
+ const type = String(props.type ?? "text");
12
13
  const label = props.label ? String(props.label) : undefined;
13
14
  const placeholder = props.placeholder ? String(props.placeholder) : undefined;
14
- const maxLength = typeof props.maxLength === "number" ? props.maxLength : undefined;
15
- const raw = get(path);
16
- const value = typeof raw === "string" ? raw : "";
17
- return (_jsxs("div", { className: "w-full space-y-1.5", children: [label && _jsx(Label, { htmlFor: id, children: label }), _jsx(Input, { id: id, value: value, onChange: (e) => set(path, e.target.value), placeholder: placeholder, maxLength: maxLength })] }));
15
+ const maxLength = props.maxLength ? Number(props.maxLength) : undefined;
16
+ const path = `/inputs/${name}`;
17
+ const value = get(path) ?? (props.defaultValue != null ? String(props.defaultValue) : "");
18
+ const id = useId();
19
+ return (_jsxs("div", { className: "w-full space-y-1.5", children: [label && (_jsx(Label, { htmlFor: id, style: { color: colors.text }, children: label })), _jsx(Input, { id: id, type: type === "number" ? "number" : "text", placeholder: placeholder, maxLength: maxLength, value: value, onChange: (e) => set(path, e.target.value), style: {
20
+ backgroundColor: colors.inputBg,
21
+ borderColor: colors.inputBorder,
22
+ color: colors.text,
23
+ } })] }));
18
24
  }
@@ -2,6 +2,7 @@
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { Children, Fragment } from "react";
4
4
  import { cn } from "@neynar/ui/utils";
5
+ import { useSnapColors } from "../hooks/use-snap-colors.js";
5
6
  const GAP_MAP = {
6
7
  none: "gap-0",
7
8
  sm: "gap-1",
@@ -13,5 +14,6 @@ export function SnapItemGroup({ element: { props }, children, }) {
13
14
  const separator = Boolean(props.separator);
14
15
  const gap = GAP_MAP[String(props.gap ?? "sm")] ?? "gap-1";
15
16
  const items = Children.toArray(children);
16
- return (_jsx("div", { className: cn("flex flex-col", border && "rounded-lg border", gap), children: items.map((child, i) => (_jsxs(Fragment, { children: [separator && i > 0 && (_jsx("div", { className: "h-px bg-border" })), child] }, i))) }));
17
+ const colors = useSnapColors();
18
+ return (_jsx("div", { className: cn("flex flex-col", border && "rounded-lg border", gap), style: border ? { borderColor: colors.border } : undefined, children: items.map((child, i) => (_jsxs(Fragment, { children: [separator && i > 0 && (_jsx("div", { className: "h-px", style: { backgroundColor: colors.border } })), child] }, i))) }));
17
19
  }
@@ -1,7 +1,7 @@
1
- import type { ReactNode } from "react";
2
- export declare function SnapItem({ element: { props }, children, }: {
1
+ export declare function SnapItem({ element: { props, children: childIds }, children, }: {
3
2
  element: {
4
3
  props: Record<string, unknown>;
4
+ children?: string[];
5
5
  };
6
- children?: ReactNode;
6
+ children?: React.ReactNode;
7
7
  }): import("react/jsx-runtime").JSX.Element;
@@ -1,9 +1,10 @@
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
- export function SnapItem({ element: { props }, children, }) {
4
+ import { useSnapColors } from "../hooks/use-snap-colors.js";
5
+ export function SnapItem({ element: { props, children: childIds }, children, }) {
5
6
  const title = String(props.title ?? "");
6
7
  const description = props.description ? String(props.description) : undefined;
7
- const variant = props.variant ?? "default";
8
- return (_jsxs(Item, { variant: variant, className: "flex-1 py-1.5 px-2.5", children: [_jsxs(ItemContent, { className: "gap-0.5", children: [_jsx(ItemTitle, { children: title }), description && _jsx(ItemDescription, { className: "mt-0", children: description })] }), children && _jsx(ItemActions, { children: children })] }));
8
+ const colors = useSnapColors();
9
+ return (_jsxs(Item, { className: "flex-1 py-1.5 px-2.5", children: [_jsxs(ItemContent, { className: "gap-0.5", children: [_jsx(ItemTitle, { style: { color: colors.text }, children: title }), description && (_jsx(ItemDescription, { className: "mt-0", style: { color: colors.textMuted }, children: description }))] }), childIds && childIds.length > 0 && _jsx(ItemActions, { children: children })] }));
9
10
  }
@@ -1,11 +1,11 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { useSnapAccentScopeStyle } from "../hooks/use-snap-accent.js";
3
+ import { useSnapColors } from "../hooks/use-snap-colors.js";
4
4
  export function SnapProgress({ element: { props }, }) {
5
- const accentStyle = useSnapAccentScopeStyle();
5
+ const colors = useSnapColors();
6
6
  const value = Number(props.value ?? 0);
7
7
  const max = Math.max(1, Number(props.max ?? 100));
8
8
  const percent = Math.min(100, Math.max(0, (value / max) * 100));
9
9
  const label = props.label ? String(props.label) : null;
10
- return (_jsxs("div", { className: "flex w-full flex-1 flex-col gap-1", style: accentStyle, children: [label && (_jsx("span", { className: "text-muted-foreground text-xs", children: label })), _jsx("div", { className: "bg-muted h-2.5 w-full overflow-hidden rounded-full", children: _jsx("div", { className: "h-full rounded-full bg-primary transition-all", style: { width: `${percent}%` } }) })] }));
10
+ return (_jsxs("div", { className: "flex w-full flex-1 flex-col gap-1", children: [label && (_jsx("span", { className: "text-xs", style: { color: colors.textMuted }, children: label })), _jsx("div", { className: "h-2.5 w-full overflow-hidden rounded-full", style: { backgroundColor: colors.muted }, children: _jsx("div", { className: "h-full rounded-full transition-all", style: { width: `${percent}%`, backgroundColor: colors.accent } }) })] }));
11
11
  }
@@ -1,7 +1,9 @@
1
1
  "use client";
2
2
  import { jsx as _jsx } from "react/jsx-runtime";
3
3
  import { Separator } from "@neynar/ui/separator";
4
+ import { useSnapColors } from "../hooks/use-snap-colors.js";
4
5
  export function SnapSeparator({ element: { props }, }) {
5
6
  const orientation = props.orientation ?? "horizontal";
6
- return _jsx(Separator, { orientation: orientation });
7
+ const colors = useSnapColors();
8
+ return (_jsx(Separator, { orientation: orientation, style: { backgroundColor: colors.border } }));
7
9
  }
@@ -2,20 +2,24 @@
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { useStateStore } from "@json-render/react";
4
4
  import { Label } from "@neynar/ui/label";
5
- import { useSnapAccentScopeStyle } from "../hooks/use-snap-accent.js";
6
- // TODO: switch back to @neynar/ui/slider once Base UI fixes the inline
7
- // <script> tag that triggers a React console warning on client render.
5
+ import { useSnapColors } from "../hooks/use-snap-colors.js";
8
6
  export function SnapSlider({ element: { props }, }) {
9
7
  const { get, set } = useStateStore();
10
- const accentStyle = useSnapAccentScopeStyle();
8
+ const colors = useSnapColors();
11
9
  const name = String(props.name ?? "slider");
12
- const path = `/inputs/${name}`;
13
- const label = props.label ? String(props.label) : undefined;
14
10
  const min = Number(props.min ?? 0);
15
11
  const max = Number(props.max ?? 100);
16
- const step = props.step != null ? Number(props.step) : 1;
17
- const fallback = props.defaultValue != null ? Number(props.defaultValue) : (min + max) / 2;
12
+ const step = Number(props.step ?? 1);
13
+ const label = props.label ? String(props.label) : undefined;
14
+ const path = `/inputs/${name}`;
18
15
  const raw = get(path);
19
- const value = raw === undefined || raw === null ? fallback : Number(raw);
20
- return (_jsxs("div", { className: "flex w-full flex-col gap-1.5", style: accentStyle, children: [label && _jsx(Label, { children: label }), _jsx("input", { type: "range", min: min, max: max, step: step, value: value, onChange: (e) => set(path, Number(e.target.value)), className: "w-full h-2.5 rounded-full appearance-none bg-muted cursor-pointer", style: { accentColor: "var(--primary)" } })] }));
16
+ const value = raw !== undefined
17
+ ? Number(raw)
18
+ : props.defaultValue !== undefined
19
+ ? Number(props.defaultValue)
20
+ : (min + max) / 2;
21
+ return (_jsxs("div", { className: "flex w-full flex-col gap-1.5", children: [label && _jsx(Label, { style: { color: colors.text }, children: label }), _jsx("input", { type: "range", min: min, max: max, step: step, value: value, onChange: (e) => set(path, Number(e.target.value)), className: "w-full h-2.5 rounded-full appearance-none cursor-pointer", style: {
22
+ backgroundColor: colors.muted,
23
+ accentColor: colors.accent,
24
+ } })] }));
21
25
  }
@@ -2,22 +2,20 @@
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
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.js";
6
+ import { Label } from "@neynar/ui/label";
7
+ import { useSnapColors } from "../hooks/use-snap-colors.js";
10
8
  export function SnapSwitch({ element: { props }, }) {
11
- const id = useId();
12
9
  const { get, set } = useStateStore();
13
- const { mode } = useColorMode();
14
- const accentStyle = useSnapAccentScopeStyle();
10
+ const colors = useSnapColors();
15
11
  const name = String(props.name ?? "switch");
16
- const path = `/inputs/${name}`;
17
12
  const label = props.label ? String(props.label) : undefined;
18
- const fallback = Boolean(props.defaultChecked ?? false);
13
+ const path = `/inputs/${name}`;
19
14
  const raw = get(path);
20
- const checked = raw === undefined || raw === null ? fallback : Boolean(raw);
21
- return (_jsxs("div", { className: "flex items-center justify-between gap-3", children: [label && (_jsx(Label, { htmlFor: id, className: "text-foreground font-normal", children: label })), _jsx(Switch, { id: id, checked: checked, onCheckedChange: (v) => set(path, v), style: accentStyle, className: cn(mode === "light" &&
22
- "data-unchecked:!bg-border data-unchecked:!border-(--input-border)") })] }));
15
+ const checked = raw !== undefined ? Boolean(raw) : Boolean(props.defaultChecked);
16
+ const id = useId();
17
+ return (_jsxs("div", { className: "flex items-center justify-between gap-3", children: [label && (_jsx(Label, { htmlFor: id, className: "font-normal", style: { color: colors.text }, children: label })), _jsx(Switch, { id: id, checked: checked, onCheckedChange: (v) => set(path, v), style: {
18
+ backgroundColor: checked ? colors.accent : colors.muted,
19
+ borderColor: checked ? colors.accent : colors.inputBorder,
20
+ } })] }));
23
21
  }
@@ -1,13 +1,10 @@
1
1
  "use client";
2
2
  import { jsx as _jsx } from "react/jsx-runtime";
3
3
  import { Text } from "@neynar/ui/typography";
4
+ import { useSnapColors } from "../hooks/use-snap-colors.js";
4
5
  const SIZE_MAP = {
5
- md: { component: "text", textSize: "base" },
6
- sm: { component: "text", textSize: "sm" },
7
- };
8
- const WEIGHT_MAP = {
9
- bold: "bold",
10
- normal: "normal",
6
+ md: { textSize: "base" },
7
+ sm: { textSize: "sm" },
11
8
  };
12
9
  export function SnapText({ element: { props }, }) {
13
10
  const content = String(props.content ?? "");
@@ -15,5 +12,6 @@ export function SnapText({ element: { props }, }) {
15
12
  const weight = props.weight ? String(props.weight) : undefined;
16
13
  const align = props.align ?? undefined;
17
14
  const config = SIZE_MAP[size] ?? SIZE_MAP.md;
18
- return (_jsx(Text, { size: config.textSize, weight: weight, align: align, className: "flex-1", children: content }));
15
+ const colors = useSnapColors();
16
+ return (_jsx(Text, { size: config.textSize, weight: weight, align: align, className: "flex-1", style: { color: colors.text }, children: content }));
19
17
  }
@@ -1,12 +1,13 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
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.js";
7
+ import { useSnapColors } from "../hooks/use-snap-colors.js";
7
8
  export function SnapToggleGroup({ element: { props }, }) {
8
9
  const { get, set } = useStateStore();
9
- const accentStyle = useSnapAccentScopeStyle();
10
+ const colors = useSnapColors();
10
11
  const name = String(props.name ?? "toggle_group");
11
12
  const path = `/inputs/${name}`;
12
13
  const label = props.label ? String(props.label) : undefined;
@@ -43,10 +44,23 @@ export function SnapToggleGroup({ element: { props }, }) {
43
44
  }
44
45
  };
45
46
  const isVertical = orientation === "vertical";
46
- return (_jsxs("div", { className: "w-full space-y-1.5", style: accentStyle, children: [label && _jsx(Label, { children: label }), _jsx("div", { className: cn("flex gap-1 rounded-lg bg-border/20 p-1", isVertical ? "flex-col" : "flex-row"), children: options.map((opt) => {
47
+ const [hoveredIdx, setHoveredIdx] = useState(null);
48
+ return (_jsxs("div", { className: "w-full space-y-1.5", children: [label && _jsx(Label, { style: { color: colors.text }, children: label }), _jsx("div", { className: cn("flex gap-1 rounded-lg p-1", isVertical ? "flex-col" : "flex-row"), style: { backgroundColor: colors.muted }, children: options.map((opt, i) => {
47
49
  const isSelected = selected.includes(opt);
48
- return (_jsx("button", { type: "button", onClick: () => toggle(opt), className: cn("rounded-md px-3 py-2 text-sm font-medium transition-colors", isVertical ? "w-full" : "flex-1", isSelected
49
- ? "bg-primary text-primary-foreground"
50
- : "text-foreground hover:bg-border/30"), children: opt }, opt));
50
+ const isHovered = hoveredIdx === i && !isSelected;
51
+ return (_jsx("button", { type: "button", onClick: () => toggle(opt), onPointerEnter: () => setHoveredIdx(i), onPointerLeave: () => setHoveredIdx(null), className: cn("rounded-md px-3 py-2 text-sm font-medium transition-colors", isVertical ? "w-full" : "flex-1"), style: {
52
+ transition: "background-color 0.15s, color 0.15s",
53
+ ...(isSelected
54
+ ? {
55
+ backgroundColor: colors.mode === "dark" ? "rgba(255,255,255,0.10)" : "rgba(0,0,0,0.08)",
56
+ color: colors.text,
57
+ }
58
+ : {
59
+ color: colors.text,
60
+ backgroundColor: isHovered
61
+ ? (colors.mode === "dark" ? "rgba(255,255,255,0.04)" : "rgba(0,0,0,0.04)")
62
+ : (colors.mode === "dark" ? "rgba(255,255,255,0.02)" : "rgba(0,0,0,0.02)"),
63
+ }),
64
+ }, children: opt }, opt));
51
65
  }) })] }));
52
66
  }
@@ -0,0 +1,38 @@
1
+ /** Readable foreground color (black or white) for a given hex background. */
2
+ export declare function pickForegroundForBg(hex: string): string;
3
+ export type SnapColors = {
4
+ /** Resolved accent hex */
5
+ accent: string;
6
+ /** Readable foreground for accent bg (black or white) */
7
+ accentFg: string;
8
+ /** Primary button hover color */
9
+ accentHover: string;
10
+ /** Secondary/outline button hover color */
11
+ outlineHover: string;
12
+ /** Primary text color */
13
+ text: string;
14
+ /** Muted/secondary text color */
15
+ textMuted: string;
16
+ /** Border color */
17
+ border: string;
18
+ /** Muted background (tracks, containers) */
19
+ muted: string;
20
+ /** Surface/card background */
21
+ surface: string;
22
+ /** Input border */
23
+ inputBorder: string;
24
+ /** Input background */
25
+ inputBg: string;
26
+ /** Current color mode */
27
+ mode: "light" | "dark";
28
+ /** Resolve a palette color name to hex */
29
+ paletteHex: (name: string) => string;
30
+ /** Resolve a palette color name to hex, with accent fallback */
31
+ colorHex: (name: string | undefined) => string;
32
+ };
33
+ /**
34
+ * Returns fully resolved color values for snap components.
35
+ * All colors are concrete hex values (or color-mix expressions for hover states)
36
+ * so they can be used as inline styles, independent of host app CSS.
37
+ */
38
+ export declare function useSnapColors(): SnapColors;
@@ -0,0 +1,82 @@
1
+ "use client";
2
+ import { useMemo } from "react";
3
+ import { useStateStore } from "@json-render/react";
4
+ import { useColorMode } from "@neynar/ui/color-mode";
5
+ import { resolveSnapPaletteHex } from "../lib/resolve-palette-hex.js";
6
+ import { useSnapPreviewPageAccent } from "../accent-context.js";
7
+ import { PALETTE_DARK_HEX, PALETTE_LIGHT_HEX } from "@farcaster/snap";
8
+ /** Readable foreground color (black or white) for a given hex background. */
9
+ export function pickForegroundForBg(hex) {
10
+ const h = hex.replace(/^#/, "");
11
+ if (h.length !== 6)
12
+ return "#ffffff";
13
+ const r = Number.parseInt(h.slice(0, 2), 16);
14
+ const g = Number.parseInt(h.slice(2, 4), 16);
15
+ const b = Number.parseInt(h.slice(4, 6), 16);
16
+ const yiq = (r * 299 + g * 587 + b * 114) / 1000;
17
+ return yiq >= 180 ? "#0a0a0a" : "#ffffff";
18
+ }
19
+ const NEUTRAL_LIGHT = {
20
+ text: "#111111",
21
+ textMuted: "#6B7280",
22
+ border: "#E5E7EB",
23
+ muted: "rgba(0,0,0,0.06)",
24
+ surface: "#ffffff",
25
+ inputBorder: "#E5E7EB",
26
+ inputBg: "rgba(0,0,0,0.06)",
27
+ };
28
+ const NEUTRAL_DARK = {
29
+ text: "#FAFAFA",
30
+ textMuted: "#A1A1AA",
31
+ border: "#2D2D44",
32
+ muted: "rgba(255,255,255,0.03)",
33
+ surface: "#23262f",
34
+ inputBorder: "#3F3F46",
35
+ inputBg: "rgba(255,255,255,0.03)",
36
+ };
37
+ function buildSnapColors(accentName, mode) {
38
+ const accent = resolveSnapPaletteHex(accentName, mode);
39
+ const accentFg = pickForegroundForBg(accent);
40
+ const neutrals = mode === "dark" ? NEUTRAL_DARK : NEUTRAL_LIGHT;
41
+ const paletteMap = mode === "dark" ? PALETTE_DARK_HEX : PALETTE_LIGHT_HEX;
42
+ const accentHover = mode === "light"
43
+ ? `color-mix(in srgb, ${accent} 82%, #000000)`
44
+ : `color-mix(in srgb, ${accent} 78%, #ffffff)`;
45
+ const outlineHover = `color-mix(in srgb, ${accent} 14%, ${neutrals.surface})`;
46
+ const paletteHex = (name) => resolveSnapPaletteHex(name, mode);
47
+ const colorHex = (name) => {
48
+ if (!name || name === "accent")
49
+ return accent;
50
+ if (Object.hasOwn(paletteMap, name))
51
+ return paletteMap[name];
52
+ return accent;
53
+ };
54
+ return {
55
+ accent,
56
+ accentFg,
57
+ accentHover,
58
+ outlineHover,
59
+ ...neutrals,
60
+ mode,
61
+ paletteHex,
62
+ colorHex,
63
+ };
64
+ }
65
+ /**
66
+ * Returns fully resolved color values for snap components.
67
+ * All colors are concrete hex values (or color-mix expressions for hover states)
68
+ * so they can be used as inline styles, independent of host app CSS.
69
+ */
70
+ export function useSnapColors() {
71
+ const { get } = useStateStore();
72
+ const { mode } = useColorMode();
73
+ const pageAccent = useSnapPreviewPageAccent();
74
+ const fromState = get("/theme/accent");
75
+ const accentRaw = (typeof pageAccent === "string" && pageAccent.length > 0
76
+ ? pageAccent
77
+ : fromState) ?? undefined;
78
+ const accentName = typeof accentRaw === "string" && accentRaw.length > 0
79
+ ? accentRaw
80
+ : "purple";
81
+ return useMemo(() => buildSnapColors(accentName, mode), [accentName, mode]);
82
+ }
@@ -174,7 +174,7 @@ export function SnapView({ snap, handlers, loading = false, appearance = "dark",
174
174
  break;
175
175
  }
176
176
  }, [handlers]);
177
- return (_jsxs("div", { style: { position: "relative", width: "100%" }, children: [showConfetti && _jsx(ConfettiOverlay, {}), loading && (_jsx("div", { style: {
177
+ return (_jsxs("div", { style: { position: "relative", width: "100%" }, children: [showConfetti && _jsx(ConfettiOverlay, {}), _jsx("div", { style: {
178
178
  position: "absolute",
179
179
  inset: 0,
180
180
  display: "flex",
@@ -182,10 +182,13 @@ export function SnapView({ snap, handlers, loading = false, appearance = "dark",
182
182
  justifyContent: "center",
183
183
  zIndex: 10,
184
184
  fontSize: 14,
185
- color: "var(--text-muted)",
186
- background: "var(--bg-primary, rgba(0,0,0,0.6))",
187
- backdropFilter: "blur(4px)",
188
- }, children: "Loading..." })), _jsx("div", { style: previewSurfaceStyle, children: _jsx(SnapPreviewAccentProvider, { pageAccent: snap.theme?.accent, children: _jsx(SnapCatalogView, { spec: spec, state: initialState, loading: false, onStateChange: (changes) => {
185
+ color: appearance === "dark" ? "rgba(255,255,255,0.5)" : "rgba(0,0,0,0.4)",
186
+ background: appearance === "dark" ? "rgba(0,0,0,0.3)" : "rgba(255,255,255,0.5)",
187
+ backdropFilter: loading ? "blur(8px)" : "blur(0px)",
188
+ opacity: loading ? 1 : 0,
189
+ pointerEvents: loading ? "auto" : "none",
190
+ transition: "opacity 0.3s ease, backdrop-filter 0.3s ease",
191
+ }, children: "Loading..." }), _jsx("div", { style: previewSurfaceStyle, children: _jsx(SnapPreviewAccentProvider, { pageAccent: snap.theme?.accent, children: _jsx(SnapCatalogView, { spec: spec, state: initialState, loading: false, onStateChange: (changes) => {
189
192
  applyStatePaths(stateRef.current, changes);
190
193
  }, onAction: handleAction }, pageKey) }) })] }));
191
194
  }