diff --git a/.changeset/rich-eagles-roll.md b/.changeset/rich-eagles-roll.md new file mode 100644 index 000000000..03646df12 --- /dev/null +++ b/.changeset/rich-eagles-roll.md @@ -0,0 +1,5 @@ +--- +'@relayprotocol/relay-kit-ui': major +--- + +Migrate styling system from Panda CSS to Tailwind CSS diff --git a/.gitignore b/.gitignore index ddc181637..a857c830a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +.claude/worktrees + # Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore # Logs @@ -179,10 +181,5 @@ dist _cjs _esm _types -## Panda -styled-system -styled-system-studio dist -design-system -!design-system/package.json **/src/version.ts \ No newline at end of file diff --git a/demo/components/CustomizeSidebar.tsx b/demo/components/CustomizeSidebar.tsx new file mode 100644 index 000000000..dc0cbb908 --- /dev/null +++ b/demo/components/CustomizeSidebar.tsx @@ -0,0 +1,734 @@ +import { FC, useCallback, useEffect, useRef, useState } from 'react' +import { useCustomize } from 'context/customizeContext' +import { useTheme } from 'next-themes' +import { ChainVM } from '@relayprotocol/relay-sdk' + +const WALLET_VM_TYPES = [ + 'evm', + 'bvm', + 'svm', + 'suivm', + 'tvm', + 'hypevm' +] as const + +type CustomizeSidebarProps = { + singleChainMode: boolean + setSingleChainMode: (value: boolean) => void + supportedWalletVMs: ChainVM[] + setSupportedWalletVMs: ( + value: ChainVM[] | ((prev: ChainVM[]) => ChainVM[]) + ) => void +} + +// --- Color manipulation utilities --- +function hexToHsl(hex: string): [number, number, number] { + const r = parseInt(hex.slice(1, 3), 16) / 255 + const g = parseInt(hex.slice(3, 5), 16) / 255 + const b = parseInt(hex.slice(5, 7), 16) / 255 + const max = Math.max(r, g, b), + min = Math.min(r, g, b) + let h = 0, + s = 0 + const l = (max + min) / 2 + if (max !== min) { + const d = max - min + s = l > 0.5 ? d / (2 - max - min) : d / (max + min) + if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6 + else if (max === g) h = ((b - r) / d + 2) / 6 + else h = ((r - g) / d + 4) / 6 + } + return [h * 360, s * 100, l * 100] +} + +function hslToHex(h: number, s: number, l: number): string { + h /= 360 + s /= 100 + l /= 100 + const hue2rgb = (p: number, q: number, t: number) => { + if (t < 0) t += 1 + if (t > 1) t -= 1 + if (t < 1 / 6) return p + (q - p) * 6 * t + if (t < 1 / 2) return q + if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6 + return p + } + let r, g, b + if (s === 0) { + r = g = b = l + } else { + const q = l < 0.5 ? l * (1 + s) : l + s - l * s + const p = 2 * l - q + r = hue2rgb(p, q, h + 1 / 3) + g = hue2rgb(p, q, h) + b = hue2rgb(p, q, h - 1 / 3) + } + const toHex = (n: number) => + Math.round(Math.min(255, Math.max(0, n * 255))) + .toString(16) + .padStart(2, '0') + return `#${toHex(r)}${toHex(g)}${toHex(b)}` +} + +/** Darken a hex color by a percentage (0-100) */ +function darken(hex: string, amount: number): string { + try { + const [h, s, l] = hexToHsl(hex) + return hslToHex(h, s, Math.max(0, l - amount)) + } catch { + return hex + } +} + +/** Lighten a hex color by a percentage (0-100) */ +function lighten(hex: string, amount: number): string { + try { + const [h, s, l] = hexToHsl(hex) + return hslToHex(h, s, Math.min(100, l + amount)) + } catch { + return hex + } +} + +// Default colors that approximate the actual CSS variable values +const LIGHT_DEFAULTS: Record = { + primaryColor: '#7c65c1', + focusColor: '#9b8ce0', + subtleBackgroundColor: '#fcfcfc', + subtleBorderColor: '#e6e6e6', + 'text.default': '#1a1a1a', + 'text.subtle': '#6f6f6f', + 'widget.background': '#ffffff', + 'widget.card.background': '#fcfcfc', + 'widget.selector.background': '#f5f5f5', + 'buttons.primary.background': '#7c65c1', + 'buttons.primary.color': '#ffffff', + 'buttons.secondary.background': '#ede9f6', + 'buttons.secondary.color': '#4615c8', + 'input.background': '#f0f0f0', + 'modal.background': '#fcfcfc', + 'anchor.color': '#4615c8' +} + +const DARK_DEFAULTS: Record = { + primaryColor: '#9b8ce0', + focusColor: '#7c65c1', + subtleBackgroundColor: '#111111', + subtleBorderColor: '#333333', + 'text.default': '#ececec', + 'text.subtle': '#a0a0a0', + 'widget.background': '#1a1a1a', + 'widget.card.background': '#111111', + 'widget.selector.background': '#222222', + 'buttons.primary.background': '#9b8ce0', + 'buttons.primary.color': '#ffffff', + 'buttons.secondary.background': '#2a2440', + 'buttons.secondary.color': '#c4b8f3', + 'input.background': '#2a2a2a', + 'modal.background': '#111111', + 'anchor.color': '#c4b8f3' +} + +// Debounced color input — renders immediately but debounces context updates +const ColorInput: FC<{ + label: string + value: string + defaultValue: string + onChange: (value: string) => void + onClear: () => void +}> = ({ label, value, defaultValue, onChange, onClear }) => { + const [localValue, setLocalValue] = useState(value || defaultValue) + const timerRef = useRef | null>(null) + const isOverridden = !!value + + // Sync from external changes (e.g. reset button) + useEffect(() => { + setLocalValue(value || defaultValue) + }, [value, defaultValue]) + + const handleChange = useCallback( + (newValue: string) => { + setLocalValue(newValue) + if (timerRef.current) clearTimeout(timerRef.current) + timerRef.current = setTimeout(() => { + onChange(newValue) + }, 150) + }, + [onChange] + ) + + useEffect(() => { + return () => { + if (timerRef.current) clearTimeout(timerRef.current) + } + }, []) + + return ( +
+ handleChange(e.target.value)} + style={{ + width: 28, + height: 28, + border: 'none', + borderRadius: 6, + cursor: 'pointer', + padding: 0, + background: 'transparent' + }} + /> + {label} + {isOverridden && ( + + )} +
+ ) +} + +const SectionTitle: FC<{ children: React.ReactNode }> = ({ children }) => ( +
+ {children} +
+) + +const SubLabel: FC<{ children: React.ReactNode }> = ({ children }) => ( +
+ {children} +
+) + +export const CustomizeSidebar: FC = ({ + singleChainMode, + setSingleChainMode, + supportedWalletVMs, + setSupportedWalletVMs +}) => { + const [open, setOpen] = useState(false) + const { resolvedTheme } = useTheme() + const isDark = resolvedTheme === 'dark' + const defaults = isDark ? DARK_DEFAULTS : LIGHT_DEFAULTS + const { + themeOverrides, + updateThemeValue, + setThemeOverrides + } = useCustomize() + + const getNestedValue = (path: string): string => { + const keys = path.split('.') + let obj: any = themeOverrides + for (const key of keys) { + if (!obj || typeof obj !== 'object') return '' + obj = obj[key] + } + return typeof obj === 'string' ? obj : '' + } + + // Auto-derive hover state from a base color + const deriveHover = useCallback( + (baseHex: string) => { + return isDark ? lighten(baseHex, 8) : darken(baseHex, 8) + }, + [isDark] + ) + + // When setting a base color, also auto-set its hover variant + const setWithHover = useCallback( + (basePath: string, hoverPath: string, value: string) => { + updateThemeValue(basePath, value) + updateThemeValue(hoverPath, deriveHover(value)) + }, + [updateThemeValue, deriveHover] + ) + + const clearWithHover = useCallback( + (basePath: string, hoverPath: string) => { + updateThemeValue(basePath, undefined) + updateThemeValue(hoverPath, undefined) + }, + [updateThemeValue] + ) + + const sidebarBg = isDark ? '#111' : '#fff' + const sidebarBorder = isDark + ? 'rgba(255,255,255,0.1)' + : 'rgba(0,0,0,0.08)' + const sidebarText = isDark ? '#e5e5e5' : '#1a1a1a' + + return ( + <> + +
+ {/* Toggle button */} + + + {/* Sidebar panel */} +
+ {/* Header */} +
+ Customize + +
+ + {/* Theme Colors */} +
+ Colors + + updateThemeValue('primaryColor', v)} + onClear={() => updateThemeValue('primaryColor', undefined)} + /> + + updateThemeValue('focusColor', v)} + onClear={() => updateThemeValue('focusColor', undefined)} + /> + + Text + + updateThemeValue('text.default', v)} + onClear={() => updateThemeValue('text.default', undefined)} + /> + + updateThemeValue('text.subtle', v)} + onClear={() => updateThemeValue('text.subtle', undefined)} + /> + + { + updateThemeValue('anchor.color', v) + updateThemeValue('anchor.hover.color', deriveHover(v)) + }} + onClear={() => { + updateThemeValue('anchor.color', undefined) + updateThemeValue('anchor.hover.color', undefined) + }} + /> +
+ + {/* Buttons */} +
+ Buttons + + Primary (hover auto-derived) + + + setWithHover( + 'buttons.primary.background', + 'buttons.primary.hover.background', + v + ) + } + onClear={() => + clearWithHover( + 'buttons.primary.background', + 'buttons.primary.hover.background' + ) + } + /> + + { + updateThemeValue('buttons.primary.color', v) + updateThemeValue('buttons.primary.hover.color', v) + }} + onClear={() => { + updateThemeValue('buttons.primary.color', undefined) + updateThemeValue('buttons.primary.hover.color', undefined) + }} + /> + + Secondary (hover auto-derived) + + + setWithHover( + 'buttons.secondary.background', + 'buttons.secondary.hover.background', + v + ) + } + onClear={() => + clearWithHover( + 'buttons.secondary.background', + 'buttons.secondary.hover.background' + ) + } + /> + + { + updateThemeValue('buttons.secondary.color', v) + updateThemeValue('buttons.secondary.hover.color', v) + }} + onClear={() => { + updateThemeValue('buttons.secondary.color', undefined) + updateThemeValue('buttons.secondary.hover.color', undefined) + }} + /> + + {/* CTA italic toggle */} +
+ + updateThemeValue( + 'buttons.cta.fontStyle', + e.target.checked ? 'italic' : 'normal' + ) + } + /> + +
+
+ + {/* Surfaces */} +
+ Surfaces + + updateThemeValue('widget.background', v)} + onClear={() => updateThemeValue('widget.background', undefined)} + /> + + updateThemeValue('widget.card.background', v)} + onClear={() => + updateThemeValue('widget.card.background', undefined) + } + /> + + + setWithHover( + 'widget.selector.background', + 'widget.selector.hover.background', + v + ) + } + onClear={() => + clearWithHover( + 'widget.selector.background', + 'widget.selector.hover.background' + ) + } + /> + + updateThemeValue('input.background', v)} + onClear={() => updateThemeValue('input.background', undefined)} + /> + + updateThemeValue('modal.background', v)} + onClear={() => updateThemeValue('modal.background', undefined)} + /> + + updateThemeValue('subtleBackgroundColor', v)} + onClear={() => + updateThemeValue('subtleBackgroundColor', undefined) + } + /> + + updateThemeValue('subtleBorderColor', v)} + onClear={() => updateThemeValue('subtleBorderColor', undefined)} + /> + + {/* Border radius */} +
+ Border Radius + { + const v = parseInt(getNestedValue('widget.card.borderRadius')) + return isNaN(v) ? 12 : v + })()} + onChange={(e) => { + const val = `${e.target.value}px` + updateThemeValue('widget.borderRadius', val) + updateThemeValue('widget.card.borderRadius', val) + updateThemeValue('modal.borderRadius', val) + updateThemeValue('input.borderRadius', val) + updateThemeValue('dropdown.borderRadius', val) + updateThemeValue('widget.swap.currency.button.borderRadius', val) + updateThemeValue('buttons.borderRadius', val) + }} + style={{ width: 80 }} + /> + + {(() => { + const v = parseInt(getNestedValue('widget.card.borderRadius')) + return isNaN(v) ? 12 : v + })()}px + +
+ + {/* Reset all button */} + +
+ + {/* Widget Config */} +
+ Config + +
+ setSingleChainMode(e.target.checked)} + /> + +
+ +
+ Wallet VMs +
+
+ {WALLET_VM_TYPES.map((vm) => ( +
+ { + if (e.target.checked) { + setSupportedWalletVMs((prev) => [...prev, vm]) + } else { + setSupportedWalletVMs((prev) => + prev.filter((item) => item !== vm) + ) + } + }} + /> + +
+ ))} +
+
+ + {/* Global / Provider Settings */} +
+ Global + +
+
+
+ + ) +} diff --git a/demo/components/providers/RelayKitProviderWrapper.tsx b/demo/components/providers/RelayKitProviderWrapper.tsx index 1347fca07..586a7e793 100644 --- a/demo/components/providers/RelayKitProviderWrapper.tsx +++ b/demo/components/providers/RelayKitProviderWrapper.tsx @@ -6,7 +6,8 @@ import { import { RelayKitProvider } from '@relayprotocol/relay-kit-ui' import { useTheme } from 'next-themes' import { useRouter } from 'next/router' -import { FC, ReactNode, useEffect, useState } from 'react' +import { FC, ReactNode, useMemo } from 'react' +import { useCustomize } from 'context/customizeContext' const DEFAULT_APP_FEES = [ { @@ -15,6 +16,11 @@ const DEFAULT_APP_FEES = [ } ] +const BASE_THEME = { + font: 'var(--font-inter), -apple-system, Helvetica, sans-serif', + fontHeading: 'Chivo, -apple-system, Helvetica, sans-serif' +} + export const RelayKitProviderWrapper: FC<{ relayApi?: string dynamicChains: RelayChain[] @@ -22,16 +28,16 @@ export const RelayKitProviderWrapper: FC<{ }> = ({ relayApi, dynamicChains, children }) => { const { theme } = useTheme() const router = useRouter() - const [websocketsEnabled, setWebsocketsEnabled] = useState(false) + const { themeOverrides, websocketsEnabled } = useCustomize() const appFeesEnabled = router.query.appFees === 'true' - // Handle websocket configuration from query params - useEffect(() => { - const websocketParam = router.query.websockets as string - if (websocketParam !== undefined) { - setWebsocketsEnabled(websocketParam === 'true') - } - }, [router.query.websockets]) + const mergedTheme = useMemo( + () => ({ + ...BASE_THEME, + ...themeOverrides + }), + [themeOverrides] + ) return ( {children} diff --git a/demo/context/customizeContext.tsx b/demo/context/customizeContext.tsx new file mode 100644 index 000000000..e31a34d68 --- /dev/null +++ b/demo/context/customizeContext.tsx @@ -0,0 +1,91 @@ +import { + createContext, + FC, + ReactNode, + useContext, + useState, + useCallback, + useMemo +} from 'react' +import type { RelayKitTheme } from '@relayprotocol/relay-kit-ui' +import { MAINNET_RELAY_API, TESTNET_RELAY_API } from '@relayprotocol/relay-sdk' + +type CustomizeState = { + themeOverrides: Partial + setThemeOverrides: (overrides: Partial) => void + updateThemeValue: (path: string, value: string | undefined) => void + relayApi: string + setRelayApi: (api: string) => void + websocketsEnabled: boolean + setWebsocketsEnabled: (enabled: boolean) => void +} + +const CustomizeContext = createContext({ + themeOverrides: {}, + setThemeOverrides: () => {}, + updateThemeValue: () => {}, + relayApi: MAINNET_RELAY_API, + setRelayApi: () => {}, + websocketsEnabled: false, + setWebsocketsEnabled: () => {} +}) + +export const useCustomize = () => useContext(CustomizeContext) + +export const CustomizeProvider: FC<{ children: ReactNode }> = ({ + children +}) => { + const [themeOverrides, setThemeOverrides] = useState< + Partial + >({ + buttons: { + cta: { + fontStyle: 'italic' + } + } + }) + const [relayApi, setRelayApi] = useState(MAINNET_RELAY_API) + const [websocketsEnabled, setWebsocketsEnabled] = useState(false) + + const updateThemeValue = useCallback( + (path: string, value: string | undefined) => { + setThemeOverrides((prev) => { + const next = { ...prev } + const keys = path.split('.') + let obj: any = next + for (let i = 0; i < keys.length - 1; i++) { + if (!obj[keys[i]]) obj[keys[i]] = {} + else obj[keys[i]] = { ...obj[keys[i]] } + obj = obj[keys[i]] + } + const lastKey = keys[keys.length - 1] + if (value === undefined || value === '') { + delete obj[lastKey] + } else { + obj[lastKey] = value + } + return next + }) + }, + [] + ) + + const value = useMemo( + () => ({ + themeOverrides, + setThemeOverrides, + updateThemeValue, + relayApi, + setRelayApi, + websocketsEnabled, + setWebsocketsEnabled + }), + [themeOverrides, updateThemeValue, relayApi, websocketsEnabled] + ) + + return ( + + {children} + + ) +} diff --git a/demo/pages/_app.tsx b/demo/pages/_app.tsx index 11f97c831..3a0d205fc 100644 --- a/demo/pages/_app.tsx +++ b/demo/pages/_app.tsx @@ -3,7 +3,7 @@ import '../fonts.css' import '../global.css' import type { AppProps, AppContext } from 'next/app' -import React, { ReactNode, FC, useState, useEffect } from 'react' +import React, { ReactNode, FC, useEffect } from 'react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { createConfig, http, WagmiProvider } from 'wagmi' import { Chain, mainnet, optimism, base, zora } from 'wagmi/chains' @@ -33,9 +33,10 @@ import { EclipseWalletConnectors } from '@dynamic-labs/eclipse' import { TronWalletConnectors } from '@dynamic-labs/tron' import { AbstractEvmWalletConnectors } from '@dynamic-labs-connectors/abstract-global-wallet-evm' import { MoonPayProvider } from 'context/MoonpayProvider' +import { CustomizeProvider, useCustomize } from 'context/customizeContext' import { queryRelayChains } from '@relayprotocol/relay-kit-hooks' import { RelayKitProviderWrapper } from 'components/providers/RelayKitProviderWrapper' -import { Barlow, Chivo } from 'next/font/google' +import { Barlow, Chivo, Inter } from 'next/font/google' import { Porto } from 'porto' Porto.create() @@ -55,6 +56,13 @@ export const barlow = Barlow({ variable: '--font-barlow' }) +export const inter = Inter({ + weight: ['400', '500', '600', '700'], + display: 'swap', + subsets: ['latin'], + variable: '--font-inter' +}) + type AppWrapperProps = { children: ReactNode dynamicChains: RelayChain[] @@ -66,14 +74,14 @@ const queryClient = new QueryClient() const AppWrapper: FC = ({ children, dynamicChains }) => { const { walletFilter, setWalletFilter } = useWalletFilter() + const { relayApi, setRelayApi } = useCustomize() const router = useRouter() - const [relayApi, setRelayApi] = useState(MAINNET_RELAY_API) useEffect(() => { const isTestnet = router.query.api === 'testnets' const newApi = isTestnet ? TESTNET_RELAY_API : MAINNET_RELAY_API if (relayApi !== newApi) { - setRelayApi(isTestnet ? TESTNET_RELAY_API : MAINNET_RELAY_API) + setRelayApi(newApi) } }, [router.query.api]) @@ -114,6 +122,7 @@ const AppWrapper: FC = ({ children, dynamicChains }) => { :root { --font-chivo: ${chivo.style.fontFamily}; --font-barlow: ${barlow.style.fontFamily}; + --font-inter: ${inter.style.fontFamily}; } ` @@ -122,7 +131,12 @@ const AppWrapper: FC = ({ children, dynamicChains }) => { }, []) return ( -
+
- - - - - + + + + + + + ) } diff --git a/demo/pages/index.tsx b/demo/pages/index.tsx index f6e50650e..ccb6e7068 100644 --- a/demo/pages/index.tsx +++ b/demo/pages/index.tsx @@ -21,7 +21,6 @@ const Index: NextPage = () => {