diff --git a/CHANGELOG.md b/CHANGELOG.md
index 430bb02..d598898 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,8 @@ The project is pre-1.0. Versions below `1.0.0` are internal milestones, not publ
### Added
+- Web: working site search on the marketing site. Cmd+K (Ctrl+K on Windows and Linux), a Search button in the navigation and the Docs search field open a command palette over every page. It searches the pages, docs topics and any docs article that has a page, with arrow keys and Enter to choose and Escape to close. Docs topics now live in `content.ts` so the page and the search share one source.
+- Web: light/dark theme toggle in the marketing navigation. It follows the device until the visitor chooses, remembers the choice in `localStorage`, and sets the theme before first paint so there is no flash. Sun and moon icons added to `public/icons` (hand-drawn, not Figma exports).
- Web: the five other marketing pages (Product, How it works, Trust, Pricing, Docs), a marketing e2e check (`pnpm --filter @wursor/e2e test:marketing`), and product components for setup, activity and search. Web: Next.js app with design tokens (light and dark, AA contrast), the marketing Home page (desktop and phone), and a product component library built at design size (conversation, workspace, board, compare and approval) that the marketing mockups use and the app will reuse (ADR 0023). ADRs 0018 to 0022 (0022: hosting on DigitalOcean): Next.js web app (partly supersedes ADR 0011), sign-in by code, link and passkeys, Cloud provisioning on WeOwn Cloud, and keeping the "never sees a diff" rule wording. PRD, IMPLEMENTATION and AGENTS.md updated to match.
- `docs/prd-assets/headless-architecture.png`, the architecture diagram used in the PRD.
- Design reference: Figma file "Wursor - Experiment" (Design system, Product Design, Marketing site and Brand Guideline pages).
diff --git a/web/__tests__/marketing/CommandPalette.test.tsx b/web/__tests__/marketing/CommandPalette.test.tsx
new file mode 100644
index 0000000..347f54f
--- /dev/null
+++ b/web/__tests__/marketing/CommandPalette.test.tsx
@@ -0,0 +1,198 @@
+import { render, screen, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { CommandPaletteProvider } from '../../src/marketing/CommandPalette.tsx';
+import { DocsSearch } from '../../src/marketing/pages/DocsSearch.tsx';
+import { Nav } from '../../src/marketing/Nav.tsx';
+
+function setup() {
+ return render(
+
+
+
+
+ ,
+ );
+}
+
+const dialog = () => screen.queryByRole('dialog', { name: 'Search' });
+
+describe('CommandPalette', () => {
+ beforeEach(() => {
+ window.matchMedia = vi.fn().mockReturnValue({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() }) as unknown as typeof window.matchMedia;
+ });
+ afterEach(() => {
+ document.body.style.overflow = '';
+ });
+
+ it('is closed until asked for', () => {
+ setup();
+ expect(dialog()).not.toBeInTheDocument();
+ });
+
+ it.each([
+ ['Ctrl+K', '{Control>}k{/Control}'],
+ ['Cmd+K', '{Meta>}k{/Meta}'],
+ ])('opens with %s and focuses the search box', async (_name, keys) => {
+ setup();
+ await userEvent.keyboard(keys);
+ expect(dialog()).toBeInTheDocument();
+ expect(screen.getByRole('combobox', { name: 'Search the site' })).toHaveFocus();
+ });
+
+ it('opens while typing in a text field, and stops the browser acting on the shortcut', async () => {
+ render(
+
+
+ ,
+ );
+ screen.getByRole('textbox', { name: 'Name' }).focus();
+ const handled: boolean[] = [];
+ const spy = (e: KeyboardEvent) => e.key === 'k' && handled.push(e.defaultPrevented);
+ document.addEventListener('keydown', spy);
+ try {
+ await userEvent.keyboard('{Control>}k{/Control}');
+ } finally {
+ document.removeEventListener('keydown', spy);
+ }
+ expect(dialog()).toBeInTheDocument();
+ expect(handled).toEqual([true]);
+ });
+
+ it('toggles closed with the same shortcut', async () => {
+ setup();
+ await userEvent.keyboard('{Control>}k{/Control}');
+ await userEvent.keyboard('{Control>}k{/Control}');
+ expect(dialog()).not.toBeInTheDocument();
+ });
+
+ it('closes with Escape and hands focus back to where it was', async () => {
+ setup();
+ screen.getByRole('button', { name: 'Elsewhere' }).focus();
+ await userEvent.keyboard('{Control>}k{/Control}');
+ await userEvent.keyboard('{Escape}');
+ expect(dialog()).not.toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Elsewhere' })).toHaveFocus();
+ });
+
+ it('closes when the backdrop is clicked, but not when the panel is', async () => {
+ setup();
+ await userEvent.keyboard('{Control>}k{/Control}');
+ await userEvent.click(screen.getByRole('combobox'));
+ expect(dialog()).toBeInTheDocument();
+ await userEvent.click(screen.getByTestId('palette-backdrop'));
+ expect(dialog()).not.toBeInTheDocument();
+ });
+
+ it('suggests the pages before anything is typed', async () => {
+ setup();
+ await userEvent.keyboard('{Control>}k{/Control}');
+ const options = within(screen.getByRole('listbox')).getAllByRole('option');
+ expect(options.map((o) => o.textContent)).toEqual(expect.arrayContaining([expect.stringContaining('Pricing'), expect.stringContaining('Docs')]));
+ });
+
+ it('filters as you type and links each result to its page', async () => {
+ setup();
+ await userEvent.keyboard('{Control>}k{/Control}');
+ await userEvent.keyboard('pricing');
+ const options = within(screen.getByRole('listbox')).getAllByRole('option');
+ expect(options[0]).toHaveTextContent('Pricing');
+ expect(options[0]).toHaveAttribute('href', '/pricing');
+ });
+
+ it('says so when nothing matches', async () => {
+ setup();
+ await userEvent.keyboard('{Control>}k{/Control}');
+ await userEvent.keyboard('zzzz');
+ expect(screen.queryByRole('option')).not.toBeInTheDocument();
+ expect(screen.getByText(/No results for/)).toHaveTextContent('zzzz');
+ });
+
+ it('moves the selection with the arrow keys and wraps around', async () => {
+ setup();
+ await userEvent.keyboard('{Control>}k{/Control}');
+ const input = screen.getByRole('combobox');
+ const options = () => within(screen.getByRole('listbox')).getAllByRole('option');
+ expect(options()[0]).toHaveAttribute('aria-selected', 'true');
+ expect(input).toHaveAttribute('aria-activedescendant', options()[0]!.id);
+
+ await userEvent.keyboard('{ArrowDown}');
+ expect(options()[1]).toHaveAttribute('aria-selected', 'true');
+
+ await userEvent.keyboard('{ArrowUp}{ArrowUp}');
+ expect(options().at(-1)).toHaveAttribute('aria-selected', 'true');
+ });
+
+ it('opens the selected result with Enter and closes', async () => {
+ setup();
+ const clicked = vi.fn();
+ const onClick = (e: MouseEvent) => {
+ e.preventDefault();
+ clicked((e.target as HTMLElement).closest('a')?.getAttribute('href'));
+ };
+ document.addEventListener('click', onClick);
+ try {
+ await userEvent.keyboard('{Control>}k{/Control}');
+ await userEvent.keyboard('pricing{Enter}');
+ expect(clicked).toHaveBeenCalledWith('/pricing');
+ expect(dialog()).not.toBeInTheDocument();
+ } finally {
+ document.removeEventListener('click', onClick);
+ }
+ });
+
+ it('starts from the top of the list each time it opens', async () => {
+ setup();
+ await userEvent.keyboard('{Control>}k{/Control}');
+ await userEvent.keyboard('zzzz{Escape}');
+ await userEvent.keyboard('{Control>}k{/Control}');
+ expect(screen.getByRole('combobox')).toHaveValue('');
+ expect(screen.getAllByRole('option')[0]).toHaveAttribute('aria-selected', 'true');
+ });
+
+ it('keeps the page from scrolling behind it while open', async () => {
+ setup();
+ await userEvent.keyboard('{Control>}k{/Control}');
+ expect(document.body.style.overflow).toBe('hidden');
+ await userEvent.keyboard('{Escape}');
+ expect(document.body.style.overflow).toBe('');
+ });
+
+ it('keeps focus in the search box when Tab is pressed', async () => {
+ setup();
+ await userEvent.keyboard('{Control>}k{/Control}');
+ await userEvent.tab();
+ expect(screen.getByRole('combobox')).toHaveFocus();
+ });
+});
+
+describe('search triggers', () => {
+ beforeEach(() => {
+ window.matchMedia = vi.fn().mockReturnValue({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() }) as unknown as typeof window.matchMedia;
+ });
+
+ it('opens from the Search button in the navigation', async () => {
+ setup();
+ await userEvent.click(screen.getByRole('button', { name: 'Search' }));
+ expect(dialog()).toBeInTheDocument();
+ });
+
+ it('opens from the docs search field, which is no longer disabled', async () => {
+ setup();
+ const field = screen.getByRole('searchbox', { name: 'Search the docs' });
+ expect(field).toBeEnabled();
+ await userEvent.click(field);
+ expect(dialog()).toBeInTheDocument();
+ });
+
+ it('shows the shortcut on the docs search field', () => {
+ setup();
+ expect(screen.getByText('Ctrl K')).toBeInTheDocument();
+ });
+
+ it('does nothing, and does not crash, when there is no provider', async () => {
+ render();
+ await userEvent.click(screen.getByRole('button', { name: 'Search' }));
+ expect(dialog()).not.toBeInTheDocument();
+ });
+});
diff --git a/web/__tests__/marketing/Nav.test.tsx b/web/__tests__/marketing/Nav.test.tsx
index d5537ad..985e2cc 100644
--- a/web/__tests__/marketing/Nav.test.tsx
+++ b/web/__tests__/marketing/Nav.test.tsx
@@ -1,9 +1,24 @@
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
-import { describe, expect, it } from 'vitest';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
import { Nav } from '../../src/marketing/Nav.tsx';
+function deviceTheme(dark: boolean) {
+ window.matchMedia = vi.fn().mockImplementation((query: string) => ({
+ matches: dark && query === '(prefers-color-scheme: dark)',
+ media: query,
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ })) as unknown as typeof window.matchMedia;
+}
+
describe('Nav', () => {
+ beforeEach(() => {
+ localStorage.clear();
+ document.documentElement.removeAttribute('data-theme');
+ deviceTheme(false);
+ });
+
it('links the brand to the home page', () => {
render();
expect(screen.getByRole('link', { name: 'Wursor' })).toHaveAttribute('href', '/');
@@ -57,4 +72,39 @@ describe('Nav', () => {
await userEvent.click(within(menu).getByRole('link', { name: 'Pricing' }));
expect(screen.queryByRole('navigation', { name: 'Mobile' })).not.toBeInTheDocument();
});
+
+ describe('theme toggle', () => {
+ it('is off on a light device', () => {
+ render();
+ expect(screen.getByRole('button', { name: 'Dark theme' })).toHaveAttribute('aria-pressed', 'false');
+ });
+
+ it('is on when the device is dark and nothing is saved', () => {
+ deviceTheme(true);
+ render();
+ expect(screen.getByRole('button', { name: 'Dark theme' })).toHaveAttribute('aria-pressed', 'true');
+ });
+
+ it('is on when dark was chosen earlier, even on a light device', () => {
+ localStorage.setItem('wursor-theme', 'dark');
+ render();
+ expect(screen.getByRole('button', { name: 'Dark theme' })).toHaveAttribute('aria-pressed', 'true');
+ });
+
+ it('switches the page to dark and remembers it', async () => {
+ render();
+ await userEvent.click(screen.getByRole('button', { name: 'Dark theme' }));
+ expect(document.documentElement).toHaveAttribute('data-theme', 'dark');
+ expect(localStorage.getItem('wursor-theme')).toBe('dark');
+ expect(screen.getByRole('button', { name: 'Dark theme' })).toHaveAttribute('aria-pressed', 'true');
+ });
+
+ it('switches a dark device to light', async () => {
+ deviceTheme(true);
+ render();
+ await userEvent.click(screen.getByRole('button', { name: 'Dark theme' }));
+ expect(document.documentElement).toHaveAttribute('data-theme', 'light');
+ expect(screen.getByRole('button', { name: 'Dark theme' })).toHaveAttribute('aria-pressed', 'false');
+ });
+ });
});
diff --git a/web/__tests__/marketing/search.test.ts b/web/__tests__/marketing/search.test.ts
new file mode 100644
index 0000000..90f61eb
--- /dev/null
+++ b/web/__tests__/marketing/search.test.ts
@@ -0,0 +1,88 @@
+import { describe, expect, it } from 'vitest';
+import { SEARCH_ENTRIES, isPaletteShortcut, searchEntries, shortcutLabel, type SearchEntry } from '../../src/marketing/search.ts';
+
+const entry = (id: string, title: string, description = '', group: SearchEntry['group'] = 'Page'): SearchEntry => ({
+ id, title, description, group, href: `/${id}`,
+});
+
+describe('searchEntries', () => {
+ const entries = [
+ entry('a', 'Pricing', 'Plans for every size'),
+ entry('b', 'Review and approve', 'Compare before and after, then publish', 'Docs topic'),
+ entry('c', 'Trust', 'We never publish without approval'),
+ ];
+
+ it('shows the pages when nothing has been typed', () => {
+ expect(searchEntries('', entries).map((e) => e.id)).toEqual(['a', 'c']);
+ });
+
+ it('ignores case and surrounding spaces', () => {
+ expect(searchEntries(' PRICING ', entries).map((e) => e.id)).toEqual(['a']);
+ });
+
+ it('finds an entry by its description', () => {
+ expect(searchEntries('compare', entries).map((e) => e.id)).toEqual(['b']);
+ });
+
+ it('needs every word to match', () => {
+ expect(searchEntries('pricing banana', entries)).toEqual([]);
+ });
+
+ it('puts title matches before description-only matches', () => {
+ expect(searchEntries('approv', entries).map((e) => e.id)).toEqual(['b', 'c']);
+ });
+
+ it('puts titles that start with the query first', () => {
+ const list = [entry('x', 'Undo a change'), entry('y', 'How to undo')];
+ expect(searchEntries('undo', list).map((e) => e.id)).toEqual(['x', 'y']);
+ });
+});
+
+describe('SEARCH_ENTRIES', () => {
+ it('only holds results that go somewhere', () => {
+ expect(SEARCH_ENTRIES.length).toBeGreaterThan(0);
+ for (const e of SEARCH_ENTRIES) expect(e.href).toMatch(/^\//);
+ });
+
+ it('has unique ids', () => {
+ const ids = SEARCH_ENTRIES.map((e) => e.id);
+ expect(new Set(ids).size).toBe(ids.length);
+ });
+
+ it('lists every navigation page', () => {
+ const titles = searchEntries('').map((e) => e.title);
+ expect(titles).toEqual(expect.arrayContaining(['Product', 'How it works', 'Trust and safety', 'Pricing', 'Docs']));
+ });
+
+ it('finds a docs topic by what it covers', () => {
+ expect(searchEntries('invite').map((e) => e.title)).toContain('Team and billing');
+ });
+});
+
+describe('isPaletteShortcut', () => {
+ const key = (k: string, mods: { metaKey?: boolean; ctrlKey?: boolean; shiftKey?: boolean; altKey?: boolean } = {}) =>
+ isPaletteShortcut({ key: k, metaKey: false, ctrlKey: false, shiftKey: false, altKey: false, ...mods });
+
+ it('accepts Cmd+K and Ctrl+K', () => {
+ expect(key('k', { metaKey: true })).toBe(true);
+ expect(key('k', { ctrlKey: true })).toBe(true);
+ });
+
+ it('accepts a capital K (Caps Lock)', () => {
+ expect(key('K', { metaKey: true })).toBe(true);
+ });
+
+ it('rejects K alone, other letters, and extra modifiers', () => {
+ expect(key('k')).toBe(false);
+ expect(key('j', { metaKey: true })).toBe(false);
+ expect(key('k', { metaKey: true, shiftKey: true })).toBe(false);
+ expect(key('k', { ctrlKey: true, altKey: true })).toBe(false);
+ });
+});
+
+describe('shortcutLabel', () => {
+ it('shows the Command symbol on Apple devices and Ctrl elsewhere', () => {
+ expect(shortcutLabel(true)).toBe('⌘K');
+ expect(shortcutLabel(false)).toBe('Ctrl K');
+ });
+});
diff --git a/web/__tests__/marketing/theme.test.ts b/web/__tests__/marketing/theme.test.ts
new file mode 100644
index 0000000..d6dbe71
--- /dev/null
+++ b/web/__tests__/marketing/theme.test.ts
@@ -0,0 +1,59 @@
+import { beforeEach, describe, expect, it } from 'vitest';
+import { THEME_KEY, applyTheme, currentTheme, readStoredTheme, themeInitScript } from '../../src/marketing/theme.ts';
+
+describe('theme', () => {
+ beforeEach(() => {
+ localStorage.clear();
+ document.documentElement.removeAttribute('data-theme');
+ });
+
+ it('has no stored theme until the visitor chooses one', () => {
+ expect(readStoredTheme()).toBeNull();
+ });
+
+ it('ignores a stored value that is not light or dark', () => {
+ localStorage.setItem(THEME_KEY, 'purple');
+ expect(readStoredTheme()).toBeNull();
+ });
+
+ it('applies a theme to the page and remembers it', () => {
+ applyTheme('dark');
+ expect(document.documentElement).toHaveAttribute('data-theme', 'dark');
+ expect(readStoredTheme()).toBe('dark');
+ });
+
+ it('still applies the theme when storage is blocked', () => {
+ const original = Storage.prototype.setItem;
+ Storage.prototype.setItem = () => {
+ throw new Error('blocked');
+ };
+ try {
+ expect(() => applyTheme('light')).not.toThrow();
+ expect(document.documentElement).toHaveAttribute('data-theme', 'light');
+ } finally {
+ Storage.prototype.setItem = original;
+ }
+ });
+
+ it('gives the head script that sets the saved theme before first paint', () => {
+ localStorage.setItem(THEME_KEY, 'dark');
+ new Function(themeInitScript)();
+ expect(document.documentElement).toHaveAttribute('data-theme', 'dark');
+ });
+
+ it('leaves the page on the device theme when nothing is saved', () => {
+ new Function(themeInitScript)();
+ expect(document.documentElement).not.toHaveAttribute('data-theme');
+ });
+
+ it('treats a browser without matchMedia as light instead of crashing', () => {
+ const original = window.matchMedia;
+ // @ts-expect-error simulating an environment that lacks matchMedia
+ window.matchMedia = undefined;
+ try {
+ expect(currentTheme()).toBe('light');
+ } finally {
+ window.matchMedia = original;
+ }
+ });
+});
diff --git a/web/app/(marketing)/docs/page.tsx b/web/app/(marketing)/docs/page.tsx
index 75ba274..2062666 100644
--- a/web/app/(marketing)/docs/page.tsx
+++ b/web/app/(marketing)/docs/page.tsx
@@ -1,10 +1,11 @@
import type { Metadata } from 'next';
-import { SearchField } from '../../../src/product/SearchField.tsx';
import { ClosingCta } from '../../../src/marketing/home/ClosingCta.tsx';
import { FeatureGrid } from '../../../src/marketing/pages/FeatureGrid.tsx';
import { PageHero } from '../../../src/marketing/pages/PageHero.tsx';
import { PopularArticles } from '../../../src/marketing/pages/PopularArticles.tsx';
+import { DocsSearch } from '../../../src/marketing/pages/DocsSearch.tsx';
import { Shell } from '../../../src/marketing/Shell.tsx';
+import { DOCS_TOPICS } from '../../../src/marketing/content.ts';
export const metadata: Metadata = { title: 'Docs' };
@@ -17,20 +18,13 @@ export default function DocsPage() {
subtitle="Guides for starting or connecting a site, moving between hosts, asking for changes and staying in control."
>
- {/* Search is shown as in the design but not usable until the docs exist. */}
-
+ {/* Opens the site search; it covers pages and topics until the articles are written. */}
+
diff --git a/web/app/(marketing)/layout.tsx b/web/app/(marketing)/layout.tsx
index 2e9c2c4..053cadb 100644
--- a/web/app/(marketing)/layout.tsx
+++ b/web/app/(marketing)/layout.tsx
@@ -2,6 +2,8 @@ import { GeistMono } from 'geist/font/mono';
import { GeistSans } from 'geist/font/sans';
import type { Metadata } from 'next';
import type { ReactNode } from 'react';
+import { CommandPaletteProvider } from '../../src/marketing/CommandPalette.tsx';
+import { themeInitScript } from '../../src/marketing/theme.ts';
import '../../src/styles/tokens.css';
import '../../src/styles/marketing.css';
import '../../src/styles/product.css';
@@ -13,8 +15,14 @@ export const metadata: Metadata = {
export default function MarketingLayout({ children }: { children: ReactNode }) {
return (
-
- {children}
+ // suppressHydrationWarning: the head script sets data-theme on before React hydrates.
+
+
+
+
+
+ {children}
+
);
}
diff --git a/web/next.config.ts b/web/next.config.ts
index ca6fa93..614e657 100644
--- a/web/next.config.ts
+++ b/web/next.config.ts
@@ -4,6 +4,9 @@ const apiOrigin = process.env.WURSOR_API_URL ?? 'http://localhost:3000';
const config: NextConfig = {
reactStrictMode: true,
+ // `next dev` blocks its dev endpoints for any host but localhost, so a phone on the same Wi-Fi gets the HTML but a page that
+ // never hydrates (menu, search and theme buttons all dead). Allow private-network addresses. Dev only; no effect on `next build`.
+ allowedDevOrigins: ['10.*.*.*', '192.168.*.*', '172.*.*.*'],
// Do not let `next dev` write its own agent instructions into web/. The repository's AGENTS.md is the guide.
agentRules: false,
// Interim: the Sprint 1 shell at /app still calls the API on the same origin.
diff --git a/web/public/icons/README.md b/web/public/icons/README.md
index 1f1ede8..7c0366b 100644
--- a/web/public/icons/README.md
+++ b/web/public/icons/README.md
@@ -1,3 +1,5 @@
Single-colour line icons exported from the Figma file "Wursor - weown.net" (Icon/* components).
Used through the `Icon` component (src/ui/Icon.tsx), which paints them with CSS masks so the colour comes from the
current theme. Do not edit the files; re-export from Figma if an icon changes.
+
+Exception: `sun.svg` and `moon.svg` (theme toggle) are not Figma exports. They are hand-drawn to match the set (16px, 1.5 stroke, round caps). Replace them with the Figma Icon/Sun and Icon/Moon exports when those exist.
diff --git a/web/public/icons/moon.svg b/web/public/icons/moon.svg
new file mode 100644
index 0000000..e7ab8cd
--- /dev/null
+++ b/web/public/icons/moon.svg
@@ -0,0 +1,5 @@
+
diff --git a/web/public/icons/sun.svg b/web/public/icons/sun.svg
new file mode 100644
index 0000000..b2123fe
--- /dev/null
+++ b/web/public/icons/sun.svg
@@ -0,0 +1,5 @@
+
diff --git a/web/src/marketing/CommandPalette.tsx b/web/src/marketing/CommandPalette.tsx
new file mode 100644
index 0000000..c4da01d
--- /dev/null
+++ b/web/src/marketing/CommandPalette.tsx
@@ -0,0 +1,140 @@
+'use client';
+
+import Link from 'next/link';
+import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type KeyboardEvent, type ReactNode } from 'react';
+import { Icon } from '../ui/Icon.tsx';
+import { isPaletteShortcut, searchEntries } from './search.ts';
+
+type Palette = { open: () => void };
+
+/** Without a provider (a page rendered on its own) opening does nothing instead of crashing. */
+const PaletteContext = createContext({ open: () => {} });
+export const useCommandPalette = () => useContext(PaletteContext);
+
+/** Owns the open state and the Cmd/Ctrl+K shortcut. Wrap the whole marketing site once. */
+export function CommandPaletteProvider({ children }: { children: ReactNode }) {
+ const [isOpen, setIsOpen] = useState(false);
+ const open = useCallback(() => setIsOpen(true), []);
+ const close = useCallback(() => setIsOpen(false), []);
+ const value = useMemo(() => ({ open }), [open]);
+
+ useEffect(() => {
+ const onKey = (e: globalThis.KeyboardEvent) => {
+ if (!isPaletteShortcut(e)) return;
+ e.preventDefault(); // Ctrl+K is "focus the address bar search" in some browsers.
+ setIsOpen((v) => !v);
+ };
+ document.addEventListener('keydown', onKey);
+ return () => document.removeEventListener('keydown', onKey);
+ }, []);
+
+ return (
+
+ {children}
+ {isOpen && }
+
+ );
+}
+
+const optionId = (id: string) => `palette-option-${id}`;
+
+function Palette({ onClose }: { onClose: () => void }) {
+ const [query, setQuery] = useState('');
+ const [active, setActive] = useState(0);
+ const inputRef = useRef(null);
+ const results = useMemo(() => searchEntries(query), [query]);
+ const activeEntry = results[active];
+
+ // Remember where focus was, take it into the search box, and give it back on close.
+ useEffect(() => {
+ const previous = document.activeElement as HTMLElement | null;
+ const overflow = document.body.style.overflow;
+ document.body.style.overflow = 'hidden';
+ inputRef.current?.focus();
+ return () => {
+ document.body.style.overflow = overflow;
+ previous?.focus?.();
+ };
+ }, []);
+
+ useEffect(() => {
+ if (activeEntry) document.getElementById(optionId(activeEntry.id))?.scrollIntoView?.({ block: 'nearest' });
+ }, [activeEntry]);
+
+ const onKeyDown = (e: KeyboardEvent) => {
+ if (e.key === 'Escape') {
+ e.preventDefault();
+ onClose();
+ } else if (e.key === 'Tab') {
+ e.preventDefault(); // One control to focus: the search box. Results are chosen with the arrow keys.
+ } else if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
+ e.preventDefault();
+ if (results.length) setActive((i) => (i + (e.key === 'ArrowDown' ? 1 : -1) + results.length) % results.length);
+ } else if (e.key === 'Enter' && activeEntry) {
+ e.preventDefault();
+ document.getElementById(optionId(activeEntry.id))?.click();
+ }
+ };
+
+ return (
+