diff --git a/client/.gitignore b/client/.gitignore index bbcc4b5..8b826f7 100644 --- a/client/.gitignore +++ b/client/.gitignore @@ -41,3 +41,6 @@ wrangler.toml.lock # typescript *.tsbuildinfo next-env.d.ts + +# swc plugin cache +/.swc/ diff --git a/client/next.config.ts b/client/next.config.ts index 66e1566..5919d7d 100644 --- a/client/next.config.ts +++ b/client/next.config.ts @@ -3,6 +3,8 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { /* config options here */ reactCompiler: true, + // Pin the workspace root — a stray lockfile in $HOME otherwise wins root inference. + turbopack: { root: __dirname }, }; export default nextConfig; diff --git a/client/src/__tests__/components/auth/GoogleAuthButton.test.tsx b/client/src/__tests__/components/auth/GoogleAuthButton.test.tsx deleted file mode 100644 index 23a28d9..0000000 --- a/client/src/__tests__/components/auth/GoogleAuthButton.test.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { render, screen, fireEvent } from '@testing-library/react'; -import { GoogleAuthButton } from '@/components/auth/GoogleAuthButton'; -import { isFirebaseConfigured } from '@/lib/firebase'; - -jest.mock('@/lib/firebase', () => ({ - isFirebaseConfigured: jest.fn(), - getFirebaseAuth: jest.fn(), -})); - -const mockIsFirebaseConfigured = isFirebaseConfigured as jest.MockedFunction; - -describe('GoogleAuthButton', () => { - it('does not render when Firebase is not configured', () => { - mockIsFirebaseConfigured.mockReturnValue(false); - const { container } = render( - - ); - expect(container.firstChild).toBeNull(); - }); - - it('renders the button when Firebase is configured', () => { - mockIsFirebaseConfigured.mockReturnValue(true); - render(); - expect(screen.getByRole('button', { name: /Sign in with Google/i })).toBeInTheDocument(); - }); - - it('calls onClick when clicked', () => { - mockIsFirebaseConfigured.mockReturnValue(true); - const onClick = jest.fn().mockResolvedValue(undefined); - render(); - fireEvent.click(screen.getByRole('button')); - expect(onClick).toHaveBeenCalledTimes(1); - }); - - it('shows "Please wait..." and disables button when busy', () => { - mockIsFirebaseConfigured.mockReturnValue(true); - render(); - expect(screen.getByRole('button')).toBeDisabled(); - expect(screen.getByText('Please wait...')).toBeInTheDocument(); - }); -}); diff --git a/client/src/__tests__/components/auth/GoogleAuthPanel.test.tsx b/client/src/__tests__/components/auth/GoogleAuthPanel.test.tsx new file mode 100644 index 0000000..86e105b --- /dev/null +++ b/client/src/__tests__/components/auth/GoogleAuthPanel.test.tsx @@ -0,0 +1,70 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { GoogleAuthPanel } from '@/components/auth/GoogleAuthPanel'; +import { useAuth } from '@/contexts/AuthContext'; +import { isFirebaseConfigured } from '@/lib/firebase'; + +const push = jest.fn(); +jest.mock('next/navigation', () => ({ useRouter: () => ({ push }) })); +jest.mock('@/contexts/AuthContext', () => ({ useAuth: jest.fn() })); +jest.mock('@/lib/firebase', () => ({ isFirebaseConfigured: jest.fn() })); +jest.mock('react-hot-toast', () => ({ __esModule: true, default: { success: jest.fn(), error: jest.fn() } })); + +const mockUseAuth = useAuth as jest.MockedFunction; +const mockConfigured = isFirebaseConfigured as jest.MockedFunction; + +const authState = (over: Partial> = {}) => ({ + user: null, + loading: false, + loginWithGoogle: jest.fn().mockResolvedValue(undefined), + logout: jest.fn(), + refreshUser: jest.fn(), + ...over, +}); + +beforeEach(() => { + push.mockClear(); + mockConfigured.mockReturnValue(true); + mockUseAuth.mockReturnValue(authState()); +}); + +describe('GoogleAuthPanel', () => { + it('offers Google as the only credential in signin mode', () => { + render(); + expect(screen.getByRole('button', { name: /Continue with Google/i })).toBeInTheDocument(); + expect(document.querySelectorAll('input')).toHaveLength(0); + }); + + it('offers Google as the only credential in signup mode', () => { + render(); + expect(screen.getByRole('button', { name: /Sign up with Google/i })).toBeInTheDocument(); + expect(document.querySelectorAll('input')).toHaveLength(0); + }); + + it('signs in and redirects to the dashboard', async () => { + const loginWithGoogle = jest.fn().mockResolvedValue(undefined); + mockUseAuth.mockReturnValue(authState({ loginWithGoogle })); + + render(); + await userEvent.click(screen.getByRole('button', { name: /Continue with Google/i })); + + await waitFor(() => expect(loginWithGoogle).toHaveBeenCalledTimes(1)); + expect(push).toHaveBeenCalledWith('/dashboard'); + }); + + // Without a password fallback, an unconfigured Firebase is a dead end — the + // page has to say so rather than render an empty card. + it('explains the dead end when Firebase is not configured', () => { + mockConfigured.mockReturnValue(false); + render(); + + expect(screen.queryByRole('button', { name: /Google/i })).not.toBeInTheDocument(); + expect(screen.getByText(/isn't configured on this deployment/i)).toBeInTheDocument(); + }); + + it('redirects an already-authenticated visitor away', async () => { + mockUseAuth.mockReturnValue(authState({ user: { id: '1', name: 'Ada', email: 'ada@example.com' } as any })); + render(); + await waitFor(() => expect(push).toHaveBeenCalledWith('/dashboard')); + }); +}); diff --git a/client/src/__tests__/contexts/AuthContext.test.tsx b/client/src/__tests__/contexts/AuthContext.test.tsx index b810ee5..46499d0 100644 --- a/client/src/__tests__/contexts/AuthContext.test.tsx +++ b/client/src/__tests__/contexts/AuthContext.test.tsx @@ -5,9 +5,7 @@ import { api } from '@/lib/api'; jest.mock('@/lib/api', () => ({ api: { getCurrentUser: jest.fn(), - login: jest.fn(), logout: jest.fn(), - register: jest.fn(), googleAuth: jest.fn(), }, })); @@ -64,34 +62,6 @@ describe('AuthContext', () => { await waitFor(() => expect(screen.getByText('User: Alice')).toBeInTheDocument()); }); - it('login() sets user on success', async () => { - mockApi.getCurrentUser.mockRejectedValueOnce(new Error('Not authenticated')); - mockApi.login.mockResolvedValueOnce({ - success: true, - data: { user: fakeUser }, - message: 'ok', - }); - - function LoginTester() { - const { user, loading, login } = useAuth(); - if (loading) return
Loading
; - return ( -
- {user ? User: {user.name} : } -
- ); - } - - render(); - await waitFor(() => screen.getByRole('button', { name: 'Login' })); - - await act(async () => { - screen.getByRole('button', { name: 'Login' }).click(); - }); - - await waitFor(() => expect(screen.getByText('User: Alice')).toBeInTheDocument()); - }); - it('logout() clears user', async () => { mockApi.getCurrentUser.mockResolvedValueOnce({ success: true, @@ -122,29 +92,4 @@ describe('AuthContext', () => { await waitFor(() => expect(screen.getByText('Logged out')).toBeInTheDocument()); expect(mockApi.logout).toHaveBeenCalledTimes(1); }); - - it('register() calls api.register', async () => { - mockApi.getCurrentUser.mockRejectedValueOnce(new Error('Not authenticated')); - mockApi.register.mockResolvedValueOnce({ success: true, message: 'ok' }); - - function RegisterTester() { - const { loading, register } = useAuth(); - if (loading) return
Loading
; - return ; - } - - render(); - await waitFor(() => screen.getByRole('button', { name: 'Register' })); - - await act(async () => { - screen.getByRole('button', { name: 'Register' }).click(); - }); - - expect(mockApi.register).toHaveBeenCalledWith({ - name: 'Alice', - email: 'a@b.com', - password: 'pass', - confirmPassword: 'pass', - }); - }); }); diff --git a/client/src/__tests__/unit/useTheme.test.ts b/client/src/__tests__/unit/useTheme.test.ts deleted file mode 100644 index 8fd8f35..0000000 --- a/client/src/__tests__/unit/useTheme.test.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { renderHook, act } from '@testing-library/react'; -import { useTheme } from '@/hooks/useTheme'; - -describe('useTheme', () => { - beforeEach(() => { - localStorage.clear(); - document.documentElement.removeAttribute('data-theme'); - }); - - it('defaults to dark theme when localStorage is empty', () => { - const { result } = renderHook(() => useTheme()); - expect(result.current.theme).toBe('dark'); - }); - - it('does not set data-theme attribute for dark (relies on :root defaults)', () => { - renderHook(() => useTheme()); - expect(document.documentElement.getAttribute('data-theme')).toBeNull(); - }); - - it('reads light theme from localStorage on mount', () => { - localStorage.setItem('theme', 'light'); - const { result } = renderHook(() => useTheme()); - expect(result.current.theme).toBe('light'); - }); - - it('sets data-theme="light" on html element when theme is light', () => { - localStorage.setItem('theme', 'light'); - renderHook(() => useTheme()); - expect(document.documentElement.getAttribute('data-theme')).toBe('light'); - }); - - it('toggleTheme switches from dark to light', () => { - const { result } = renderHook(() => useTheme()); - expect(result.current.theme).toBe('dark'); - - act(() => { result.current.toggleTheme(); }); - - expect(result.current.theme).toBe('light'); - }); - - it('toggleTheme switches from light to dark', () => { - localStorage.setItem('theme', 'light'); - const { result } = renderHook(() => useTheme()); - - act(() => { result.current.toggleTheme(); }); - - expect(result.current.theme).toBe('dark'); - }); - - it('toggleTheme persists the new value to localStorage', () => { - const { result } = renderHook(() => useTheme()); - - act(() => { result.current.toggleTheme(); }); - - expect(localStorage.getItem('theme')).toBe('light'); - }); - - it('toggling to light sets data-theme="light" on html element', () => { - const { result } = renderHook(() => useTheme()); - - act(() => { result.current.toggleTheme(); }); - - expect(document.documentElement.getAttribute('data-theme')).toBe('light'); - }); - - it('toggling back to dark removes data-theme attribute from html element', () => { - localStorage.setItem('theme', 'light'); - const { result } = renderHook(() => useTheme()); - - act(() => { result.current.toggleTheme(); }); - - expect(document.documentElement.getAttribute('data-theme')).toBeNull(); - }); - - it('double toggle returns to the original theme', () => { - const { result } = renderHook(() => useTheme()); - expect(result.current.theme).toBe('dark'); - - act(() => { result.current.toggleTheme(); }); - act(() => { result.current.toggleTheme(); }); - - expect(result.current.theme).toBe('dark'); - expect(localStorage.getItem('theme')).toBe('dark'); - expect(document.documentElement.getAttribute('data-theme')).toBeNull(); - }); - - it('ignores invalid localStorage values and defaults to dark', () => { - localStorage.setItem('theme', 'invalid-value'); - const { result } = renderHook(() => useTheme()); - expect(result.current.theme).toBe('dark'); - }); -}); diff --git a/client/src/app/dashboard/page.tsx b/client/src/app/dashboard/page.tsx index 55070ea..6222c7e 100644 --- a/client/src/app/dashboard/page.tsx +++ b/client/src/app/dashboard/page.tsx @@ -235,7 +235,11 @@ export default function DashboardPage() { { l: 'Active balancers', v: loadBalancers.filter(b => b.status === 'active').length, sub: `of ${loadBalancers.length} total` }, { l: 'Origins total', v: loadBalancers.reduce((a, b) => a + (b.originCount || 0), 0), sub: 'all checks passing', color: 'var(--green)' }, ].map((s, i) => ( -
+
{s.l}
{s.v} @@ -269,9 +273,11 @@ export default function DashboardPage() { onClick={() => setStatusFilter(value)} className="btn btn-sm" style={{ - background: isActive ? 'var(--bg-2)' : 'transparent', - color: isActive ? 'var(--text)' : 'var(--text-3)', - border: '1px solid var(--line)', + background: isActive ? 'var(--accent-dim)' : '#ffffff0d', + color: isActive ? 'var(--accent)' : 'var(--text-3)', + border: `1px solid ${isActive ? 'var(--accent)' : 'var(--line)'}`, + borderRadius: 999, + fontWeight: isActive ? 600 : 500, fontSize: 'clamp(12px, 2vw, 13px)', padding: 'clamp(6px, 1vw, 8px) clamp(10px, 2vw, 12px)', }} diff --git a/client/src/app/globals.css b/client/src/app/globals.css index 0eccc2b..60e434a 100644 --- a/client/src/app/globals.css +++ b/client/src/app/globals.css @@ -1,50 +1,42 @@ -@import "tailwindcss"; +/* source("../") bounds auto-detection to src/ — otherwise it crawls from the git + root (the whole monorepo) on every PostCSS pass, which Turbopack runs in a + fresh ~115MB Node subprocess. */ +@import "tailwindcss" source("../"); /* EdgeBalancer — 2026 */ :root { - --bg: oklch(0.17 0.008 60); - --bg-1: oklch(0.21 0.01 60); - --bg-2: oklch(0.24 0.012 60); - --bg-3: oklch(0.28 0.014 60); - --line: oklch(0.32 0.012 60); - --line-2: oklch(0.38 0.014 60); - --text: oklch(0.96 0.005 80); - --text-2: oklch(0.78 0.008 70); - --text-3: oklch(0.58 0.008 70); - --accent: oklch(0.80 0.17 70); - --accent-2: oklch(0.88 0.13 85); - --accent-dim: oklch(0.80 0.17 70 / 0.12); - --green: oklch(0.78 0.14 150); - --red: oklch(0.68 0.20 25); - --blue: oklch(0.75 0.12 240); - --radius: 6px; - --radius-lg: 10px; - --shadow: 0 1px 0 0 oklch(1 0 0 / 0.04) inset, 0 0 0 1px var(--line); - --mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace; - --sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; -} - -[data-theme="light"] { - --bg: oklch(0.97 0.003 80); - --bg-1: oklch(0.94 0.004 80); - --bg-2: oklch(0.91 0.005 80); - --bg-3: oklch(0.87 0.006 80); - --line: oklch(0.82 0.008 70); - --line-2: oklch(0.74 0.010 70); - --text: oklch(0.20 0.012 60); - --text-2: oklch(0.38 0.010 65); - --text-3: oklch(0.55 0.008 70); - --accent: oklch(0.52 0.17 55); - --accent-2: oklch(0.44 0.15 55); - --accent-dim: oklch(0.52 0.17 55 / 0.10); - --green: oklch(0.45 0.14 150); - --red: oklch(0.50 0.20 25); - --blue: oklch(0.48 0.12 240); - --shadow: 0 1px 3px oklch(0 0 0 / 0.12), 0 0 0 1px var(--line); + --bg: #0a0a0f; + --bg-1: #14141a; + --bg-2: #1a1a1e; + --bg-3: #26262c; + --line: #ffffff1a; + --line-2: #ffffff26; + --text: #e0e7ff; + --text-2: #cad5e2; + --text-3: #62748e; + --accent: #f59e0b; + --accent-2: #fbbf24; + --accent-3: #fcd34d; + --accent-dim: #f59e0b1f; + --orange: #fe6e00; + --orange-2: #ff8b1a; + --green: #00d294; + --red: #ff6568; + --blue: #54a2ff; + --violet: #a685ff; + --radius: 0.5rem; + --radius-lg: 0.75rem; + --radius-xl: 1rem; + --shadow: 0 1px 0 0 #ffffff0a inset, 0 0 0 1px var(--line); + --glass: #1a1a1e99; + --glass-hover: #1a1a1ed9; + --mono: var(--font-jetbrains-mono), ui-monospace, SFMono-Regular, Menlo, monospace; + --sans: var(--font-rubik), 'Segoe UI', -apple-system, Arial, sans-serif; } * { box-sizing: border-box; } html, body { margin: 0; padding: 0; } +html { scroll-behavior: smooth; } body { font-family: var(--sans); background: var(--bg); @@ -62,8 +54,8 @@ a { color: inherit; text-decoration: none; } .topo { position: absolute; inset: 0; pointer-events: none; opacity: 0.5; background-image: - radial-gradient(circle at 20% 0%, oklch(0.80 0.17 70 / 0.08), transparent 50%), - radial-gradient(circle at 80% 100%, oklch(0.75 0.12 240 / 0.06), transparent 50%); + radial-gradient(circle at 20% 0%, #f59e0b14, transparent 50%), + radial-gradient(circle at 80% 100%, #54a2ff0f, transparent 50%); } .grid-bg { position: absolute; inset: 0; pointer-events: none; @@ -77,6 +69,8 @@ a { color: inherit; text-decoration: none; } /* Typography */ .mono { font-family: var(--mono); } +.nav-link { transition: color 150ms; } +.nav-link:hover { color: var(--accent-2); } .kicker { font-family: var(--mono); font-size: 11px; @@ -96,16 +90,26 @@ a { color: inherit; text-decoration: none; } overflow: hidden; } .btn:active { transform: scale(0.98); } -.btn-primary { background: var(--accent); color: var(--bg); } +.btn-primary { + background-image: linear-gradient(to right, var(--accent), var(--orange)); + color: #fff; + font-weight: 600; + border-radius: var(--radius-lg); + box-shadow: 0 4px 24px #f59e0b4d; + transition: all 200ms cubic-bezier(0.4, 0, 0.2, 1); +} .btn-primary:hover { - background: var(--accent-2); + background-image: linear-gradient(to right, var(--accent-2), var(--orange-2)); transform: translateY(-1px); - box-shadow: 0 4px 12px var(--accent-dim); + box-shadow: 0 4px 32px #f59e0b73; +} +.btn-ghost { + color: var(--text-2); border-color: var(--line-2); + background: #ffffff0d; border-radius: var(--radius-lg); font-weight: 600; } -.btn-ghost { color: var(--text); border-color: var(--line-2); background: transparent; } -.btn-ghost:hover { background: var(--bg-2); border-color: var(--text-3); } -.btn-dark { background: var(--bg-2); color: var(--text); border-color: var(--line-2); } -.btn-dark:hover { background: var(--bg-3); border-color: var(--line); } +.btn-ghost:hover { background: #ffffff1a; border-color: #f59e0b66; color: #fff; } +.btn-dark { background: var(--bg-2); color: var(--text); border-color: var(--line-2); border-radius: var(--radius-lg); } +.btn-dark:hover { background: var(--bg-3); border-color: #f59e0b66; } .btn-lg { padding: 14px 22px; font-size: 15px; } .btn-sm { padding: 6px 12px; font-size: 13px; } @@ -152,6 +156,35 @@ a { color: inherit; text-decoration: none; } transform: translateY(0); } +/* Glass feature card */ +.feature-card { + background: var(--glass); + border: 1px solid var(--line); + border-radius: var(--radius-xl); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + transition: all 300ms cubic-bezier(0.4, 0, 0.2, 1); +} +.feature-card:hover { + background: var(--glass-hover); + border-color: #fbbf2459; +} +.feature-card-lift:hover { transform: translateY(-2px); } + +/* Accent treatments */ +.gradient-text { + color: transparent; + background-image: linear-gradient(90deg, #fbbf24, #a855f7, #3b82f6); + -webkit-background-clip: text; + background-clip: text; +} +.glow { box-shadow: 0 0 40px #f59e0b40; } + +/* FAQ disclosure */ +details > summary { cursor: pointer; } +details > summary::-webkit-details-marker { display: none; } +details[open] > summary svg { transform: rotate(180deg); } + /* Chip */ .chip { display: inline-flex; align-items: center; gap: 6px; @@ -195,6 +228,13 @@ a { color: inherit; text-decoration: none; } @keyframes spin { to { transform: rotate(360deg); } } +@keyframes slideUp { + from { opacity: 0; transform: translateY(20px); } + to { opacity: 1; transform: translateY(0); } +} +@keyframes ping { + 75%, 100% { opacity: 0; transform: scale(2); } +} /* Scroll-triggered animations */ @keyframes fadeInUp { @@ -239,6 +279,11 @@ a { color: inherit; text-decoration: none; } .scale-in { animation: scaleIn 0.5s ease-out forwards; } .slide-in { animation: slideIn 240ms ease-out; } +/* Perry-style entrance animations (run immediately, no observer) */ +.animate-fade-in { opacity: 0; animation: fadeIn 0.5s ease-out forwards; } +.animate-slide-up { opacity: 0; animation: slideUp 0.5s ease-out forwards; } +.animate-ping { animation: ping 1s cubic-bezier(0, 0, 0.2, 1) infinite; } + /* Stagger delays for children */ .stagger-1 { animation-delay: 0.1s; } .stagger-2 { animation-delay: 0.2s; } @@ -256,8 +301,9 @@ a { color: inherit; text-decoration: none; } @media (max-width: 768px) { .hide-md { display: none !important; } - .two-col { grid-template-columns: 1fr !important; } - .cmp-grid { grid-template-columns: 1fr !important; } + .two-col, + .cmp-grid, + .auth-grid { grid-template-columns: 1fr !important; } .vs-col { display: none !important; } nav { padding: 16px 24px !important; } footer { padding: 16px 24px !important; } diff --git a/client/src/app/layout.tsx b/client/src/app/layout.tsx index 7b9b53f..03bead7 100644 --- a/client/src/app/layout.tsx +++ b/client/src/app/layout.tsx @@ -1,16 +1,17 @@ import type { Metadata } from "next"; -import { Geist, Geist_Mono } from "next/font/google"; +import { Rubik, JetBrains_Mono } from "next/font/google"; import "./globals.css"; import { AuthProvider } from "@/contexts/AuthContext"; import { ToastProvider } from '@/components/providers/ToastProvider'; -const geistSans = Geist({ - variable: "--font-geist-sans", +const rubik = Rubik({ + variable: "--font-rubik", subsets: ["latin"], + weight: ["400", "500", "600", "700"], }); -const geistMono = Geist_Mono({ - variable: "--font-geist-mono", +const jetbrainsMono = JetBrains_Mono({ + variable: "--font-jetbrains-mono", subsets: ["latin"], }); @@ -27,13 +28,9 @@ export default function RootLayout({ return ( - - {/* Apply stored theme before first paint to prevent flash */} -