From 447b5e872b000f46a1e66dc7264efbdcfd6e4d41 Mon Sep 17 00:00:00 2001 From: Michael Yankelev Date: Fri, 31 Jul 2026 15:10:10 +0200 Subject: [PATCH 1/3] feat: harvest the v1 login UI and rewire it to the facade Brings back the login page, its three methods, and the terminal styling from v1, driving them through the P2 login handoff instead of the v1 vault bootstrap: Core Kit authenticates on the UI thread, the exported secret is transferred once to the engine, and every derivation happens in Rust. Wallet login collects the SIWE signature with wagmi and forwards it to facade.siweLogin; logout runs facade.logout plus the Core Kit teardown. Closes #804 --- apps/web/src/App.test.tsx | 10 +- apps/web/src/auth/CoreKitProvider.tsx | 69 +++ apps/web/src/auth/coreKit.ts | 110 +++++ apps/web/src/auth/siweNonce.test.ts | 39 ++ apps/web/src/auth/siweNonce.ts | 21 + apps/web/src/auth/useAuth.test.tsx | 142 ++++++ apps/web/src/auth/useAuth.ts | 139 ++++++ apps/web/src/components/MatrixBackground.tsx | 77 +++ apps/web/src/components/StagingBanner.tsx | 17 + .../src/components/auth/EmailLoginForm.tsx | 52 ++ .../src/components/auth/GoogleLoginButton.tsx | 25 + apps/web/src/components/auth/LogoutButton.tsx | 29 ++ .../src/components/auth/WalletLoginButton.tsx | 151 ++++++ apps/web/src/engine/config.ts | 7 +- apps/web/src/index.css | 162 ++++++ apps/web/src/lib/wagmi.ts | 14 + apps/web/src/main.tsx | 27 +- apps/web/src/routes/FilesPage.tsx | 2 + apps/web/src/routes/LoginPage.tsx | 102 +++- apps/web/src/styles/login.css | 465 ++++++++++++++++++ apps/web/src/test/authFakes.tsx | 113 +++++ apps/web/src/vite-env.d.ts | 5 + 22 files changed, 1762 insertions(+), 16 deletions(-) create mode 100644 apps/web/src/auth/CoreKitProvider.tsx create mode 100644 apps/web/src/auth/coreKit.ts create mode 100644 apps/web/src/auth/siweNonce.test.ts create mode 100644 apps/web/src/auth/siweNonce.ts create mode 100644 apps/web/src/auth/useAuth.test.tsx create mode 100644 apps/web/src/auth/useAuth.ts create mode 100644 apps/web/src/components/MatrixBackground.tsx create mode 100644 apps/web/src/components/StagingBanner.tsx create mode 100644 apps/web/src/components/auth/EmailLoginForm.tsx create mode 100644 apps/web/src/components/auth/GoogleLoginButton.tsx create mode 100644 apps/web/src/components/auth/LogoutButton.tsx create mode 100644 apps/web/src/components/auth/WalletLoginButton.tsx create mode 100644 apps/web/src/index.css create mode 100644 apps/web/src/lib/wagmi.ts create mode 100644 apps/web/src/styles/login.css create mode 100644 apps/web/src/test/authFakes.tsx diff --git a/apps/web/src/App.test.tsx b/apps/web/src/App.test.tsx index 9550e589f..cc609b905 100644 --- a/apps/web/src/App.test.tsx +++ b/apps/web/src/App.test.tsx @@ -2,12 +2,16 @@ import { render, screen } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; import { describe, expect, it } from 'vitest'; import { App } from './App'; +import { fakeCoreKitSession, fakeEngineClient, pageWrapper } from './test/authFakes'; function renderAt(path: string) { + const Providers = pageWrapper(fakeEngineClient().client, fakeCoreKitSession().session); return render( - - - + + + + + ); } diff --git a/apps/web/src/auth/CoreKitProvider.tsx b/apps/web/src/auth/CoreKitProvider.tsx new file mode 100644 index 000000000..d6dec7666 --- /dev/null +++ b/apps/web/src/auth/CoreKitProvider.tsx @@ -0,0 +1,69 @@ +import { createContext, useContext, useEffect, useRef, useState, type ReactNode } from 'react'; +import type { CoreKitSession } from './coreKit'; + +export interface CoreKitContextValue { + /** `null` until the session is built and its restore attempt has settled. */ + session: CoreKitSession | null; + /** True while the mount-time session restore is still in flight. */ + isRestoring: boolean; + /** Why Core Kit is unusable — a missing build config, or a failed restore. */ + error: string | null; +} + +const CoreKitContext = createContext(undefined); + +export interface CoreKitProviderProps { + /** Builds this tab's Core Kit session. Read once, on mount. */ + createSession: () => CoreKitSession; + children: ReactNode; +} + +/** + * Owns the tab's one Core Kit session and its mount-time restore. Construction + * runs in an effect so a StrictMode double-mount cannot leave two SDK instances + * racing for the same storage. + */ +export function CoreKitProvider({ createSession, children }: CoreKitProviderProps) { + const [value, setValue] = useState({ + session: null, + isRestoring: true, + error: null, + }); + const factory = useRef(createSession); + + useEffect(() => { + let live = true; + let session: CoreKitSession; + try { + session = factory.current(); + } catch (error) { + setValue({ session: null, isRestoring: false, error: message(error) }); + return; + } + + session + .restore() + .then(() => live && setValue({ session, isRestoring: false, error: null })) + // A failed restore still yields a usable session to log in with. + .catch( + (error: unknown) => live && setValue({ session, isRestoring: false, error: message(error) }) + ); + + return () => { + live = false; + }; + }, []); + + return {children}; +} + +function message(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +/** This tab's Core Kit session and its restore state. */ +export function useCoreKit(): CoreKitContextValue { + const value = useContext(CoreKitContext); + if (!value) throw new Error('auth hooks must be used within '); + return value; +} diff --git a/apps/web/src/auth/coreKit.ts b/apps/web/src/auth/coreKit.ts new file mode 100644 index 000000000..09743606a --- /dev/null +++ b/apps/web/src/auth/coreKit.ts @@ -0,0 +1,110 @@ +/** + * Web3Auth Core Kit on the UI thread (blueprint/web-client.md "Login and + * identity"): it owns its own popup/redirect flows, so it cannot live in the + * engine worker. Its only output the vault cares about is the login secret, + * which `engine/loginHandoff` transfers to the engine — every derivation from + * it happens in Rust. + */ + +import { COREKIT_STATUS, WEB3AUTH_NETWORK, Web3AuthMPCCoreKit } from '@web3auth/mpc-core-kit'; +import { tssLib } from '@toruslabs/tss-dkls-lib'; +import type { LoginSecretExporter } from '../engine/loginHandoff'; + +/** How a session was established; also the `authStore` login method. */ +export type CoreKitLoginMethod = 'google' | 'email'; + +/** + * The Core Kit surface the login flow drives. Narrow by construction: the hook + * never sees a Web3Auth parameter shape, and a test substitutes a plain object. + */ +export interface CoreKitSession extends LoginSecretExporter { + /** Restores a prior session, if the SDK has one on this device. */ + restore(): Promise; + /** True once a login (or a restore) has completed on this device. */ + isLoggedIn(): boolean; + login(method: CoreKitLoginMethod, email?: string): Promise; + /** How the live session was established, as Core Kit reports it. */ + method(): CoreKitLoginMethod; + /** The signed-in user's email, when the method carries one. */ + email(): string | null; + logout(): Promise; +} + +const NETWORK = { + local: WEB3AUTH_NETWORK.DEVNET, + ci: WEB3AUTH_NETWORK.DEVNET, + staging: WEB3AUTH_NETWORK.DEVNET, + production: WEB3AUTH_NETWORK.MAINNET, +} as const; + +type Environment = keyof typeof NETWORK; + +/** Adapts the Web3Auth SDK to the narrow session seam above. */ +class Web3AuthSession implements CoreKitSession { + constructor( + private readonly coreKit: Web3AuthMPCCoreKit, + private readonly verifier: string, + private readonly clientId: string + ) {} + + async restore(): Promise { + await this.coreKit.init(); + } + + isLoggedIn(): boolean { + return this.coreKit.status === COREKIT_STATUS.LOGGED_IN; + } + + async login(method: CoreKitLoginMethod, email?: string): Promise { + await this.coreKit.loginWithOAuth({ + subVerifierDetails: { + typeOfLogin: method === 'google' ? 'google' : 'email_passwordless', + verifier: this.verifier, + clientId: this.clientId, + ...(email ? { jwtParams: { login_hint: email } } : {}), + }, + }); + if (this.coreKit.status !== COREKIT_STATUS.LOGGED_IN) { + // REQUIRED_SHARE: MFA is on and this device holds no factor. Recovery and + // device approval are not built yet, so fail rather than half-log-in. + throw new Error('this device needs approval or a recovery phrase before it can sign in'); + } + await this.coreKit.commitChanges(); + } + + method(): CoreKitLoginMethod { + return this.coreKit.getUserInfo().typeOfLogin === 'google' ? 'google' : 'email'; + } + + email(): string | null { + return this.coreKit.getUserInfo().email ?? null; + } + + async logout(): Promise { + if (this.isLoggedIn()) await this.coreKit.logout(); + } + + _UNSAFE_exportTssKey(): Promise { + return this.coreKit._UNSAFE_exportTssKey(); + } +} + +/** Builds this tab's Core Kit session from the build-time environment. */ +export function createCoreKitSession(env: Partial): CoreKitSession { + const clientId = env.VITE_WEB3AUTH_CLIENT_ID; + const verifier = env.VITE_WEB3AUTH_VERIFIER; + if (!clientId || !verifier) { + throw new Error('VITE_WEB3AUTH_CLIENT_ID and VITE_WEB3AUTH_VERIFIER must both be configured'); + } + + const coreKit = new Web3AuthMPCCoreKit({ + web3AuthClientId: clientId, + web3AuthNetwork: NETWORK[(env.VITE_ENVIRONMENT ?? 'local') as Environment] ?? NETWORK.local, + // Session metadata only — the login secret is never written to storage + // (security rule 1); it leaves this realm as a transferred buffer. + storage: window.localStorage, + manualSync: true, + tssLib, + }); + return new Web3AuthSession(coreKit, verifier, clientId); +} diff --git a/apps/web/src/auth/siweNonce.test.ts b/apps/web/src/auth/siweNonce.test.ts new file mode 100644 index 000000000..9e2b13374 --- /dev/null +++ b/apps/web/src/auth/siweNonce.test.ts @@ -0,0 +1,39 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { requestSiweNonce } from './siweNonce'; + +function respond(body: unknown, status = 200) { + const response = new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }); + return vi.spyOn(globalThis, 'fetch').mockResolvedValue(response); +} + +describe('requestSiweNonce', () => { + afterEach(() => vi.restoreAllMocks()); + + it('posts to the API challenge endpoint and returns the nonce', async () => { + const fetchSpy = respond({ nonce: 'abc12345', expiresAt: '2026-01-01T00:00:00.000Z' }); + + await expect(requestSiweNonce('https://api.test')).resolves.toBe('abc12345'); + expect(fetchSpy).toHaveBeenCalledWith('https://api.test/auth/siwe/challenge', { + method: 'POST', + }); + }); + + it('refuses a nonce too weak or too malformed to sign over', async () => { + respond({ nonce: 'short' }); + await expect(requestSiweNonce('https://api.test')).rejects.toThrow(/unusable nonce/); + + respond({ nonce: 'not a nonce!' }); + await expect(requestSiweNonce('https://api.test')).rejects.toThrow(/unusable nonce/); + + respond({}); + await expect(requestSiweNonce('https://api.test')).rejects.toThrow(/unusable nonce/); + }); + + it('surfaces a refused challenge by status', async () => { + respond({ message: 'Too Many Requests' }, 429); + await expect(requestSiweNonce('https://api.test')).rejects.toThrow(/refused with 429/); + }); +}); diff --git a/apps/web/src/auth/siweNonce.ts b/apps/web/src/auth/siweNonce.ts new file mode 100644 index 000000000..a0089c5f4 --- /dev/null +++ b/apps/web/src/auth/siweNonce.ts @@ -0,0 +1,21 @@ +/** + * The single-use nonce an EIP-4361 message must carry. The engine owns the SIWE + * exchange itself (`facade.siweLogin`) but exposes no challenge command, so the + * page fetches the nonce from the API's public challenge endpoint — see #910 to + * move this below the facade. + */ + +/** EIP-4361 requires at least 8 alphanumeric characters. */ +const NONCE = /^[A-Za-z0-9]{8,}$/; + +export async function requestSiweNonce(apiBaseUrl: string): Promise { + const response = await fetch(`${apiBaseUrl}/auth/siwe/challenge`, { method: 'POST' }); + if (!response.ok) throw new Error(`siwe challenge refused with ${response.status}`); + + const { nonce } = (await response.json()) as { nonce?: unknown }; + // Fail closed: an unusable nonce must not reach the wallet as a signing prompt. + if (typeof nonce !== 'string' || !NONCE.test(nonce)) { + throw new Error('siwe challenge returned an unusable nonce'); + } + return nonce; +} diff --git a/apps/web/src/auth/useAuth.test.tsx b/apps/web/src/auth/useAuth.test.tsx new file mode 100644 index 000000000..1e975cf45 --- /dev/null +++ b/apps/web/src/auth/useAuth.test.tsx @@ -0,0 +1,142 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { authStore } from '../stores/auth.store'; +import { authWrapper, fakeCoreKitSession, fakeEngineClient, SECRET_HEX } from '../test/authFakes'; +import { useLoginSecretSource } from '../providers/EngineProvider'; +import { useAuth } from './useAuth'; + +const SECRET_BYTES = Uint8Array.from({ length: 32 }, () => 0x0f); + +/** Mounts `useAuth` alongside the secret source the failover path re-exports through. */ +function mount( + client: ReturnType, + coreKit: ReturnType +) { + return renderHook(() => ({ auth: useAuth(), secrets: useLoginSecretSource() }), { + wrapper: authWrapper(client.client, coreKit.session), + }); +} + +describe('useAuth', () => { + beforeEach(() => authStore.signedOut()); + + it('drives the Core Kit google flow and hands the engine the login secret', async () => { + const engine = fakeEngineClient(); + const coreKit = fakeCoreKitSession(); + const { result } = mount(engine, coreKit); + await waitFor(() => expect(result.current.auth.isReady).toBe(true)); + + await act(() => result.current.auth.loginWithGoogle()); + + expect(coreKit.calls.logins).toEqual([{ method: 'google', email: undefined }]); + expect(engine.calls.secrets).toEqual([SECRET_BYTES]); + expect(authStore.getState()).toMatchObject({ + isAuthenticated: true, + method: 'google', + email: 'user@example.test', + }); + }); + + it('passes the typed address to the Core Kit email flow before the handoff', async () => { + const engine = fakeEngineClient(); + const coreKit = fakeCoreKitSession(); + const { result } = mount(engine, coreKit); + await waitFor(() => expect(result.current.auth.isReady).toBe(true)); + + await act(() => result.current.auth.loginWithEmail('user@example.test')); + + expect(coreKit.calls.logins).toEqual([{ method: 'email', email: 'user@example.test' }]); + expect(engine.calls.secrets).toEqual([SECRET_BYTES]); + }); + + it('routes a wallet signature to the facade and exports no secret for it', async () => { + const engine = fakeEngineClient(); + const coreKit = fakeCoreKitSession(); + const { result } = mount(engine, coreKit); + await waitFor(() => expect(result.current.auth.isReady).toBe(true)); + + const signature = new Uint8Array(65).fill(7); + await act(() => result.current.auth.loginWithWallet('siwe-message', signature)); + + expect(engine.calls.siwe).toEqual([{ message: 'siwe-message', signature }]); + expect(engine.calls.started).toEqual([]); + expect(coreKit.calls.exports).toBe(0); + expect(authStore.getState()).toMatchObject({ isAuthenticated: true, method: 'wallet' }); + }); + + it('tears down the engine and the Core Kit session on logout', async () => { + const engine = fakeEngineClient(); + const coreKit = fakeCoreKitSession(); + const { result } = mount(engine, coreKit); + await waitFor(() => expect(result.current.auth.isReady).toBe(true)); + await act(() => result.current.auth.loginWithGoogle()); + const secrets = result.current.secrets!; + + await act(() => result.current.auth.logout()); + + expect(engine.calls.logouts).toBe(1); + expect(coreKit.calls.logouts).toBe(1); + expect(authStore.getState().isAuthenticated).toBe(false); + // The re-export capability must not outlive the session it belonged to. + await expect(secrets.provideSecret()).rejects.toThrow(/no login session/); + }); + + it('tears the Core Kit session down even when the engine refuses to log out', async () => { + const engine = fakeEngineClient({ logout: () => Promise.reject(new Error('engine gone')) }); + const coreKit = fakeCoreKitSession(); + const { result } = mount(engine, coreKit); + await waitFor(() => expect(result.current.auth.isReady).toBe(true)); + + await act(async () => { + await result.current.auth.logout().catch(() => undefined); + }); + + expect(coreKit.calls.logouts).toBe(1); + expect(authStore.getState().isAuthenticated).toBe(false); + expect(result.current.auth.error).toBe('engine gone'); + }); + + it('hands the secret over for a Core Kit session that survived the reload', async () => { + const engine = fakeEngineClient(); + const coreKit = fakeCoreKitSession({ loggedIn: true }); + const { result } = mount(engine, coreKit); + + await waitFor(() => expect(result.current.auth.isAuthenticated).toBe(true)); + + expect(coreKit.calls.logins).toEqual([]); + expect(engine.calls.secrets).toEqual([SECRET_BYTES]); + }); + + it('leaves the tab signed out and disarmed when the engine refuses the secret', async () => { + const engine = fakeEngineClient({ start: () => Promise.reject(new Error('trust violation')) }); + const coreKit = fakeCoreKitSession(); + const { result } = mount(engine, coreKit); + await waitFor(() => expect(result.current.auth.isReady).toBe(true)); + const secrets = result.current.secrets!; + + await act(async () => { + await result.current.auth.loginWithGoogle().catch(() => undefined); + }); + + expect(authStore.getState().isAuthenticated).toBe(false); + expect(result.current.auth.error).toBe('trust violation'); + await expect(secrets.provideSecret()).rejects.toThrow(/no login session/); + // The refused buffer is scrubbed rather than left holding the scalar. + expect(new Uint8Array(engine.calls.started[0])).toEqual(new Uint8Array(32)); + }); + + it('writes nothing key-shaped to browser storage', async () => { + localStorage.clear(); + sessionStorage.clear(); + const engine = fakeEngineClient(); + const coreKit = fakeCoreKitSession(); + const { result } = mount(engine, coreKit); + await waitFor(() => expect(result.current.auth.isReady).toBe(true)); + + await act(() => result.current.auth.loginWithGoogle()); + + expect(localStorage.length).toBe(0); + expect(sessionStorage.length).toBe(0); + expect(JSON.stringify(authStore.getState())).not.toContain(SECRET_HEX); + }); +}); diff --git a/apps/web/src/auth/useAuth.ts b/apps/web/src/auth/useAuth.ts new file mode 100644 index 000000000..edfb2fd47 --- /dev/null +++ b/apps/web/src/auth/useAuth.ts @@ -0,0 +1,139 @@ +/** + * The login flow, rewired onto the facade (blueprint/web-client.md "Login and + * identity"). Core Kit authenticates the person on the UI thread; the only + * thing that crosses into the vault is the login secret, transferred once by + * `handOffLoginSecret`. Nothing here derives a key, holds a token, or talks to + * the API — `facade.start` runs the engine's own challenge-signature login. + */ + +import { useCallback, useEffect, useRef, useState } from 'react'; +import { handOffLoginSecret } from '../engine/loginHandoff'; +import { authStore, useAuthState } from '../stores/auth.store'; +import { useEngine, useLoginSecretSource } from '../providers/EngineProvider'; +import { useCoreKit } from './CoreKitProvider'; +import type { CoreKitLoginMethod } from './coreKit'; + +export interface Auth { + isAuthenticated: boolean; + /** True while the tab is still assembling its engine or Core Kit session. */ + isReady: boolean; + /** True while a restore, login, or logout is in flight. */ + isBusy: boolean; + /** The last failure, already stripped of anything secret-shaped. */ + error: string | null; + loginWithGoogle(): Promise; + loginWithEmail(email: string): Promise; + /** Exchanges a wallet-signed SIWE message; secondary to the Core Kit methods. */ + loginWithWallet(message: string, signature: Uint8Array): Promise; + logout(): Promise; +} + +export function useAuth(): Auth { + const client = useEngine(); + const secrets = useLoginSecretSource(); + const { session, isRestoring, error: coreKitError } = useCoreKit(); + const { isAuthenticated } = useAuthState(); + + const [isBusy, setIsBusy] = useState(false); + const [error, setError] = useState(null); + const inFlight = useRef(false); + + const isReady = client !== null && session !== null && !isRestoring; + + /** + * Serializes the auth transitions: `start` is once-per-engine, and Core Kit's + * popup flows do not survive being opened twice. + */ + const exclusively = useCallback(async (step: () => Promise): Promise => { + if (inFlight.current) return; + inFlight.current = true; + setIsBusy(true); + setError(null); + try { + await step(); + } catch (failure) { + setError(failure instanceof Error ? failure.message : String(failure)); + throw failure; + } finally { + inFlight.current = false; + setIsBusy(false); + } + }, []); + + /** + * The Core Kit → engine handoff. The secret source is armed first so a + * leadership failover mid-start can re-export it, and disarmed if the engine + * refuses the secret. + */ + const handOff = useCallback(async (): Promise => { + if (!client || !session) throw new Error('the engine is not ready to accept a login'); + secrets?.use(session); + try { + await handOffLoginSecret(client, session); + } catch (failure) { + secrets?.use(null); + throw failure; + } + authStore.signedIn(session.method(), session.email()); + }, [client, secrets, session]); + + const login = useCallback( + (method: CoreKitLoginMethod, email?: string) => + exclusively(async () => { + if (!session) throw new Error('the login provider is not ready'); + await session.login(method, email); + await handOff(); + }), + [exclusively, handOff, session] + ); + + const loginWithGoogle = useCallback(() => login('google'), [login]); + const loginWithEmail = useCallback((email: string) => login('email', email), [login]); + + const loginWithWallet = useCallback( + (message: string, signature: Uint8Array) => + exclusively(async () => { + if (!client) throw new Error('the engine is not ready to accept a login'); + await client.facade.siweLogin(message, signature); + authStore.signedIn('wallet'); + }), + [client, exclusively] + ); + + const logout = useCallback( + () => + exclusively(async () => { + // Every leg runs: a refused engine zeroize must not strand the Core Kit + // session, and a failed Core Kit logout must not leave the UI signed in. + const failures = await Promise.allSettled([ + client?.facade.logout() ?? Promise.resolve(), + session?.logout() ?? Promise.resolve(), + ]); + secrets?.use(null); + authStore.signedOut(); + const failed = failures.find((outcome) => outcome.status === 'rejected'); + if (failed) throw failed.reason as Error; + }), + [client, exclusively, secrets, session] + ); + + // A Core Kit session that survived the reload still has to hand the engine its + // secret; without this the tab renders logged-out over a live login. + const restored = useRef(false); + useEffect(() => { + if (restored.current || !isReady || isAuthenticated || !session?.isLoggedIn()) return; + restored.current = true; + exclusively(handOff).catch(() => undefined); + }, [exclusively, handOff, isAuthenticated, isReady, session]); + + return { + isAuthenticated, + isReady, + isBusy: isBusy || isRestoring, + error: error ?? coreKitError, + loginWithGoogle, + loginWithEmail, + loginWithWallet, + logout, + }; +} diff --git a/apps/web/src/components/MatrixBackground.tsx b/apps/web/src/components/MatrixBackground.tsx new file mode 100644 index 000000000..51d2d343a --- /dev/null +++ b/apps/web/src/components/MatrixBackground.tsx @@ -0,0 +1,77 @@ +import { useEffect, useRef } from 'react'; + +interface MatrixBackgroundProps { + /** Canvas opacity. */ + opacity?: number; + /** Frame interval in ms; 16 is ~60fps. */ + frameInterval?: number; +} + +const FONT_SIZE = 14; +const COLUMN_WIDTH = 20; +const CHARACTERS = '01'; +const PRIMARY_COLOR = '#00D084'; +const DIM_COLOR = '#006644'; + +/** Decorative falling-bits canvas behind the login panel. */ +export function MatrixBackground({ opacity = 0.5, frameInterval = 16 }: MatrixBackgroundProps) { + const canvasRef = useRef(null); + const animationRef = useRef(0); + + useEffect(() => { + const canvas = canvasRef.current; + const context = canvas?.getContext('2d'); + if (!canvas || !context) return; + + let columns: number[] = []; + let lastFrameTime = 0; + + const resize = () => { + canvas.width = window.innerWidth; + canvas.height = window.innerHeight; + // Stagger the starts above the viewport so columns do not fall in lockstep. + columns = Array.from({ length: Math.floor(canvas.width / COLUMN_WIDTH) }, () => + Math.floor(Math.random() * -100) + ); + }; + + const draw = (timestamp: number) => { + animationRef.current = requestAnimationFrame(draw); + if (timestamp - lastFrameTime < frameInterval) return; + lastFrameTime = timestamp; + + // Fade the previous frame rather than clearing it: that is the trail. + context.fillStyle = 'rgba(0, 0, 0, 0.05)'; + context.fillRect(0, 0, canvas.width, canvas.height); + context.font = `${FONT_SIZE}px "JetBrains Mono", monospace`; + + for (let i = 0; i < columns.length; i++) { + const y = columns[i] * FONT_SIZE; + const char = CHARACTERS[Math.floor(Math.random() * CHARACTERS.length)]; + + context.fillStyle = PRIMARY_COLOR; + context.fillText(char, i * COLUMN_WIDTH, y); + if (Math.random() > 0.98) { + context.fillStyle = DIM_COLOR; + context.fillText(char, i * COLUMN_WIDTH, y - FONT_SIZE); + } + + columns[i]++; + if (y > canvas.height && Math.random() > 0.975) columns[i] = 0; + } + }; + + resize(); + window.addEventListener('resize', resize); + animationRef.current = requestAnimationFrame(draw); + + return () => { + window.removeEventListener('resize', resize); + cancelAnimationFrame(animationRef.current); + }; + }, [frameInterval]); + + return ( +