diff --git a/examples/with-react-ui/src/App.tsx b/examples/with-react-ui/src/App.tsx index a30a0f79..b2f05db5 100644 --- a/examples/with-react-ui/src/App.tsx +++ b/examples/with-react-ui/src/App.tsx @@ -9,7 +9,7 @@ const config: PhantomSDKConfig = { function App() { return ( - +
+ + + +
+

📱 Mobile Testing

+

+ On mobile devices, you'll see an additional "Open in Phantom App" button that will redirect to the Phantom + mobile app via phantom.app/ul +

+
); diff --git a/packages/browser-sdk/src/types.ts b/packages/browser-sdk/src/types.ts index 90eef107..4fd1c8a2 100644 --- a/packages/browser-sdk/src/types.ts +++ b/packages/browser-sdk/src/types.ts @@ -49,13 +49,15 @@ interface ExtendedInjectedProviderConfig extends InjectedProviderConfig { embeddedWalletType?: never; } +type AuthProviderType = EmbeddedProviderAuthType | "injected"; + type AuthOptions = { - provider: EmbeddedProviderAuthType | "injected"; + provider: AuthProviderType; customAuthData?: Record; }; type ConnectResult = Omit & { - authProvider?: EmbeddedProviderAuthType | "injected" | undefined; + authProvider?: AuthProviderType | undefined; }; // Re-export types from core for convenience @@ -67,6 +69,7 @@ export type { SignMessageResult, SignedTransaction, AuthOptions, + AuthProviderType, DebugCallback, DebugLevel, }; diff --git a/packages/react-sdk/src/index.ts b/packages/react-sdk/src/index.ts index 0675ba7a..8c63cb37 100644 --- a/packages/react-sdk/src/index.ts +++ b/packages/react-sdk/src/index.ts @@ -10,14 +10,7 @@ export * from "./types"; // Re-export useful types and utilities from browser-sdk export { NetworkId, AddressType, DebugLevel, debug } from "@phantom/browser-sdk"; -export type { - DebugMessage, - AutoConfirmEnableParams, - AutoConfirmResult, - AutoConfirmSupportedChainsResult, -} from "@phantom/browser-sdk"; -// Re-export event types for typed event handlers export type { EmbeddedProviderEvent, ConnectEventData, @@ -26,6 +19,11 @@ export type { DisconnectEventData, EmbeddedProviderEventMap, EventCallback, + DebugMessage, + AutoConfirmEnableParams, + AutoConfirmResult, + AutoConfirmSupportedChainsResult, + AuthOptions, } from "@phantom/browser-sdk"; // Re-export chain interfaces diff --git a/packages/react-ui/README.md b/packages/react-ui/README.md index e14aa879..c9d8660b 100644 --- a/packages/react-ui/README.md +++ b/packages/react-ui/README.md @@ -160,57 +160,62 @@ function ConnectButton() { ## Theming -Customize the modal appearance using CSS variables: - -```css -:root { - /* Modal */ - --phantom-ui-modal-bg: #ffffff; - --phantom-ui-modal-overlay: rgba(0, 0, 0, 0.5); - --phantom-ui-modal-border-radius: 12px; - - /* Buttons */ - --phantom-ui-button-bg: #ab9ff2; - --phantom-ui-button-hover-bg: #9c8dff; - --phantom-ui-button-text: #ffffff; - --phantom-ui-button-border-radius: 8px; - - /* Text */ - --phantom-ui-text-primary: #212529; - --phantom-ui-text-secondary: #6c757d; - - /* Spacing */ - --phantom-ui-spacing-sm: 8px; - --phantom-ui-spacing-md: 16px; - --phantom-ui-spacing-lg: 24px; -} -``` +Customize the modal appearance by passing a theme object to the `PhantomProvider`. The package includes two built-in themes: `darkTheme` (default) and `lightTheme`. -### Dark Theme +### Using Built-in Themes -```css -[data-theme="dark"] { - --phantom-ui-modal-bg: #2d2d2d; - --phantom-ui-text-primary: #ffffff; - --phantom-ui-text-secondary: #b3b3b3; -} -``` +```tsx +import { PhantomProvider, darkTheme, lightTheme } from "@phantom/react-ui"; -Apply themes via the `theme` prop or CSS: +// Use dark theme (default) + + + -```tsx - +// Use light theme + +``` + +### Custom Theme -// Or via CSS -
- - - -
+You can pass a partial theme object to customize specific properties: + +```tsx +import { PhantomProvider } from "@phantom/react-ui"; + +const customTheme = { + background: "#1a1a1a", + text: "#ffffff", + secondary: "#98979C", + brand: "#ab9ff2", + error: "#ff4444", + success: "#00ff00", + borderRadius: "16px", + overlay: "rgba(0, 0, 0, 0.8)", +}; + + + +; ``` +### Theme Properties + +| Property | Type | Description | +| -------------- | -------- | --------------------------------------------------------- | +| `background` | `string` | Background color for modal | +| `text` | `string` | Primary text color | +| `secondary` | `string` | Secondary color for text, borders, dividers (must be hex) | +| `brand` | `string` | Brand/primary action color | +| `error` | `string` | Error state color | +| `success` | `string` | Success state color | +| `borderRadius` | `string` | Border radius for buttons and modal | +| `overlay` | `string` | Overlay background color (with opacity) | + +**Note:** The `secondary` color must be a hex color value (e.g., `#98979C`) as it's used to derive auxiliary colors with opacity. + ## Migration from @phantom/react-sdk Migration is simple - just add the UI provider and import `useConnect` from `@phantom/react-ui`: diff --git a/packages/react-ui/src/PhantomProvider.tsx b/packages/react-ui/src/PhantomProvider.tsx index 44012131..2775291f 100644 --- a/packages/react-ui/src/PhantomProvider.tsx +++ b/packages/react-ui/src/PhantomProvider.tsx @@ -1,52 +1,36 @@ -import React, { createContext, useContext, useState, useCallback, useMemo, type ReactNode } from "react"; +import React, { useState, useCallback, useMemo, type ReactNode } from "react"; import { useConnect as useBaseConnect, usePhantom, PhantomProvider as BasePhantomProvider, - useIsExtensionInstalled, - useIsPhantomLoginAvailable, type PhantomSDKConfig, } from "@phantom/react-sdk"; -import { isMobileDevice, getDeeplinkToPhantom } from "@phantom/browser-sdk"; +import { isMobileDevice, getDeeplinkToPhantom, type AuthProviderType } from "@phantom/browser-sdk"; +import { darkTheme, mergeTheme, type PhantomTheme } from "./themes"; +import { Modal } from "./components/Modal"; +import { PhantomUIContext, type PhantomUIContextValue, type ConnectionUIState } from "./context"; export interface PhantomUIProviderProps { children: ReactNode; - theme?: "light" | "dark" | "auto"; - customTheme?: Record; + theme?: Partial; config: PhantomSDKConfig; + appIcon?: string; // URL to app icon + appName?: string; // App name to display } -// Connection UI state -interface ConnectionUIState { - isVisible: boolean; - isConnecting: boolean; - error: Error | null; - providerType: "injected" | "embedded" | "deeplink" | null; -} - -interface PhantomUIContextValue { - // Connection state - connectionState: ConnectionUIState; - showConnectionModal: () => void; - hideConnectionModal: () => void; - connectWithAuthProvider: (provider: "google" | "apple" | "phantom") => Promise; - connectWithInjected: () => Promise; - connectWithDeeplink: () => void; - isMobile: boolean; -} - -const PhantomUIContext = createContext(null); - // Internal UI Provider that consumes react-sdk context -function PhantomUIProvider({ children, theme = "light", customTheme }: Omit) { +function PhantomUIProvider({ children, theme = darkTheme, appIcon, appName }: Omit) { const baseConnect = useBaseConnect(); const { sdk } = usePhantom(); - const isExtensionInstalled = useIsExtensionInstalled(); - const isPhantomLoginAvailable = useIsPhantomLoginAvailable(); // Check if this is a mobile device const isMobile = useMemo(() => isMobileDevice(), []); + // Get the resolved theme object + const resolvedTheme = useMemo(() => { + return mergeTheme(theme); + }, [theme]); + // Connection state const [connectionState, setConnectionState] = useState({ isVisible: false, @@ -76,13 +60,13 @@ function PhantomUIProvider({ children, theme = "light", customTheme }: Omit { + async (provider: AuthProviderType) => { try { setConnectionState(prev => ({ ...prev, isConnecting: true, error: null, - providerType: "embedded", // Always embedded when using modal + providerType: provider, })); await baseConnect.connect({ provider }); @@ -186,109 +170,24 @@ function PhantomUIProvider({ children, theme = "light", customTheme }: Omit {children} - {/* Connection Modal - rendered conditionally based on state */} - {connectionState.isVisible && ( -
-
e.stopPropagation()}> -
-

Connect to Phantom

- -
- -
- {connectionState.error &&
{connectionState.error.message}
} - -
- {/* Mobile device with no Phantom extension - show deeplink button */} - {isMobile && !isExtensionInstalled.isInstalled && ( - - )} - - {/* Primary auth options - Phantom, Google */} - {!isMobile && ( - <> - {/* Login with Phantom (embedded provider using Phantom extension) */} - {isPhantomLoginAvailable.isAvailable && ( - - )} - - {/* Continue with Google */} - - - )} - - {/* Extension option - smaller UI section */} - {!isMobile && isExtensionInstalled.isInstalled && ( -
-
- or -
- -
- )} -
-
-
-
- )} + ); } // Main exported Provider that wraps both react-sdk and react-ui providers -export function PhantomProvider({ children, theme = "light", customTheme, config }: PhantomUIProviderProps) { +export function PhantomProvider({ children, theme = darkTheme, config, appIcon, appName }: PhantomUIProviderProps) { return ( - + {children} ); } - -export function usePhantomUI(): PhantomUIContextValue { - const context = useContext(PhantomUIContext); - if (!context) { - throw new Error("usePhantomUI must be used within a PhantomProvider"); - } - return context; -} diff --git a/packages/react-ui/src/components/Button.tsx b/packages/react-ui/src/components/Button.tsx new file mode 100644 index 00000000..17ad3e4a --- /dev/null +++ b/packages/react-ui/src/components/Button.tsx @@ -0,0 +1,178 @@ +import React, { useState, useMemo, type CSSProperties, type ReactNode } from "react"; +import { hexToRgba } from "../utils"; +import { useTheme } from "../hooks/useTheme"; + +interface BaseButtonStyleOptions { + fullWidth: boolean; + disabled: boolean; + fontSize?: CSSProperties["fontSize"]; + fontWeight?: CSSProperties["fontWeight"]; + justifyContent?: CSSProperties["justifyContent"]; +} + +// Custom hook for base button styles +const useBaseButtonStyle = ({ + fullWidth, + disabled, + fontSize, + fontWeight, + justifyContent = "center", +}: BaseButtonStyleOptions): CSSProperties => { + const theme = useTheme(); + + return { + width: fullWidth ? "100%" : "auto", + padding: "12px 16px", + border: "none", + borderRadius: theme.borderRadius, + fontFamily: theme.typography.captionBold.fontFamily, + fontSize: fontSize ?? theme.typography.captionBold.fontSize, + fontStyle: theme.typography.captionBold.fontStyle, + fontWeight: fontWeight ?? theme.typography.captionBold.fontWeight, + lineHeight: theme.typography.captionBold.lineHeight, + letterSpacing: theme.typography.captionBold.letterSpacing, + cursor: disabled ? "not-allowed" : "pointer", + transition: "background-color 0.2s", + display: "flex", + alignItems: "center", + justifyContent, + gap: "8px", + opacity: disabled ? 0.6 : 1, + }; +}; + +// Button component +export interface ButtonProps { + children: ReactNode; + onClick: () => void; + disabled?: boolean; + variant?: "primary" | "secondary"; + fullWidth?: boolean; + isLoading?: boolean; +} + +export function Button({ + children, + onClick, + disabled = false, + variant = "primary", + fullWidth = true, + isLoading = false, +}: ButtonProps) { + const theme = useTheme(); + const [isHovering, setIsHovering] = useState(false); + const isInteractive = !disabled && !isLoading; + + const baseStyle = useBaseButtonStyle({ + fullWidth, + disabled: disabled || isLoading, + justifyContent: variant === "primary" ? "center" : "space-between", + }); + + const backgroundColor = useMemo(() => { + if (!isInteractive) { + return variant === "primary" ? theme.aux : "transparent"; + } + + if (isHovering) { + return variant === "primary" ? hexToRgba(theme.secondary, 0.15) : hexToRgba(theme.secondary, 0.1); + } + + return variant === "primary" ? theme.aux : "transparent"; + }, [isInteractive, isHovering, variant, theme.aux, theme.secondary]); + + const buttonStyle: CSSProperties = { + ...baseStyle, + backgroundColor, + color: theme.text, + border: variant === "secondary" ? `1px solid ${theme.secondary}` : "none", + }; + + const handleMouseEnter = () => { + if (isInteractive) { + setIsHovering(true); + } + }; + + const handleMouseLeave = () => { + setIsHovering(false); + }; + + return ( + + ); +} + +// LoginWithPhantomButton component +export interface LoginWithPhantomButtonProps { + children?: ReactNode; + onClick: () => void; + disabled?: boolean; + fullWidth?: boolean; + isLoading?: boolean; +} + +export function LoginWithPhantomButton({ + children = "Login with Phantom", + onClick, + disabled = false, + fullWidth = true, + isLoading = false, +}: LoginWithPhantomButtonProps) { + const theme = useTheme(); + const [isHovering, setIsHovering] = useState(false); + const isInteractive = !disabled && !isLoading; + + const baseStyle = useBaseButtonStyle({ + fullWidth, + disabled: disabled || isLoading, + }); + + const backgroundColor = useMemo(() => { + if (!isInteractive) { + return theme.brand; + } + + if (isHovering) { + return hexToRgba(theme.brand, 0.85); + } + + return theme.brand; + }, [isInteractive, isHovering, theme.brand]); + + const buttonStyle: CSSProperties = { + ...baseStyle, + backgroundColor, + color: theme.text, + }; + + const handleMouseEnter = () => { + if (isInteractive) { + setIsHovering(true); + } + }; + + const handleMouseLeave = () => { + setIsHovering(false); + }; + + return ( + + ); +} diff --git a/packages/react-ui/src/components/Modal.tsx b/packages/react-ui/src/components/Modal.tsx new file mode 100644 index 00000000..72604be7 --- /dev/null +++ b/packages/react-ui/src/components/Modal.tsx @@ -0,0 +1,230 @@ +import React, { useState, type CSSProperties } from "react"; +import { useIsExtensionInstalled, useIsPhantomLoginAvailable } from "@phantom/react-sdk"; +import { Button, LoginWithPhantomButton } from "./Button"; +import { useTheme } from "../hooks/useTheme"; +import { usePhantomUI } from "../hooks/usePhantomUI"; + +export interface ModalProps { + appIcon?: string; + appName?: string; +} + +export function Modal({ appIcon, appName }: ModalProps) { + const theme = useTheme(); + const { + connectionState: { isVisible, isConnecting, error, providerType }, + hideConnectionModal, + connectWithAuthProvider, + connectWithInjected, + connectWithDeeplink, + isMobile, + } = usePhantomUI(); + const isExtensionInstalled = useIsExtensionInstalled(); + const isPhantomLoginAvailable = useIsPhantomLoginAvailable(); + const [isCloseButtonHovering, setIsCloseButtonHovering] = useState(false); + + if (!isVisible) return null; + + // Styles + const overlayStyle: CSSProperties = { + position: "fixed", + top: 0, + left: 0, + right: 0, + bottom: 0, + backgroundColor: theme.overlay, + display: "flex", + alignItems: "center", + justifyContent: "center", + zIndex: 9999, + }; + + const modalStyle: CSSProperties = { + backgroundColor: theme.background, + borderRadius: "16px", + padding: "24px", + maxWidth: "400px", + width: "100%", + boxShadow: "0 8px 32px rgba(0, 0, 0, 0.3)", + position: "relative" as const, + }; + + const headerStyle: CSSProperties = { + position: "relative", + display: "flex", + justifyContent: "center", + alignItems: "center", + marginBottom: "24px", + }; + + const titleStyle: CSSProperties = { + margin: 0, + ...theme.typography.caption, + color: theme.secondary, + fontFeatureSettings: '"liga" off, "clig" off', + textAlign: "center" as const, + }; + + const closeButtonStyle: CSSProperties = { + position: "absolute" as const, + right: 0, + top: "50%", + transform: "translateY(-50%)", + background: "none", + border: "none", + color: isCloseButtonHovering ? theme.secondary : theme.text, + fontSize: "24px", + cursor: "pointer", + padding: "4px 8px", + lineHeight: 1, + transition: "color 0.2s", + }; + + const appIconStyle: CSSProperties = appIcon + ? { + width: "56px", + height: "56px", + borderRadius: "50%", + display: "block", + margin: "0 auto 24px", + objectFit: "cover" as const, + } + : {}; + + const buttonContainerStyle: CSSProperties = { + display: "flex", + flexDirection: "column" as const, + gap: "12px", + }; + + const dividerStyle: CSSProperties = { + display: "flex", + alignItems: "center", + margin: "24px 0", + ...theme.typography.caption, + color: theme.secondary, + textTransform: "uppercase" as const, + }; + + const dividerLineStyle: CSSProperties = { + flex: 1, + height: "1px", + backgroundColor: theme.secondary, + }; + + const dividerTextStyle: CSSProperties = { + padding: "0 12px", + }; + + const footerStyle: CSSProperties = { + marginTop: "24px", + textAlign: "center" as const, + ...theme.typography.label, + color: theme.secondary, + }; + + const errorStyle: CSSProperties = { + backgroundColor: "rgba(220, 53, 69, 0.1)", + color: "#ff6b6b", + border: "1px solid rgba(220, 53, 69, 0.3)", + borderRadius: "8px", + padding: "12px", + marginBottom: "12px", + fontSize: "14px", + }; + + return ( +
+
e.stopPropagation()}> + {/* Header */} +
+

