diff --git a/web/src/lib/components/SearchInput.svelte b/web/src/lib/components/SearchInput.svelte
new file mode 100644
index 00000000..f1755709
--- /dev/null
+++ b/web/src/lib/components/SearchInput.svelte
@@ -0,0 +1,57 @@
+
+
+
diff --git a/web/src/lib/components/SearchInput.test.ts b/web/src/lib/components/SearchInput.test.ts
new file mode 100644
index 00000000..84517669
--- /dev/null
+++ b/web/src/lib/components/SearchInput.test.ts
@@ -0,0 +1,94 @@
+import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
+import { render, screen, fireEvent } from '@testing-library/svelte';
+
+const state = vi.hoisted(() => ({
+ pageUrl: new URL('http://localhost/')
+}));
+
+vi.mock('$app/state', () => ({
+ page: { get url() { return state.pageUrl; } }
+}));
+
+vi.mock('$app/navigation', () => ({
+ goto: vi.fn()
+}));
+
+import SearchInput from './SearchInput.svelte';
+import { goto } from '$app/navigation';
+
+beforeEach(() => {
+ vi.useFakeTimers();
+ state.pageUrl = new URL('http://localhost/');
+});
+
+afterEach(() => {
+ vi.clearAllMocks();
+ vi.useRealTimers();
+});
+
+describe('SearchInput', () => {
+ test('mount pre-fills value from page.url.searchParams', () => {
+ state.pageUrl = new URL('http://localhost/search?q=hello');
+ render(SearchInput);
+ const input = screen.getByRole('searchbox') as HTMLInputElement;
+ expect(input.value).toBe('hello');
+ });
+
+ test('typing fires goto once after 250ms debounce', async () => {
+ render(SearchInput);
+ const input = screen.getByRole('searchbox') as HTMLInputElement;
+ await fireEvent.input(input, { target: { value: 'h' } });
+ expect(goto).not.toHaveBeenCalled();
+ vi.advanceTimersByTime(250);
+ expect(goto).toHaveBeenCalledTimes(1);
+ expect(goto).toHaveBeenCalledWith('/search?q=h', { replaceState: false });
+ });
+
+ test('rapid keystrokes debounce to a single goto', async () => {
+ render(SearchInput);
+ const input = screen.getByRole('searchbox') as HTMLInputElement;
+ await fireEvent.input(input, { target: { value: 'h' } });
+ vi.advanceTimersByTime(100);
+ await fireEvent.input(input, { target: { value: 'he' } });
+ vi.advanceTimersByTime(100);
+ await fireEvent.input(input, { target: { value: 'hel' } });
+ vi.advanceTimersByTime(250);
+ expect(goto).toHaveBeenCalledTimes(1);
+ expect(goto).toHaveBeenCalledWith('/search?q=hel', { replaceState: false });
+ });
+
+ test('typing while on /search uses replaceState=true', async () => {
+ state.pageUrl = new URL('http://localhost/search?q=h');
+ render(SearchInput);
+ const input = screen.getByRole('searchbox') as HTMLInputElement;
+ await fireEvent.input(input, { target: { value: 'he' } });
+ vi.advanceTimersByTime(250);
+ expect(goto).toHaveBeenCalledWith('/search?q=he', { replaceState: true });
+ });
+
+ test('clearing input on /search calls goto("/search") (drops ?q=)', async () => {
+ state.pageUrl = new URL('http://localhost/search?q=h');
+ render(SearchInput);
+ const input = screen.getByRole('searchbox') as HTMLInputElement;
+ await fireEvent.input(input, { target: { value: '' } });
+ vi.advanceTimersByTime(250);
+ expect(goto).toHaveBeenCalledWith('/search', { replaceState: true });
+ });
+
+ test('Escape clears the input value', async () => {
+ render(SearchInput);
+ const input = screen.getByRole('searchbox') as HTMLInputElement;
+ await fireEvent.input(input, { target: { value: 'hello' } });
+ expect(input.value).toBe('hello');
+ await fireEvent.keyDown(input, { key: 'Escape' });
+ expect(input.value).toBe('');
+ });
+
+ test('encodeURIComponent applied to special chars', async () => {
+ render(SearchInput);
+ const input = screen.getByRole('searchbox') as HTMLInputElement;
+ await fireEvent.input(input, { target: { value: 'a b&c' } });
+ vi.advanceTimersByTime(250);
+ expect(goto).toHaveBeenCalledWith('/search?q=a%20b%26c', { replaceState: false });
+ });
+});