diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index a2dc41e..6f9b30c 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -1,26 +1,19 @@ +/** + * Import Tailwind CSS + */ @import "tailwindcss"; -:root { - --background: #ffffff; - --foreground: #171717; -} - -@theme inline { - --color-background: var(--background); - --color-foreground: var(--foreground); - --font-sans: var(--font-geist-sans); - --font-mono: var(--font-geist-mono); -} - -@media (prefers-color-scheme: dark) { - :root { - --background: #0a0a0a; - --foreground: #ededed; - } -} +/** + * Import BridgeWise UI Components theme variables + */ +@import "@bridgewise/ui-components/styles/globals.css"; +/** + * Application-specific styles + * Use BridgeWise theme variables for consistency + */ body { - background: var(--background); - color: var(--foreground); - font-family: Arial, Helvetica, sans-serif; + background: var(--bw-colors-background-primary); + color: var(--bw-colors-foreground-primary); + font-family: var(--bw-typography-font-family-sans); } diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index e9454a2..5fba724 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -1,5 +1,6 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; +import { ThemeProvider, ThemeScript, TransactionProvider } from "@bridgewise/ui-components"; import "./globals.css"; const geistSans = Geist({ @@ -24,10 +25,17 @@ export default function RootLayout({ }>) { return ( + + + - {children} + + + {children} + + ); diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index ee8bc73..9b109fe 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -2,7 +2,7 @@ 'use client'; import { useEffect } from 'react'; -import { TransactionHeartbeat, TransactionProvider, useTransaction } from '../components/ui-lib'; +import { TransactionHeartbeat, useTransaction } from '@bridgewise/ui-components'; function TransactionDemo() { const { state, updateState, startTransaction, clearState } = useTransaction(); @@ -64,9 +64,5 @@ function TransactionDemo() { } export default function Home() { - return ( - - - - ); + return ; } diff --git a/examples/custom-theme.example.tsx b/examples/custom-theme.example.tsx new file mode 100644 index 0000000..45c00d0 --- /dev/null +++ b/examples/custom-theme.example.tsx @@ -0,0 +1,80 @@ +/** + * Custom Theme Configuration Example + * Shows how external developers can customize the BridgeWise theme + */ + +import { ThemeProvider } from '@bridgewise/ui-components/theme'; +import type { Theme, DeepPartial } from '@bridgewise/ui-components'; + +/** + * Example: Custom brand colors + */ +const customTheme: DeepPartial = { + colors: { + background: { + primary: '#fafafa', + secondary: '#f4f4f5', + }, + foreground: { + primary: '#18181b', + link: '#3b82f6', + }, + transaction: { + background: '#ffffff', + border: '#e4e4e7', + progressBar: { + success: '#22c55e', + error: '#ef4444', + pending: '#3b82f6', + }, + }, + }, + spacing: { + xs: '0.5rem', + sm: '0.75rem', + md: '1.25rem', + }, + typography: { + fontFamily: { + sans: 'Inter, system-ui, sans-serif', + }, + }, +}; + +/** + * Example: Dark-first theme + */ +const darkFirstTheme: DeepPartial = { + colors: { + background: { + primary: '#0a0a0a', + secondary: '#171717', + }, + foreground: { + primary: '#ffffff', + secondary: '#a3a3a3', + }, + }, +}; + +/** + * Usage in your app + */ +export function App() { + return ( + + {/* Your application components */} + + ); +} + +/** + * Force dark mode + */ +export function DarkApp() { + return ( + + {/* Your application components */} + + ); +} diff --git a/examples/headless-component.example.tsx b/examples/headless-component.example.tsx new file mode 100644 index 0000000..be678d1 --- /dev/null +++ b/examples/headless-component.example.tsx @@ -0,0 +1,110 @@ +/** + * Headless Component Example + * Shows how to use the headless TransactionHeartbeat with custom styling + */ + +import { TransactionHeartbeatHeadless } from '@bridgewise/ui-components/headless'; +import { TransactionProvider } from '@bridgewise/ui-components'; + +/** + * Example 1: Minimal custom notification + */ +export function MinimalHeartbeat() { + return ( + + {({ state, clearState, isSuccess, isFailed, isPending }) => ( +
+
+
+

+ {isSuccess ? '✅ Done!' : isFailed ? '❌ Failed' : '⏳ Processing...'} +

+

{state.step}

+
+ +
+ +
+ )} +
+ ); +} + +/** + * Example 2: Toast-style notification + */ +export function ToastHeartbeat() { + return ( + + {({ state, clearState, isSuccess, isFailed }) => { + const bgColor = isSuccess ? 'bg-green-50' : isFailed ? 'bg-red-50' : 'bg-blue-50'; + const textColor = isSuccess ? 'text-green-900' : isFailed ? 'text-red-900' : 'text-blue-900'; + + return ( +
+
+ + {isSuccess ? '🎉' : isFailed ? '💔' : '🔄'} + +
+

{state.step}

+
+
+
+
+ +
+
+ ); + }} + + ); +} + +/** + * Example 3: Compact progress bar + */ +export function CompactHeartbeat() { + return ( + + {({ state, isSuccess, isFailed }) => { + const color = isSuccess ? '#22c55e' : isFailed ? '#ef4444' : '#3b82f6'; + + return ( +
+ ); + }} + + ); +} + +/** + * Complete app example + */ +export function App() { + return ( + +
+

My Application

+ {/* Choose your style */} + + {/* or */} + {/* or */} +
+
+ ); +} diff --git a/examples/theme-toggle.example.tsx b/examples/theme-toggle.example.tsx new file mode 100644 index 0000000..6f6895e --- /dev/null +++ b/examples/theme-toggle.example.tsx @@ -0,0 +1,127 @@ +/** + * Theme Toggle Examples + * Shows how to build theme switching controls + */ + +'use client'; + +import { useTheme } from '@bridgewise/ui-components/theme'; + +/** + * Example 1: Simple toggle button + */ +export function SimpleThemeToggle() { + const { mode, toggleMode } = useTheme(); + + return ( + + ); +} + +/** + * Example 2: Three-way toggle (Light / System / Dark) + */ +export function AdvancedThemeToggle() { + const { mode, setMode } = useTheme(); + + return ( +
+ {(['light', 'system', 'dark'] as const).map((themeMode) => ( + + ))} +
+ ); +} + +/** + * Example 3: Dropdown menu + */ +export function ThemeDropdown() { + const { mode, setMode } = useTheme(); + + return ( +
+ +
+ + + +
+
+ ); +} + +/** + * Example 4: Icon-only toggle with animation + */ +export function AnimatedThemeToggle() { + const { mode, toggleMode } = useTheme(); + const isDark = mode === 'dark'; + + return ( + + ); +} + +/** + * Example 5: With current theme display + */ +export function ThemeToggleWithInfo() { + const { theme, mode, toggleMode } = useTheme(); + + return ( +
+ +
+
Mode: {mode}
+
Background: {theme.colors.background.primary}
+
+
+ ); +} diff --git a/libs/ui-components/package-lock.json b/libs/ui-components/package-lock.json new file mode 100644 index 0000000..6efef90 --- /dev/null +++ b/libs/ui-components/package-lock.json @@ -0,0 +1,92 @@ +{ + "name": "@bridgewise/ui-components", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@bridgewise/ui-components", + "version": "0.1.0", + "devDependencies": { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "typescript": "^5.0.0" + }, + "peerDependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.9", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.9.tgz", + "integrity": "sha512-Lpo8kgb/igvMIPeNV2rsYKTgaORYdO1XGVZ4Qz3akwOj0ySGYMPlQWa8BaLn0G63D1aSaAQ5ldR06wCpChQCjA==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/react": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", + "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", + "license": "MIT", + "peer": true, + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.3" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT", + "peer": true + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + } + } +} diff --git a/libs/ui-components/package.json b/libs/ui-components/package.json index e27ed2f..0bd8bad 100644 --- a/libs/ui-components/package.json +++ b/libs/ui-components/package.json @@ -2,6 +2,12 @@ "name": "@bridgewise/ui-components", "version": "0.1.0", "main": "src/index.ts", + "exports": { + ".": "./src/index.ts", + "./theme": "./src/theme/index.ts", + "./headless": "./src/components/headless/index.ts", + "./styles/globals.css": "./src/styles/globals.css" + }, "peerDependencies": { "react": "^19.0.0", "react-dom": "^19.0.0" diff --git a/libs/ui-components/src/components/TransactionHeartbeat/TransactionContext.tsx b/libs/ui-components/src/components/TransactionHeartbeat/TransactionContext.tsx new file mode 100644 index 0000000..c5fe48f --- /dev/null +++ b/libs/ui-components/src/components/TransactionHeartbeat/TransactionContext.tsx @@ -0,0 +1,104 @@ +/** + * Transaction Context + * Manages transaction state across the application + */ + +'use client'; + +import React, { createContext, useContext, useState, useEffect, useCallback, ReactNode } from 'react'; + +export interface TransactionState { + id: string; + status: 'idle' | 'pending' | 'success' | 'failed'; + progress: number; + step: string; + txHash?: string; + timestamp: number; +} + +interface TransactionContextType { + state: TransactionState; + updateState: (updates: Partial) => void; + clearState: () => void; + startTransaction: (id: string) => void; +} + +const TransactionContext = createContext(undefined); + +const STORAGE_KEY = 'bridgewise_tx_state'; + +export const TransactionProvider = ({ children }: { children: ReactNode }) => { + const [state, setState] = useState({ + id: '', + status: 'idle', + progress: 0, + step: '', + timestamp: 0, + }); + + // Load from storage on mount + useEffect(() => { + if (typeof window === 'undefined') return; + try { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored) { + const parsed = JSON.parse(stored); + // Expiry check (24 hours) + if (Date.now() - parsed.timestamp < 24 * 60 * 60 * 1000) { + setState(parsed); + } else { + localStorage.removeItem(STORAGE_KEY); + } + } + } catch (e) { + console.error('Failed to load transaction state', e); + } + }, []); + + // Save to storage on change + useEffect(() => { + if (typeof window === 'undefined') return; + if (state.status !== 'idle') { + localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); + } + }, [state]); + + const updateState = useCallback((updates: Partial) => { + setState((prev) => ({ ...prev, ...updates, timestamp: Date.now() })); + }, []); + + const clearState = useCallback(() => { + setState({ + id: '', + status: 'idle', + progress: 0, + step: '', + timestamp: 0, + }); + localStorage.removeItem(STORAGE_KEY); + }, []); + + const startTransaction = useCallback((id: string) => { + setState({ + id, + status: 'pending', + progress: 0, + step: 'Initializing...', + timestamp: Date.now() + }); + }, []); + + return ( + + {children} + + ); +}; + +export const useTransaction = () => { + const context = useContext(TransactionContext); + if (!context) { + throw new Error('useTransaction must be used within a TransactionProvider'); + } + return context; +}; diff --git a/libs/ui-components/src/components/TransactionHeartbeat/TransactionHeartbeat.headless.tsx b/libs/ui-components/src/components/TransactionHeartbeat/TransactionHeartbeat.headless.tsx new file mode 100644 index 0000000..b646661 --- /dev/null +++ b/libs/ui-components/src/components/TransactionHeartbeat/TransactionHeartbeat.headless.tsx @@ -0,0 +1,83 @@ +/** + * TransactionHeartbeat Headless Component + * Provides transaction state and logic without any styling + * Uses render props pattern for maximum flexibility + */ + +'use client'; + +import React from 'react'; +import { useTransaction } from './TransactionContext'; +import type { TransactionState } from './TransactionContext'; + +export interface TransactionHeartbeatRenderProps { + /** Full transaction state object */ + state: TransactionState; + /** Function to clear/dismiss the transaction state */ + clearState: () => void; + /** Update transaction state with partial updates */ + updateState: (updates: Partial) => void; + /** Start a new transaction */ + startTransaction: (id: string) => void; + /** Convenience boolean: true if status is 'success' */ + isSuccess: boolean; + /** Convenience boolean: true if status is 'failed' */ + isFailed: boolean; + /** Convenience boolean: true if status is 'pending' */ + isPending: boolean; +} + +export interface TransactionHeartbeatHeadlessProps { + /** + * Render function that receives transaction state and controls + * Return null to hide the component + */ + children: (props: TransactionHeartbeatRenderProps) => React.ReactNode; +} + +/** + * Headless transaction heartbeat component + * + * @example + * ```tsx + * + * {({ state, clearState, isSuccess, isPending }) => ( + *
+ *

{isSuccess ? '✅ Done!' : isPending ? '⏳ Processing...' : '❌ Failed'}

+ * + *

{state.step}

+ * + *
+ * )} + *
+ * ``` + */ +export const TransactionHeartbeatHeadless: React.FC = ({ + children, +}) => { + const { state, clearState, updateState, startTransaction } = useTransaction(); + + // Don't render if transaction is idle + if (state.status === 'idle') { + return null; + } + + // Derive convenience booleans from state + const isSuccess = state.status === 'success'; + const isFailed = state.status === 'failed'; + const isPending = state.status === 'pending'; + + return ( + <> + {children({ + state, + clearState, + updateState, + startTransaction, + isSuccess, + isFailed, + isPending, + })} + + ); +}; diff --git a/libs/ui-components/src/components/TransactionHeartbeat/TransactionHeartbeat.tsx b/libs/ui-components/src/components/TransactionHeartbeat/TransactionHeartbeat.tsx new file mode 100644 index 0000000..12dc512 --- /dev/null +++ b/libs/ui-components/src/components/TransactionHeartbeat/TransactionHeartbeat.tsx @@ -0,0 +1,212 @@ +/** + * TransactionHeartbeat Component + * Styled notification component for transaction status + * Uses BridgeWise theme CSS variables for styling + */ + +'use client'; + +import React from 'react'; +import { TransactionHeartbeatHeadless } from './TransactionHeartbeat.headless'; + +/** + * Transaction heartbeat notification component + * Displays transaction progress with themed styling + * + * @example + * ```tsx + * import { TransactionProvider, TransactionHeartbeat } from '@bridgewise/ui-components'; + * + * function App() { + * return ( + * + * + * + * + * ); + * } + * ``` + */ +export const TransactionHeartbeat: React.FC = () => { + return ( + + {({ state, clearState, isSuccess, isFailed, isPending }) => ( +
+
+ {/* Header */} +
+

+ {isSuccess + ? 'Transaction Complete' + : isFailed + ? 'Transaction Failed' + : 'Bridging Assets...'} +

+ +
+ + {/* Progress bar */} +
+
+
+
+
+
+ + {state.progress}% + +
+ + {/* Status text */} +

+ {state.step} +

+ + {/* Transaction hash link */} + {state.txHash && ( + + )} +
+ + {/* Status indicator bar at bottom */} +
+ + {/* Pulse animation keyframes */} + +
+ )} + + ); +}; diff --git a/libs/ui-components/src/components/TransactionHeartbeat/index.ts b/libs/ui-components/src/components/TransactionHeartbeat/index.ts new file mode 100644 index 0000000..3f1196d --- /dev/null +++ b/libs/ui-components/src/components/TransactionHeartbeat/index.ts @@ -0,0 +1,12 @@ +/** + * TransactionHeartbeat Component Exports + */ + +export { TransactionHeartbeat } from './TransactionHeartbeat'; +export { TransactionHeartbeatHeadless } from './TransactionHeartbeat.headless'; +export { TransactionProvider, useTransaction } from './TransactionContext'; +export type { TransactionState } from './TransactionContext'; +export type { + TransactionHeartbeatHeadlessProps, + TransactionHeartbeatRenderProps, +} from './TransactionHeartbeat.headless'; diff --git a/libs/ui-components/src/components/headless/index.ts b/libs/ui-components/src/components/headless/index.ts new file mode 100644 index 0000000..bd242ba --- /dev/null +++ b/libs/ui-components/src/components/headless/index.ts @@ -0,0 +1,10 @@ +/** + * Headless Components Exports + * Unstyled components for maximum customization + */ + +export { TransactionHeartbeatHeadless } from '../TransactionHeartbeat/TransactionHeartbeat.headless'; +export type { + TransactionHeartbeatHeadlessProps, + TransactionHeartbeatRenderProps, +} from '../TransactionHeartbeat/TransactionHeartbeat.headless'; diff --git a/libs/ui-components/src/index.ts b/libs/ui-components/src/index.ts new file mode 100644 index 0000000..c044b38 --- /dev/null +++ b/libs/ui-components/src/index.ts @@ -0,0 +1,42 @@ +/** + * BridgeWise UI Components + * Main entry point for the component library + */ + +// Theme System +export { + ThemeProvider, + useTheme, + ThemeScript, + defaultTheme, + darkTheme, + primitiveColors, + mergeTheme, + generateCSSVariables, +} from './theme'; + +export type { + Theme, + ThemeMode, + ThemeColors, + ThemeSpacing, + ThemeTypography, + ThemeShadows, + ThemeRadii, + ThemeTransitions, + ThemeContextValue, + DeepPartial, + ThemeConfig, + CSSVariables, +} from './theme'; + +// Components +export { + TransactionHeartbeat, + TransactionProvider, + useTransaction, +} from './components/TransactionHeartbeat'; + +export type { + TransactionState, +} from './components/TransactionHeartbeat'; diff --git a/libs/ui-components/src/styles/globals.css b/libs/ui-components/src/styles/globals.css new file mode 100644 index 0000000..2a8db69 --- /dev/null +++ b/libs/ui-components/src/styles/globals.css @@ -0,0 +1,170 @@ +/** + * BridgeWise UI Components Global Styles + * Theme system base styles and CSS variables + * + * Note: This file does NOT import Tailwind CSS. + * The consuming application should import Tailwind separately. + */ + +/** + * CSS Variable Fallbacks + * These provide defaults before JavaScript loads the theme + */ +:root { + /* Colors - Light mode fallbacks */ + --bw-colors-background-primary: #ffffff; + --bw-colors-background-secondary: #f8fafc; + --bw-colors-background-tertiary: #f1f5f9; + --bw-colors-foreground-primary: #0f172a; + --bw-colors-foreground-secondary: #475569; + --bw-colors-foreground-tertiary: #94a3b8; + --bw-colors-foreground-link: #2563eb; + + /* Borders */ + --bw-colors-border-default: #e2e8f0; + --bw-colors-border-focus: #3b82f6; + --bw-colors-border-error: #ef4444; + + /* Status */ + --bw-colors-status-success: #22c55e; + --bw-colors-status-error: #ef4444; + --bw-colors-status-warning: #eab308; + --bw-colors-status-info: #3b82f6; + --bw-colors-status-pending: #2563eb; + + /* Transaction component */ + --bw-colors-transaction-background: #ffffff; + --bw-colors-transaction-border: #e2e8f0; + --bw-colors-transaction-progress-bar-success: #22c55e; + --bw-colors-transaction-progress-bar-error: #ef4444; + --bw-colors-transaction-progress-bar-pending: #2563eb; + + /* Spacing */ + --bw-spacing-xs: 0.25rem; + --bw-spacing-sm: 0.5rem; + --bw-spacing-md: 1rem; + --bw-spacing-lg: 1.5rem; + --bw-spacing-xl: 2rem; + --bw-spacing-2xl: 3rem; + + /* Typography */ + --bw-typography-font-family-sans: var(--font-geist-sans, system-ui, sans-serif); + --bw-typography-font-family-mono: var(--font-geist-mono, ui-monospace, monospace); + --bw-typography-font-size-xs: 0.75rem; + --bw-typography-font-size-sm: 0.875rem; + --bw-typography-font-size-base: 1rem; + --bw-typography-font-size-lg: 1.125rem; + --bw-typography-font-size-xl: 1.25rem; + --bw-typography-font-size-2xl: 1.5rem; + --bw-typography-font-weight-normal: 400; + --bw-typography-font-weight-medium: 500; + --bw-typography-font-weight-semibold: 600; + --bw-typography-font-weight-bold: 700; + --bw-typography-line-height-tight: 1.25; + --bw-typography-line-height-normal: 1.5; + --bw-typography-line-height-relaxed: 1.75; + + /* Shadows */ + --bw-shadows-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05); + --bw-shadows-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); + --bw-shadows-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1); + --bw-shadows-xl: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1); + + /* Radii */ + --bw-radii-none: 0; + --bw-radii-sm: 0.125rem; + --bw-radii-md: 0.375rem; + --bw-radii-lg: 0.5rem; + --bw-radii-xl: 0.75rem; + --bw-radii-full: 9999px; + + /* Transitions */ + --bw-transitions-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1); + --bw-transitions-base: 300ms cubic-bezier(0.4, 0, 0.2, 1); + --bw-transitions-slow: 500ms cubic-bezier(0.4, 0, 0.2, 1); +} + +/** + * Dark Mode Fallbacks + * Applied before JavaScript loads when system prefers dark + */ +@media (prefers-color-scheme: dark) { + :root { + /* Colors - Dark mode fallbacks */ + --bw-colors-background-primary: #0f172a; + --bw-colors-background-secondary: #1e293b; + --bw-colors-background-tertiary: #334155; + --bw-colors-foreground-primary: #f8fafc; + --bw-colors-foreground-secondary: #94a3b8; + --bw-colors-foreground-tertiary: #64748b; + --bw-colors-foreground-link: #60a5fa; + + /* Borders */ + --bw-colors-border-default: #334155; + --bw-colors-border-focus: #60a5fa; + --bw-colors-border-error: #f87171; + + /* Status */ + --bw-colors-status-success: #4ade80; + --bw-colors-status-error: #f87171; + --bw-colors-status-warning: #facc15; + --bw-colors-status-info: #60a5fa; + --bw-colors-status-pending: #3b82f6; + + /* Transaction component */ + --bw-colors-transaction-background: #1e293b; + --bw-colors-transaction-border: #334155; + } +} + +/** + * Tailwind v4 Theme Integration + * Maps CSS variables to Tailwind utilities using @theme inline directive + */ +@theme inline { + /* Colors */ + --color-bw-bg-primary: var(--bw-colors-background-primary); + --color-bw-bg-secondary: var(--bw-colors-background-secondary); + --color-bw-bg-tertiary: var(--bw-colors-background-tertiary); + --color-bw-fg-primary: var(--bw-colors-foreground-primary); + --color-bw-fg-secondary: var(--bw-colors-foreground-secondary); + --color-bw-fg-tertiary: var(--bw-colors-foreground-tertiary); + --color-bw-fg-link: var(--bw-colors-foreground-link); + --color-bw-border: var(--bw-colors-border-default); + --color-bw-border-focus: var(--bw-colors-border-focus); + --color-bw-success: var(--bw-colors-status-success); + --color-bw-error: var(--bw-colors-status-error); + --color-bw-warning: var(--bw-colors-status-warning); + --color-bw-info: var(--bw-colors-status-info); + + /* Spacing */ + --spacing-bw-xs: var(--bw-spacing-xs); + --spacing-bw-sm: var(--bw-spacing-sm); + --spacing-bw-md: var(--bw-spacing-md); + --spacing-bw-lg: var(--bw-spacing-lg); + --spacing-bw-xl: var(--bw-spacing-xl); + --spacing-bw-2xl: var(--bw-spacing-2xl); + + /* Typography */ + --font-bw-sans: var(--bw-typography-font-family-sans); + --font-bw-mono: var(--bw-typography-font-family-mono); + --text-bw-xs: var(--bw-typography-font-size-xs); + --text-bw-sm: var(--bw-typography-font-size-sm); + --text-bw-base: var(--bw-typography-font-size-base); + --text-bw-lg: var(--bw-typography-font-size-lg); + --text-bw-xl: var(--bw-typography-font-size-xl); + --text-bw-2xl: var(--bw-typography-font-size-2xl); + + /* Shadows */ + --shadow-bw-sm: var(--bw-shadows-sm); + --shadow-bw-md: var(--bw-shadows-md); + --shadow-bw-lg: var(--bw-shadows-lg); + --shadow-bw-xl: var(--bw-shadows-xl); + + /* Radii */ + --radius-bw-sm: var(--bw-radii-sm); + --radius-bw-md: var(--bw-radii-md); + --radius-bw-lg: var(--bw-radii-lg); + --radius-bw-xl: var(--bw-radii-xl); + --radius-bw-full: var(--bw-radii-full); +} diff --git a/libs/ui-components/src/theme/ThemeProvider.tsx b/libs/ui-components/src/theme/ThemeProvider.tsx new file mode 100644 index 0000000..efe44d9 --- /dev/null +++ b/libs/ui-components/src/theme/ThemeProvider.tsx @@ -0,0 +1,194 @@ +/** + * ThemeProvider Component + * Core provider for BridgeWise theming system + * Manages theme state, dark mode, and CSS variable injection + */ + +'use client'; + +import React, { createContext, useContext, useEffect, useState, useMemo, useCallback } from 'react'; +import { mergeTheme } from './utils/merge-theme'; +import { generateCSSVariables } from './utils/css-vars'; +import { defaultTheme, darkTheme } from './tokens'; +import type { Theme, ThemeMode, ThemeContextValue, DeepPartial } from './types'; + +const ThemeContext = createContext(undefined); + +export interface ThemeProviderProps { + children: React.ReactNode; + /** + * Custom theme configuration to merge with defaults + */ + theme?: DeepPartial; + /** + * Initial theme mode (light/dark/system) + * @default 'system' + */ + defaultMode?: ThemeMode; + /** + * Storage key for persisting theme preference + * @default 'bridgewise-theme-mode' + */ + storageKey?: string; + /** + * Enable/disable system theme detection + * @default true + */ + enableSystem?: boolean; + /** + * Disable all transitions during theme switch + * @default true + */ + disableTransitionOnChange?: boolean; + /** + * Custom attribute to set on root element + * @default 'data-theme' + */ + attribute?: 'class' | 'data-theme'; + /** + * Value to set for dark mode + * @default 'dark' + */ + darkValue?: string; + /** + * Value to set for light mode + * @default 'light' + */ + lightValue?: string; +} + +export const ThemeProvider: React.FC = ({ + children, + theme: customTheme, + defaultMode = 'system', + storageKey = 'bridgewise-theme-mode', + enableSystem = true, + disableTransitionOnChange = true, + attribute = 'data-theme', + darkValue = 'dark', + lightValue = 'light', +}) => { + const [mode, setModeState] = useState(defaultMode); + const [resolvedMode, setResolvedMode] = useState<'light' | 'dark'>('light'); + const [mounted, setMounted] = useState(false); + + // Merge custom theme with defaults + const mergedTheme = useMemo(() => { + const baseTheme = customTheme ? mergeTheme(defaultTheme, customTheme) : defaultTheme; + const themeWithMode = resolvedMode === 'dark' + ? mergeTheme(baseTheme, darkTheme) + : baseTheme; + return themeWithMode; + }, [customTheme, resolvedMode]); + + // Initialize theme from storage/system on mount + useEffect(() => { + setMounted(true); + + // Load from storage + if (typeof window !== 'undefined') { + const stored = localStorage.getItem(storageKey); + if (stored && ['light', 'dark', 'system'].includes(stored)) { + setModeState(stored as ThemeMode); + } + } + }, [storageKey]); + + // Handle system preference changes + useEffect(() => { + if (!enableSystem || !mounted) return; + + const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); + + const updateResolvedMode = () => { + if (mode === 'system') { + setResolvedMode(mediaQuery.matches ? 'dark' : 'light'); + } else { + setResolvedMode(mode as 'light' | 'dark'); + } + }; + + updateResolvedMode(); + + // Listen for system changes + mediaQuery.addEventListener('change', updateResolvedMode); + return () => mediaQuery.removeEventListener('change', updateResolvedMode); + }, [mode, enableSystem, mounted]); + + // Apply theme to DOM + useEffect(() => { + if (!mounted) return; + + const root = document.documentElement; + + // Disable transitions temporarily + if (disableTransitionOnChange) { + const css = document.createElement('style'); + css.textContent = '* { transition: none !important; }'; + document.head.appendChild(css); + + // Force reflow + requestAnimationFrame(() => { + requestAnimationFrame(() => { + document.head.removeChild(css); + }); + }); + } + + // Set theme attribute + if (attribute === 'class') { + root.classList.remove(lightValue, darkValue); + root.classList.add(resolvedMode === 'dark' ? darkValue : lightValue); + } else { + root.setAttribute(attribute, resolvedMode === 'dark' ? darkValue : lightValue); + } + + // Generate and inject CSS variables + const cssVars = generateCSSVariables(mergedTheme); + Object.entries(cssVars).forEach(([key, value]) => { + root.style.setProperty(key, value); + }); + + }, [resolvedMode, mergedTheme, mounted, attribute, darkValue, lightValue, disableTransitionOnChange]); + + const setMode = useCallback((newMode: ThemeMode) => { + setModeState(newMode); + if (typeof window !== 'undefined') { + localStorage.setItem(storageKey, newMode); + } + }, [storageKey]); + + const toggleMode = useCallback(() => { + setMode(mode === 'light' ? 'dark' : mode === 'dark' ? 'light' : 'light'); + }, [mode, setMode]); + + const value: ThemeContextValue = useMemo(() => ({ + theme: mergedTheme, + mode: resolvedMode, + setMode, + toggleMode, + }), [mergedTheme, resolvedMode, setMode, toggleMode]); + + // Prevent flash on SSR + if (!mounted) { + return <>{children}; + } + + return ( + + {children} + + ); +}; + +/** + * Hook to access theme context + * @throws Error if used outside ThemeProvider + */ +export const useTheme = () => { + const context = useContext(ThemeContext); + if (!context) { + throw new Error('useTheme must be used within a ThemeProvider'); + } + return context; +}; diff --git a/libs/ui-components/src/theme/ThemeScript.tsx b/libs/ui-components/src/theme/ThemeScript.tsx new file mode 100644 index 0000000..872956c --- /dev/null +++ b/libs/ui-components/src/theme/ThemeScript.tsx @@ -0,0 +1,80 @@ +/** + * ThemeScript Component + * Inline script that runs before React hydration to prevent flash of wrong theme + * This should be placed in the of your document + */ + +import React from 'react'; + +export interface ThemeScriptProps { + /** + * Storage key for theme preference + * @default 'bridgewise-theme-mode' + */ + storageKey?: string; + /** + * Attribute to set on root element + * @default 'data-theme' + */ + attribute?: 'class' | 'data-theme'; + /** + * Default theme mode + * @default 'system' + */ + defaultMode?: 'light' | 'dark' | 'system'; + /** + * Value to use for dark mode + * @default 'dark' + */ + darkValue?: string; + /** + * Value to use for light mode + * @default 'light' + */ + lightValue?: string; +} + +export const ThemeScript: React.FC = ({ + storageKey = 'bridgewise-theme-mode', + attribute = 'data-theme', + defaultMode = 'system', + darkValue = 'dark', + lightValue = 'light', +}) => { + // Inline script that runs synchronously before React hydration + const script = ` +(function() { + try { + var storageKey = '${storageKey}'; + var attribute = '${attribute}'; + var defaultMode = '${defaultMode}'; + var darkValue = '${darkValue}'; + var lightValue = '${lightValue}'; + + var stored = localStorage.getItem(storageKey); + var mode = stored || defaultMode; + + var resolvedMode = mode === 'system' + ? (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light') + : mode; + + var value = resolvedMode === 'dark' ? darkValue : lightValue; + + if (attribute === 'class') { + document.documentElement.classList.add(value); + } else { + document.documentElement.setAttribute(attribute, value); + } + } catch (e) { + // Fail silently in case of errors + } +})(); + `.trim(); + + return ( +