feat(web): marketing theme toggle, site search palette, and phone dev access

- Theme toggle in the nav: follows the device until chosen, remembers the choice, sets data-theme before first paint (no flash).
- Site search: Cmd/Ctrl+K command palette on every marketing page, opened by the shortcut, a nav Search button, or the Docs search field. Searches pages, docs topics and articles that have a page.
- Docs topics move to content.ts so the page and the search index share one source.
- next.config: allowedDevOrigins for private network ranges so a phone on the same Wi-Fi hydrates the dev site. Dev only.
- Sun and moon icons added by hand (not Figma exports); noted in the icons README.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
SinachPat
2026-10-13 22:27:34 +01:00
co-authored by Claude Sonnet 5.5
parent 0cff3902b2
commit f69ce33dd0
21 changed files with 818 additions and 17 deletions
@@ -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(
<CommandPaletteProvider>
<Nav />
<DocsSearch />
<button type="button">Elsewhere</button>
</CommandPaletteProvider>,
);
}
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(
<CommandPaletteProvider>
<input aria-label="Name" />
</CommandPaletteProvider>,
);
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(<Nav />);
await userEvent.click(screen.getByRole('button', { name: 'Search' }));
expect(dialog()).not.toBeInTheDocument();
});
});
+51 -1
View File
@@ -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(<Nav />);
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(<Nav />);
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(<Nav />);
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(<Nav />);
expect(screen.getByRole('button', { name: 'Dark theme' })).toHaveAttribute('aria-pressed', 'true');
});
it('switches the page to dark and remembers it', async () => {
render(<Nav />);
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(<Nav />);
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');
});
});
});
+88
View File
@@ -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');
});
});
+59
View File
@@ -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;
}
});
});