diff --git a/src/__tests__/FormInputs.test.tsx b/src/__tests__/FormInputs.test.tsx
new file mode 100644
index 0000000..8211266
--- /dev/null
+++ b/src/__tests__/FormInputs.test.tsx
@@ -0,0 +1,171 @@
+import { render, screen } from '@testing-library/react';
+import {
+ TextInput,
+ NumberInput,
+ Textarea,
+ Select,
+ DatePicker,
+ Toggle,
+ Checkbox,
+} from '@/components/FormInputs';
+
+describe('FormInputs - aria-describedby', () => {
+ describe('TextInput', () => {
+ it('sets aria-describedby pointing to error element', () => {
+ render();
+ const input = screen.getByRole('textbox');
+ expect(input).toHaveAttribute('aria-describedby', 'email-error');
+ expect(screen.getByText('Required')).toHaveAttribute('id', 'email-error');
+ });
+
+ it('sets aria-describedby pointing to helper text element', () => {
+ render();
+ const input = screen.getByRole('textbox');
+ expect(input).toHaveAttribute('aria-describedby', 'email-helper');
+ expect(screen.getByText('Your email address')).toHaveAttribute('id', 'email-helper');
+ });
+
+ it('does not set aria-describedby when no error or helper text', () => {
+ render();
+ const input = screen.getByRole('textbox');
+ expect(input).not.toHaveAttribute('aria-describedby');
+ });
+
+ it('sets aria-invalid when error is present', () => {
+ render();
+ expect(screen.getByRole('textbox')).toHaveAttribute('aria-invalid', 'true');
+ });
+
+ it('sets aria-invalid=false when no error', () => {
+ render();
+ expect(screen.getByRole('textbox')).toHaveAttribute('aria-invalid', 'false');
+ });
+ });
+
+ describe('NumberInput', () => {
+ it('sets aria-describedby pointing to error element', () => {
+ render();
+ const input = screen.getByRole('spinbutton');
+ expect(input).toHaveAttribute('aria-describedby', 'count-error');
+ expect(screen.getByText('Must be positive')).toHaveAttribute('id', 'count-error');
+ });
+
+ it('sets aria-describedby pointing to helper text', () => {
+ render();
+ const input = screen.getByRole('spinbutton');
+ expect(input).toHaveAttribute('aria-describedby', 'count-helper');
+ });
+ });
+
+ describe('Textarea', () => {
+ it('sets aria-describedby pointing to error element', () => {
+ render();
+ const textarea = screen.getByRole('textbox');
+ expect(textarea).toHaveAttribute('aria-describedby', 'notes-error');
+ expect(screen.getByText('Too long')).toHaveAttribute('id', 'notes-error');
+ });
+
+ it('sets aria-describedby pointing to helper text', () => {
+ render();
+ const textarea = screen.getByRole('textbox');
+ expect(textarea).toHaveAttribute('aria-describedby', 'notes-helper');
+ });
+ });
+
+ describe('Select', () => {
+ const options = [
+ { value: 'a', label: 'Option A' },
+ { value: 'b', label: 'Option B' },
+ ];
+
+ it('sets aria-describedby pointing to error element', () => {
+ render();
+ const select = screen.getByRole('combobox');
+ expect(select).toHaveAttribute('aria-describedby', 'choice-error');
+ expect(screen.getByText('Invalid')).toHaveAttribute('id', 'choice-error');
+ });
+
+ it('sets aria-describedby pointing to helper text', () => {
+ render();
+ const select = screen.getByRole('combobox');
+ expect(select).toHaveAttribute('aria-describedby', 'choice-helper');
+ });
+ });
+
+ describe('DatePicker', () => {
+ it('sets aria-describedby pointing to error element', () => {
+ render();
+ const input = screen.getByRole('textbox');
+ expect(input).toHaveAttribute('aria-describedby', 'date-error');
+ expect(screen.getByText('Invalid date')).toHaveAttribute('id', 'date-error');
+ });
+
+ it('sets aria-describedby pointing to helper text', () => {
+ render();
+ const input = screen.getByRole('textbox');
+ expect(input).toHaveAttribute('aria-describedby', 'date-helper');
+ });
+ });
+
+ describe('Toggle', () => {
+ it('sets aria-describedby pointing to error element', () => {
+ render(
+ {}}
+ error="Must be enabled"
+ />
+ );
+ const button = screen.getByRole('switch');
+ expect(button).toHaveAttribute('aria-describedby', 'enabled-error');
+ expect(screen.getByText('Must be enabled')).toHaveAttribute('id', 'enabled-error');
+ });
+
+ it('sets aria-describedby pointing to helper text', () => {
+ render(
+ {}}
+ helperText="Turn this on to proceed"
+ />
+ );
+ const button = screen.getByRole('switch');
+ expect(button).toHaveAttribute('aria-describedby', 'enabled-helper');
+ });
+ });
+
+ describe('Checkbox', () => {
+ it('sets aria-describedby pointing to error element', () => {
+ render(
+ {}}
+ error="You must accept"
+ />
+ );
+ const checkbox = screen.getByRole('checkbox');
+ expect(checkbox).toHaveAttribute('aria-describedby', 'accept-error');
+ expect(screen.getByText('You must accept')).toHaveAttribute('id', 'accept-error');
+ });
+
+ it('sets aria-describedby pointing to helper text', () => {
+ render(
+ {}}
+ helperText="I agree to the terms"
+ />
+ );
+ const checkbox = screen.getByRole('checkbox');
+ expect(checkbox).toHaveAttribute('aria-describedby', 'accept-helper');
+ });
+ });
+});
diff --git a/src/__tests__/TextInput.test.tsx b/src/__tests__/TextInput.test.tsx
index 618b108..c4f8a50 100644
--- a/src/__tests__/TextInput.test.tsx
+++ b/src/__tests__/TextInput.test.tsx
@@ -53,4 +53,10 @@ describe('TextInput', () => {
render();
expect(screen.queryByRole('paragraph')).not.toBeInTheDocument();
});
+
+ it('has visible focus ring on keyboard focus', () => {
+ render();
+ const input = screen.getByRole('textbox');
+ expect(input).toHaveClass('focus-visible:ring-2', 'focus-visible:ring-indigo-500');
+ });
});
diff --git a/src/__tests__/ToastContainer.test.tsx b/src/__tests__/ToastContainer.test.tsx
new file mode 100644
index 0000000..2eb774b
--- /dev/null
+++ b/src/__tests__/ToastContainer.test.tsx
@@ -0,0 +1,90 @@
+import { render, screen, waitFor } from '@testing-library/react';
+import ToastContainer from '@/components/ToastContainer';
+
+describe('ToastContainer', () => {
+ beforeEach(() => {
+ jest.useFakeTimers();
+ });
+
+ afterEach(() => {
+ jest.runOnlyPendingTimers();
+ jest.useRealTimers();
+ });
+
+ it('renders no toasts when none are added', () => {
+ const { container } = render();
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('deduplicates identical toasts fired in rapid succession', () => {
+ render();
+
+ const addToast = (window as any).__toastContainer?.addToast;
+ if (!addToast) throw new Error('addToast not exposed');
+
+ addToast('Saved!', 'success');
+ addToast('Saved!', 'success');
+ addToast('Saved!', 'success');
+
+ const toastMessages = screen.getAllByText('Saved!');
+ expect(toastMessages).toHaveLength(1);
+ });
+
+ it('does not deduplicate toasts with different messages', () => {
+ render();
+
+ const addToast = (window as any).__toastContainer?.addToast;
+ if (!addToast) throw new Error('addToast not exposed');
+
+ addToast('Saved!', 'success');
+ addToast('Error!', 'error');
+
+ expect(screen.getByText('Saved!')).toBeInTheDocument();
+ expect(screen.getByText('Error!')).toBeInTheDocument();
+ });
+
+ it('does not deduplicate toasts with different types', () => {
+ render();
+
+ const addToast = (window as any).__toastContainer?.addToast;
+ if (!addToast) throw new Error('addToast not exposed');
+
+ addToast('Updated', 'success');
+ addToast('Updated', 'error');
+
+ const toastMessages = screen.getAllByText('Updated');
+ expect(toastMessages).toHaveLength(2);
+ });
+
+ it('resets auto-dismiss timer when duplicate toast is added', () => {
+ render();
+
+ const addToast = (window as any).__toastContainer?.addToast;
+ if (!addToast) throw new Error('addToast not exposed');
+
+ addToast('Saving...', 'info');
+
+ jest.advanceTimersByTime(4000);
+ expect(screen.getByText('Saving...')).toBeInTheDocument();
+
+ addToast('Saving...', 'info');
+ jest.advanceTimersByTime(4000);
+ expect(screen.getByText('Saving...')).toBeInTheDocument();
+
+ jest.advanceTimersByTime(1000);
+ expect(screen.queryByText('Saving...')).not.toBeInTheDocument();
+ });
+
+ it('auto-dismisses toasts after 5 seconds', () => {
+ render();
+
+ const addToast = (window as any).__toastContainer?.addToast;
+ if (!addToast) throw new Error('addToast not exposed');
+
+ addToast('Processed', 'success');
+ expect(screen.getByText('Processed')).toBeInTheDocument();
+
+ jest.advanceTimersByTime(5000);
+ expect(screen.queryByText('Processed')).not.toBeInTheDocument();
+ });
+});
diff --git a/src/__tests__/WalletConnect.test.tsx b/src/__tests__/WalletConnect.test.tsx
new file mode 100644
index 0000000..8a46865
--- /dev/null
+++ b/src/__tests__/WalletConnect.test.tsx
@@ -0,0 +1,120 @@
+import { render, screen } from '@testing-library/react';
+import WalletConnect from '@/components/WalletConnect';
+import { useWalletContext } from '@/contexts/WalletContext';
+
+jest.mock('@/contexts/WalletContext');
+
+describe('WalletConnect', () => {
+ const mockUseWalletContext = useWalletContext as jest.MockedFunction;
+ const defaultMockReturn = {
+ address: null,
+ walletType: null,
+ connecting: false,
+ error: null,
+ freighterInstalled: null,
+ connect: jest.fn(),
+ disconnect: jest.fn(),
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockUseWalletContext.mockReturnValue(defaultMockReturn);
+ });
+
+ describe('when Freighter is not installed', () => {
+ beforeEach(() => {
+ mockUseWalletContext.mockReturnValue({
+ ...defaultMockReturn,
+ freighterInstalled: false,
+ });
+ });
+
+ it('shows install prompt message', () => {
+ render();
+ expect(screen.getByText(/Freighter isn't installed/i)).toBeInTheDocument();
+ });
+
+ it('renders link to Freighter installation page', () => {
+ render();
+ const link = screen.getByRole('link', { name: /Install Freighter/i });
+ expect(link).toBeInTheDocument();
+ expect(link).toHaveAttribute('href', 'https://www.freighter.app/');
+ expect(link).toHaveAttribute('target', '_blank');
+ expect(link).toHaveAttribute('rel', 'noopener noreferrer');
+ });
+
+ it('does not render connect buttons', () => {
+ render();
+ expect(screen.queryByRole('button', { name: /Connect/i })).not.toBeInTheDocument();
+ });
+ });
+
+ describe('when Freighter is installed and disconnected', () => {
+ beforeEach(() => {
+ mockUseWalletContext.mockReturnValue({
+ ...defaultMockReturn,
+ freighterInstalled: true,
+ });
+ });
+
+ it('renders connect buttons', () => {
+ render();
+ expect(screen.getByRole('button', { name: /Connect with Freighter/i })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /Connect with WalletConnect/i })).toBeInTheDocument();
+ });
+
+ it('does not show install prompt', () => {
+ render();
+ expect(screen.queryByText(/Freighter isn't installed/i)).not.toBeInTheDocument();
+ });
+ });
+
+ describe('when connected', () => {
+ beforeEach(() => {
+ mockUseWalletContext.mockReturnValue({
+ ...defaultMockReturn,
+ address: 'GBUQWP3BOUZX34ULNQG23RQ6F4OYFBB5L6CSTX32G37B5HMXVY5Z5AAA',
+ walletType: 'freighter',
+ freighterInstalled: true,
+ });
+ });
+
+ it('does not show install prompt', () => {
+ render();
+ expect(screen.queryByText(/Freighter isn't installed/i)).not.toBeInTheDocument();
+ });
+
+ it('does not show connect buttons', () => {
+ render();
+ expect(screen.queryByRole('button', { name: /Connect/i })).not.toBeInTheDocument();
+ });
+
+ it('shows address badge and disconnect button', () => {
+ render();
+ expect(screen.getByText(/^GBU.*AAA$/)).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /Disconnect/i })).toBeInTheDocument();
+ });
+ });
+
+ describe('compact mode', () => {
+ it('renders compact connect button when disconnected', () => {
+ mockUseWalletContext.mockReturnValue({
+ ...defaultMockReturn,
+ freighterInstalled: true,
+ });
+
+ render();
+ expect(screen.getByRole('button', { name: /Connect Wallet/i })).toBeInTheDocument();
+ });
+
+ it('shows install prompt in compact mode when Freighter not installed', () => {
+ mockUseWalletContext.mockReturnValue({
+ ...defaultMockReturn,
+ freighterInstalled: false,
+ });
+
+ render();
+ expect(screen.getByText(/Freighter isn't installed/i)).toBeInTheDocument();
+ });
+ });
+});
diff --git a/src/components/TextInput.tsx b/src/components/TextInput.tsx
index 56a4f2b..6cdbab8 100644
--- a/src/components/TextInput.tsx
+++ b/src/components/TextInput.tsx
@@ -10,7 +10,7 @@ export default function TextInput({ label, error, id, ...rest }: Props) {
([]);
+ const dedupeTimersRef = useRef