Login or Sign Up

+ +
+ + {/* App Icon */} + {appIcon && {appName} + + {/* Body */} +
+ {/* Error Message */} + {error &&
{error.message}
} + + {/* Provider Options */} +
+ {/* Mobile device with no Phantom extension - show deeplink button */} + {isMobile && !isExtensionInstalled.isInstalled && ( + + )} + + {!isMobile && ( + <> + {isPhantomLoginAvailable.isAvailable && ( + connectWithAuthProvider("phantom")} + disabled={isConnecting} + isLoading={isConnecting && providerType === "phantom"} + /> + )} + + )} + + + + + + {!isMobile && isExtensionInstalled.isInstalled && ( + <> +
+
+ OR +
+
+ + + + )} +
+
+ + {/* Footer */} +
Protected by Phantom
+
+
+ ); +} diff --git a/packages/react-ui/src/context.ts b/packages/react-ui/src/context.ts new file mode 100644 index 00000000..4bd0101c --- /dev/null +++ b/packages/react-ui/src/context.ts @@ -0,0 +1,26 @@ +import { createContext } from "react"; +import type { AuthProviderType } from "@phantom/browser-sdk"; +import type { CompletePhantomTheme } from "./themes"; + +// Connection UI state +export interface ConnectionUIState { + isVisible: boolean; + isConnecting: boolean; + error: Error | null; + providerType: AuthProviderType | "deeplink" | null; +} + +export interface PhantomUIContextValue { + // Connection state + connectionState: ConnectionUIState; + showConnectionModal: () => void; + hideConnectionModal: () => void; + connectWithAuthProvider: (provider: AuthProviderType) => Promise; + connectWithInjected: () => Promise; + connectWithDeeplink: () => void; + isMobile: boolean; + // Theme + theme: CompletePhantomTheme; +} + +export const PhantomUIContext = createContext(null); diff --git a/packages/react-ui/src/hooks/index.ts b/packages/react-ui/src/hooks/index.ts index 840f7bdf..c7f1c267 100644 --- a/packages/react-ui/src/hooks/index.ts +++ b/packages/react-ui/src/hooks/index.ts @@ -1,2 +1,3 @@ // UI-enhanced hooks - only useConnect has modal functionality export * from "./useConnect"; +export * from "./usePhantomUI"; diff --git a/packages/react-ui/src/hooks/useConnect.ts b/packages/react-ui/src/hooks/useConnect.ts index d51dab7d..a04108da 100644 --- a/packages/react-ui/src/hooks/useConnect.ts +++ b/packages/react-ui/src/hooks/useConnect.ts @@ -1,6 +1,6 @@ import { useCallback } from "react"; // import { useConnect as useBaseConnect } from "@phantom/react-sdk"; -import { usePhantomUI } from "../PhantomProvider"; +import { usePhantomUI } from "./usePhantomUI"; export interface UseConnectResult { connect: () => void; diff --git a/packages/react-ui/src/hooks/usePhantomUI.ts b/packages/react-ui/src/hooks/usePhantomUI.ts new file mode 100644 index 00000000..7a6d1e0b --- /dev/null +++ b/packages/react-ui/src/hooks/usePhantomUI.ts @@ -0,0 +1,10 @@ +import { useContext } from "react"; +import { PhantomUIContext, type PhantomUIContextValue } from "../context"; + +export function usePhantomUI(): PhantomUIContextValue { + const context = useContext(PhantomUIContext); + if (!context) { + throw new Error("usePhantomUI must be used within a PhantomProvider"); + } + return context; +} diff --git a/packages/react-ui/src/hooks/useTheme.ts b/packages/react-ui/src/hooks/useTheme.ts new file mode 100644 index 00000000..e5a4fccf --- /dev/null +++ b/packages/react-ui/src/hooks/useTheme.ts @@ -0,0 +1,11 @@ +import { useContext } from "react"; +import { PhantomUIContext } from "../context"; +import type { CompletePhantomTheme } from "../themes"; + +export function useTheme(): CompletePhantomTheme { + const context = useContext(PhantomUIContext); + if (!context) { + throw new Error("useTheme must be used within a PhantomProvider"); + } + return context.theme; +} diff --git a/packages/react-ui/src/index.ts b/packages/react-ui/src/index.ts index 07df53b9..1e28602f 100644 --- a/packages/react-ui/src/index.ts +++ b/packages/react-ui/src/index.ts @@ -4,9 +4,23 @@ // Main Provider export { PhantomProvider, type PhantomUIProviderProps } from "./PhantomProvider"; +// Hooks +export { useTheme } from "./hooks/useTheme"; + +// UI Components +export { + Button, + LoginWithPhantomButton, + type ButtonProps, + type LoginWithPhantomButtonProps, +} from "./components/Button"; + // Enhanced Hooks with UI integration export * from "./hooks"; +// Theme system +export { type PhantomTheme, type HexColor, darkTheme, lightTheme } from "./themes"; + // Re-export hooks and types from react-sdk (useConnect is overridden by UI hooks) export { useAccounts, @@ -26,6 +40,3 @@ export { export type { NetworkId } from "@phantom/client"; export { isMobileDevice, getDeeplinkToPhantom } from "@phantom/browser-sdk"; - -// Import and auto-inject CSS styles -import "./styles.css"; diff --git a/packages/react-ui/src/styles.css b/packages/react-ui/src/styles.css deleted file mode 100644 index 58e8490d..00000000 --- a/packages/react-ui/src/styles.css +++ /dev/null @@ -1,684 +0,0 @@ -/* Phantom React UI Styles */ - -/* Modal Overlay */ -.phantom-modal-overlay { - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: var(--bg-overlay); - display: flex; - align-items: center; - justify-content: center; - z-index: 1000; - backdrop-filter: blur(4px); - animation: phantomFadeIn var(--transition-fast); -} - -/* Modal Container */ -.phantom-modal { - background: var(--bg-modal); - border-radius: var(--border-radius-lg); - box-shadow: var(--shadow-modal); - width: 100%; - max-width: 400px; - max-height: 90vh; - overflow-y: auto; - margin: var(--spacing-md); - animation: phantomSlideUp var(--transition-normal); - font-family: var(--font-family); -} - -/* Modal Header */ -.phantom-modal-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: var(--spacing-lg); - border-bottom: 1px solid var(--border-color); -} - -.phantom-modal-header h2 { - margin: 0; - font-size: var(--font-size-lg); - font-weight: var(--font-weight-bold); - color: var(--text-primary); -} - -.phantom-modal-close { - background: none; - border: none; - font-size: var(--font-size-lg); - color: var(--text-secondary); - cursor: pointer; - padding: var(--spacing-xs); - border-radius: var(--border-radius); - transition: all var(--transition-fast); - width: 32px; - height: 32px; - display: flex; - align-items: center; - justify-content: center; -} - -.phantom-modal-close:hover:not(:disabled) { - background: var(--bg-secondary); - color: var(--text-primary); -} - -.phantom-modal-close:disabled { - opacity: 0.5; - cursor: not-allowed; -} - -/* Modal Content */ -.phantom-modal-content { - padding: var(--spacing-lg); -} - -.phantom-modal-subtitle { - margin: 0 0 var(--spacing-lg) 0; - color: var(--text-secondary); - font-size: var(--font-size-sm); -} - -/* Connection Options */ -.phantom-connection-options { - display: flex; - flex-direction: column; - gap: var(--spacing-md); - margin-bottom: var(--spacing-lg); -} - -/* Buttons */ -.phantom-button { - display: flex; - align-items: center; - justify-content: center; - padding: var(--spacing-md) var(--spacing-lg); - border-radius: var(--border-radius); - font-family: var(--font-family); - font-size: var(--font-size-md); - font-weight: var(--font-weight-medium); - cursor: pointer; - transition: all var(--transition-fast); - text-decoration: none; - border: none; - min-height: 48px; - width: 100%; -} - -.phantom-button:disabled { - opacity: 0.6; - cursor: not-allowed; -} - -.phantom-button-primary { - background: var(--bg-button-primary); - color: var(--text-button-primary); - border: var(--border-button); -} - -.phantom-button-primary:hover:not(:disabled) { - background: var(--primary-hover); - transform: translateY(-1px); - box-shadow: var(--shadow-md); -} - -.phantom-button-secondary { - background: var(--bg-button-secondary); - color: var(--text-button-secondary); - border: var(--border-button); -} - -.phantom-button-secondary:hover:not(:disabled) { - background: var(--bg-secondary); - transform: translateY(-1px); -} - -.phantom-button-tertiary { - background: var(--bg-button-tertiary); - color: var(--text-button-tertiary); - border: 1px solid var(--border-color); -} - -.phantom-button-tertiary:hover:not(:disabled) { - border-color: var(--primary); - transform: translateY(-1px); -} - -/* Button Content */ -.phantom-button-content { - display: flex; - align-items: center; - gap: var(--spacing-md); - width: 100%; -} - -.phantom-button-icon { - flex-shrink: 0; - width: 24px; - height: 24px; - display: flex; - align-items: center; - justify-content: center; -} - -.phantom-button-text { - display: flex; - flex-direction: column; - align-items: flex-start; - gap: var(--spacing-xs); - flex: 1; -} - -.phantom-button-title { - font-weight: var(--font-weight-medium); -} - -.phantom-button-subtitle { - font-size: var(--font-size-sm); - opacity: 0.8; -} - -/* Advanced Options */ -.phantom-advanced-options { - margin-top: var(--spacing-lg); - border-top: 1px solid var(--border-color); - padding-top: var(--spacing-lg); -} - -.phantom-advanced-options summary { - cursor: pointer; - color: var(--text-secondary); - font-size: var(--font-size-sm); - margin-bottom: var(--spacing-md); -} - -.phantom-advanced-content { - margin-top: var(--spacing-md); -} - -/* Transaction Preview */ -.phantom-transaction-container { - margin-bottom: var(--spacing-lg); -} - -.phantom-transaction-preview { - background: var(--bg-secondary); - border: 1px solid var(--border-color); - border-radius: var(--border-radius); - padding: var(--spacing-md); -} - -.phantom-network-badge { - display: inline-block; - background: var(--primary); - color: var(--text-inverse); - padding: var(--spacing-xs) var(--spacing-sm); - border-radius: var(--border-radius); - font-size: var(--font-size-xs); - font-weight: var(--font-weight-medium); - margin-bottom: var(--spacing-sm); -} - -.phantom-transaction-type { - font-weight: var(--font-weight-medium); - margin-bottom: var(--spacing-md); - color: var(--text-primary); -} - -.phantom-transaction-details { - display: flex; - flex-direction: column; - gap: var(--spacing-sm); -} - -.phantom-transaction-field { - display: flex; - align-items: center; - gap: var(--spacing-sm); -} - -.phantom-transaction-field label { - font-weight: var(--font-weight-medium); - color: var(--text-secondary); - min-width: 80px; - font-size: var(--font-size-sm); -} - -.phantom-transaction-field span { - color: var(--text-primary); - font-size: var(--font-size-sm); -} - -.phantom-address { - font-family: monospace; - font-size: var(--font-size-xs); - word-break: break-all; -} - -.phantom-data { - font-style: italic; - opacity: 0.8; -} - -/* Security Notice */ -.phantom-security-notice { - display: flex; - align-items: flex-start; - gap: var(--spacing-sm); - background: var(--bg-secondary); - border: 1px solid var(--border-color); - border-radius: var(--border-radius); - padding: var(--spacing-md); - margin-bottom: var(--spacing-lg); -} - -.phantom-security-icon { - font-size: var(--font-size-lg); - flex-shrink: 0; -} - -.phantom-security-text { - display: flex; - flex-direction: column; - gap: var(--spacing-xs); -} - -.phantom-security-text strong { - color: var(--text-primary); - font-weight: var(--font-weight-medium); -} - -.phantom-security-text span { - color: var(--text-secondary); - font-size: var(--font-size-sm); -} - -/* Error Messages */ -.phantom-error-message { - display: flex; - align-items: flex-start; - gap: var(--spacing-sm); - background: color-mix(in srgb, var(--danger) 10%, var(--bg-secondary)); - border: 1px solid var(--danger); - border-radius: var(--border-radius); - padding: var(--spacing-md); - margin-bottom: var(--spacing-lg); -} - -.phantom-error-icon { - font-size: var(--font-size-lg); - flex-shrink: 0; -} - -.phantom-error-text { - display: flex; - flex-direction: column; - gap: var(--spacing-xs); - flex: 1; -} - -.phantom-error-text strong { - color: var(--danger); - font-weight: var(--font-weight-medium); -} - -.phantom-error-text span { - color: var(--text-primary); - font-size: var(--font-size-sm); -} - -.phantom-retry-button { - background: none; - border: 1px solid var(--danger); - color: var(--danger); - padding: var(--spacing-xs) var(--spacing-sm); - border-radius: var(--border-radius); - font-size: var(--font-size-sm); - cursor: pointer; - margin-top: var(--spacing-xs); -} - -/* Loading States */ -.phantom-connecting-state, -.phantom-loading-state { - display: flex; - flex-direction: column; - align-items: center; - gap: var(--spacing-md); - padding: var(--spacing-lg); - text-align: center; - color: var(--text-secondary); -} - -.phantom-spinner { - width: 24px; - height: 24px; - border: 2px solid var(--border-color); - border-top: 2px solid var(--primary); - border-radius: 50%; - animation: phantomSpin 1s linear infinite; -} - -.phantom-spinner-small { - width: 16px; - height: 16px; - border-width: 1.5px; - margin-right: var(--spacing-xs); -} - -/* Modal Actions */ -.phantom-modal-actions { - display: flex; - gap: var(--spacing-md); - margin-top: var(--spacing-xl); -} - -.phantom-modal-actions .phantom-button { - flex: 1; -} - -/* Animations */ -@keyframes phantomFadeIn { - from { - opacity: 0; - } - to { - opacity: 1; - } -} - -@keyframes phantomSlideUp { - from { - opacity: 0; - transform: translateY(20px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -@keyframes phantomSpin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } -} - -/* Responsive Design */ -@media (max-width: 480px) { - .phantom-modal { - margin: var(--spacing-sm); - max-width: none; - } - - .phantom-modal-header, - .phantom-modal-content { - padding: var(--spacing-md); - } - - .phantom-button-content { - gap: var(--spacing-sm); - } - - .phantom-button-text { - align-items: center; - text-align: center; - } -} - -/* Focus Styles for Accessibility */ -.phantom-button:focus-visible, -.phantom-modal-close:focus-visible { - outline: 2px solid var(--primary); - outline-offset: 2px; -} - -/* High Contrast Mode Support */ -@media (prefers-contrast: high) { - .phantom-modal { - border: 2px solid var(--text-primary); - } - - .phantom-button { - border-width: 2px; - } -} - -/* Phantom UI Modal Styles */ -.phantom-ui-modal-overlay { - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: rgba(0, 0, 0, 0.5); - display: flex; - align-items: center; - justify-content: center; - z-index: 1000; - backdrop-filter: blur(4px); -} - -.phantom-ui-modal-content { - background: var(--bg-modal, #ffffff); - border-radius: var(--border-radius-lg, 12px); - box-shadow: 0 20px 40px rgba(0, 0, 0, 0.15); - width: 100%; - max-width: 400px; - max-height: 90vh; - overflow-y: auto; - margin: 20px; -} - -.phantom-ui-modal-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 24px 24px 0; -} - -.phantom-ui-modal-header h3 { - margin: 0; - font-size: 20px; - font-weight: 600; - color: var(--text-primary, #000000); -} - -.phantom-ui-close-button { - background: none; - border: none; - font-size: 24px; - color: var(--text-secondary, #666666); - cursor: pointer; - padding: 8px; - border-radius: 6px; - transition: all 0.2s; - width: 32px; - height: 32px; - display: flex; - align-items: center; - justify-content: center; -} - -.phantom-ui-close-button:hover { - background: var(--bg-secondary, #f5f5f5); - color: var(--text-primary, #000000); -} - -.phantom-ui-modal-body { - padding: 24px; -} - -.phantom-ui-error { - background: var(--bg-error, #fee); - border: 1px solid var(--border-error, #fcc); - border-radius: 6px; - padding: 12px; - color: var(--text-error, #c00); - margin-bottom: 16px; - font-size: 14px; -} - -.phantom-ui-provider-options { - display: flex; - flex-direction: column; - gap: 12px; -} - -.phantom-ui-provider-button { - display: flex; - align-items: center; - justify-content: center; - padding: 16px 24px; - border-radius: 8px; - font-family: inherit; - font-size: 16px; - font-weight: 500; - cursor: pointer; - transition: all 0.2s; - text-decoration: none; - border: 1px solid var(--border-light, #e5e5e5); - min-height: 48px; - width: 100%; - background: var(--bg-primary, #ffffff); - color: var(--text-primary, #000000); -} - -.phantom-ui-provider-button:hover:not(:disabled) { - background: var(--bg-secondary, #f8f9fa); - transform: translateY(-1px); - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); -} - -.phantom-ui-provider-button:disabled { - opacity: 0.6; - cursor: not-allowed; - transform: none; - box-shadow: none; -} - -.phantom-ui-provider-button-mobile { - background: var(--bg-accent, #ab9ff2); - color: var(--text-inverse, #ffffff); - border-color: var(--border-accent, #ab9ff2); - margin-bottom: 8px; -} - -.phantom-ui-provider-button-mobile:hover:not(:disabled) { - background: var(--bg-accent-hover, #9a8cf0); - border-color: var(--border-accent-hover, #9a8cf0); -} - -.phantom-ui-provider-button-primary { - background: var(--bg-accent, #ab9ff2); - color: var(--text-inverse, #ffffff); - border-color: var(--border-accent, #ab9ff2); - font-weight: 600; -} - -.phantom-ui-provider-button-primary:hover:not(:disabled) { - background: var(--bg-accent-hover, #9a8cf0); - border-color: var(--border-accent-hover, #9a8cf0); -} - -.phantom-ui-provider-button-secondary { - background: transparent; - border-color: var(--border-secondary, #d1d5db); - font-size: 14px; -} - -.phantom-ui-provider-button-secondary:hover:not(:disabled) { - background: var(--bg-hover, #f9fafb); - border-color: var(--border-accent, #ab9ff2); -} - -.phantom-ui-extension-section { - margin-top: 16px; - padding-top: 16px; -} - -.phantom-ui-divider { - display: flex; - align-items: center; - text-align: center; - margin: 16px 0; - color: var(--text-secondary, #6b7280); - font-size: 13px; -} - -.phantom-ui-divider::before, -.phantom-ui-divider::after { - content: ""; - flex: 1; - border-bottom: 1px solid var(--border-light, #e5e5e5); -} - -.phantom-ui-divider span { - padding: 0 12px; -} - -/* Dark theme support */ -.dark .phantom-ui-modal-content { - background: var(--bg-modal, #1f2937); - color: var(--text-primary, #ffffff); -} - -.dark .phantom-ui-modal-header h3 { - color: var(--text-primary, #ffffff); -} - -.dark .phantom-ui-close-button { - color: var(--text-secondary, #9ca3af); -} - -.dark .phantom-ui-close-button:hover { - background: var(--bg-secondary, #374151); - color: var(--text-primary, #ffffff); -} - -.dark .phantom-ui-provider-button { - background: var(--bg-secondary, #374151); - color: var(--text-primary, #ffffff); - border-color: var(--border-primary, #4b5563); -} - -.dark .phantom-ui-provider-button:hover:not(:disabled) { - background: var(--bg-hover, #4b5563); -} - -.dark .phantom-ui-provider-button-primary { - background: var(--bg-accent, #ab9ff2); - color: var(--text-inverse, #ffffff); - border-color: var(--border-accent, #ab9ff2); -} - -.dark .phantom-ui-provider-button-primary:hover:not(:disabled) { - background: var(--bg-accent-hover, #9a8cf0); - border-color: var(--border-accent-hover, #9a8cf0); -} - -.dark .phantom-ui-provider-button-secondary { - background: transparent; - border-color: var(--border-secondary, #6b7280); -} - -.dark .phantom-ui-provider-button-secondary:hover:not(:disabled) { - background: var(--bg-secondary, #374151); - border-color: var(--border-accent, #ab9ff2); -} - -.dark .phantom-ui-divider { - color: var(--text-secondary, #9ca3af); -} - -.dark .phantom-ui-divider::before, -.dark .phantom-ui-divider::after { - border-bottom-color: var(--border-primary, #4b5563); -} diff --git a/packages/react-ui/src/themes.ts b/packages/react-ui/src/themes.ts new file mode 100644 index 00000000..7f8bd0d6 --- /dev/null +++ b/packages/react-ui/src/themes.ts @@ -0,0 +1,139 @@ +/** + * Theme type definitions for Phantom UI + * Simple theme focused on colors and border radius + */ + +import { hexToRgba } from "./utils"; + +// Type-safe hex color string +export type HexColor = `#${string}`; + +export interface PhantomTheme { + // Background color for modal + background: HexColor; + + // Secondary color for text, borders, dividers (must be hex for opacity derivation) + secondary: HexColor; + + // Error color + error: HexColor; + + // Success color + success: HexColor; + + // Primary text color + text: HexColor; + + // Overlay background (with opacity) - can be rgba or hex + overlay: string; + + // Border radius for buttons and modal + borderRadius: string; + + // Brand color + brand: HexColor; +} + +export type CompletePhantomTheme = PhantomTheme & { + aux: string; // Auxiliary color derived from secondary with opacity (rgba format) + // Typography + typography: { + caption: { + fontFamily: string; + fontSize: string; + fontStyle: string; + fontWeight: string; + lineHeight: string; + letterSpacing: string; + }; + captionBold: { + fontFamily: string; + fontSize: string; + fontStyle: string; + fontWeight: string; + lineHeight: string; + letterSpacing: string; + }; + label: { + fontFamily: string; + fontSize: string; + fontStyle: string; + fontWeight: string; + lineHeight: string; + letterSpacing: string; + }; + }; +}; + +export const loginWithPhantomColor: HexColor = "#7C63E7"; +/** + * Dark theme configuration + */ +export const darkTheme: PhantomTheme = { + background: "#181818", + text: "#FFFFFF", + secondary: "#98979C", + overlay: "rgba(0, 0, 0, 0.7)", + borderRadius: "12px", + error: "#F00000", + success: "#1CC700", + brand: loginWithPhantomColor, +}; + +/** + * Light theme configuration + */ +export const lightTheme: PhantomTheme = { + background: "#FFFFFF", + text: "#181818", + secondary: "#98979C", + overlay: "rgba(0, 0, 0, 0.5)", + borderRadius: "12px", + error: "#F00000", + success: "#1CC700", + brand: loginWithPhantomColor, +}; + +/** + * Merge custom theme with base theme + */ +export function mergeTheme(customTheme?: Partial): CompletePhantomTheme { + const secondary = customTheme?.secondary || darkTheme.secondary; + const isHex = secondary.startsWith("#"); + + if (!isHex) { + throw new Error("Secondary color must be a hex color to derive auxiliary color."); + } + + return { + ...darkTheme, + ...customTheme, + aux: hexToRgba(secondary, 0.1), + typography: { + caption: { + fontFamily: '"SF Pro Text", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif', + fontSize: "14px", + fontStyle: "normal", + fontWeight: "400", + lineHeight: "17px", + letterSpacing: "-0.14px", + }, + captionBold: { + fontFamily: '"SF Pro Text", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif', + fontSize: "14px", + fontStyle: "normal", + fontWeight: "600", + lineHeight: "17px", + letterSpacing: "-0.14px", + }, + label: { + fontFamily: '"SF Pro Text", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif', + fontSize: "12px", + fontStyle: "normal", + fontWeight: "400", + lineHeight: "15px", + letterSpacing: "-0.12px", + }, + }, + }; +} diff --git a/packages/react-ui/src/themes/dark.css b/packages/react-ui/src/themes/dark.css deleted file mode 100644 index 41727828..00000000 --- a/packages/react-ui/src/themes/dark.css +++ /dev/null @@ -1,42 +0,0 @@ -/* Dark Theme */ -[data-theme="dark"] { - /* Colors */ - --primary: #ab9ff2; - --primary-hover: #9c8dff; - --secondary: #8e95a0; - --secondary-hover: #a1a8b3; - --success: #34d058; - --danger: #f85149; - --warning: #ffb347; - --info: #58a6ff; - - /* Background */ - --bg-primary: #0d1117; - --bg-secondary: #161b22; - --bg-modal: #21262d; - --bg-overlay: rgba(0, 0, 0, 0.8); - --bg-button-primary: var(--primary); - --bg-button-secondary: transparent; - --bg-button-tertiary: #21262d; - - /* Text */ - --text-primary: #f0f6fc; - --text-secondary: #8b949e; - --text-muted: #6e7681; - --text-inverse: #0d1117; - --text-button-primary: #ffffff; - --text-button-secondary: var(--primary); - --text-button-tertiary: var(--text-primary); - - /* Border */ - --border-color: #30363d; - --border-radius: 8px; - --border-radius-lg: 12px; - --border-button: 1px solid var(--primary); - - /* Shadows */ - --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.3); - --shadow-md: 0 4px 6px rgba(0, 0, 0, 0.3); - --shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.3); - --shadow-modal: 0 20px 25px rgba(0, 0, 0, 0.5); -} diff --git a/packages/react-ui/src/themes/light.css b/packages/react-ui/src/themes/light.css deleted file mode 100644 index fb57da70..00000000 --- a/packages/react-ui/src/themes/light.css +++ /dev/null @@ -1,66 +0,0 @@ -/* Light Theme */ -:root, -[data-theme="light"] { - /* Colors */ - --primary: #ab9ff2; - --primary-hover: #9c8dff; - --secondary: #6c757d; - --secondary-hover: #545b62; - --success: #28a745; - --danger: #dc3545; - --warning: #ffc107; - --info: #17a2b8; - - /* Background */ - --bg-primary: #ffffff; - --bg-secondary: #f8f9fa; - --bg-modal: #ffffff; - --bg-overlay: rgba(0, 0, 0, 0.5); - --bg-button-primary: var(--primary); - --bg-button-secondary: transparent; - --bg-button-tertiary: #f8f9fa; - - /* Text */ - --text-primary: #212529; - --text-secondary: #6c757d; - --text-muted: #868e96; - --text-inverse: #ffffff; - --text-button-primary: #ffffff; - --text-button-secondary: var(--primary); - --text-button-tertiary: var(--text-primary); - - /* Border */ - --border-color: #dee2e6; - --border-radius: 8px; - --border-radius-lg: 12px; - --border-button: 1px solid var(--primary); - - /* Spacing */ - --spacing-xs: 4px; - --spacing-sm: 8px; - --spacing-md: 16px; - --spacing-lg: 24px; - --spacing-xl: 32px; - - /* Typography */ - --font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; - --font-size-xs: 12px; - --font-size-sm: 14px; - --font-size-md: 16px; - --font-size-lg: 18px; - --font-size-xl: 24px; - --font-weight-normal: 400; - --font-weight-medium: 500; - --font-weight-bold: 600; - - /* Shadows */ - --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.1); - --shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1); - --shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.1); - --shadow-modal: 0 20px 25px rgba(0, 0, 0, 0.15); - - /* Animation */ - --transition-fast: 0.15s ease; - --transition-normal: 0.25s ease; - --transition-slow: 0.35s ease; -} diff --git a/packages/react-ui/src/utils.ts b/packages/react-ui/src/utils.ts new file mode 100644 index 00000000..36c8cd29 --- /dev/null +++ b/packages/react-ui/src/utils.ts @@ -0,0 +1,12 @@ +// Helper function to convert hex color to rgba +export const hexToRgba = (hex: string, opacity: number): string => { + // Remove # if present + const cleanHex = hex.replace("#", ""); + + // Parse hex values + const r = parseInt(cleanHex.slice(0, 2), 16); + const g = parseInt(cleanHex.slice(2, 4), 16); + const b = parseInt(cleanHex.slice(4, 6), 16); + + return `rgba(${r}, ${g}, ${b}, ${opacity})`; +}; diff --git a/packages/react-ui/tsup.config.ts b/packages/react-ui/tsup.config.ts index ff266484..ab930407 100644 --- a/packages/react-ui/tsup.config.ts +++ b/packages/react-ui/tsup.config.ts @@ -9,8 +9,4 @@ export default defineConfig({ external: ["react", "react-dom", "@phantom/react-sdk", "@phantom/client"], minify: true, sourcemap: true, - // Include CSS files in the bundle - loader: { - ".css": "copy", - }, });