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
+2
View File
@@ -8,6 +8,8 @@ The project is pre-1.0. Versions below `1.0.0` are internal milestones, not publ
### Added ### 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. - 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. - `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). - Design reference: Figma file "Wursor - Experiment" (Design system, Product Design, Marketing site and Brand Guideline pages).
@@ -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 { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event'; 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'; 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', () => { describe('Nav', () => {
beforeEach(() => {
localStorage.clear();
document.documentElement.removeAttribute('data-theme');
deviceTheme(false);
});
it('links the brand to the home page', () => { it('links the brand to the home page', () => {
render(<Nav />); render(<Nav />);
expect(screen.getByRole('link', { name: 'Wursor' })).toHaveAttribute('href', '/'); expect(screen.getByRole('link', { name: 'Wursor' })).toHaveAttribute('href', '/');
@@ -57,4 +72,39 @@ describe('Nav', () => {
await userEvent.click(within(menu).getByRole('link', { name: 'Pricing' })); await userEvent.click(within(menu).getByRole('link', { name: 'Pricing' }));
expect(screen.queryByRole('navigation', { name: 'Mobile' })).not.toBeInTheDocument(); 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;
}
});
});
+5 -11
View File
@@ -1,10 +1,11 @@
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import { SearchField } from '../../../src/product/SearchField.tsx';
import { ClosingCta } from '../../../src/marketing/home/ClosingCta.tsx'; import { ClosingCta } from '../../../src/marketing/home/ClosingCta.tsx';
import { FeatureGrid } from '../../../src/marketing/pages/FeatureGrid.tsx'; import { FeatureGrid } from '../../../src/marketing/pages/FeatureGrid.tsx';
import { PageHero } from '../../../src/marketing/pages/PageHero.tsx'; import { PageHero } from '../../../src/marketing/pages/PageHero.tsx';
import { PopularArticles } from '../../../src/marketing/pages/PopularArticles.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 { Shell } from '../../../src/marketing/Shell.tsx';
import { DOCS_TOPICS } from '../../../src/marketing/content.ts';
export const metadata: Metadata = { title: 'Docs' }; 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." subtitle="Guides for starting or connecting a site, moving between hosts, asking for changes and staying in control."
> >
<div className="m-docs-search"> <div className="m-docs-search">
{/* 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. */}
<SearchField label="Search the docs" shortcut="⌘K" disabled /> <DocsSearch />
</div> </div>
</PageHero> </PageHero>
<FeatureGrid <FeatureGrid
title="Browse by topic" title="Browse by topic"
cards={[ cards={DOCS_TOPICS}
{ icon: 'rocket', title: 'Getting started', body: 'Start or connect your site and make your first change.' },
{ icon: 'plug', title: 'Start or connect a site', body: 'Start on Wursor Cloud, link WordPress, move between hosts and manage access.' },
{ icon: 'sparkles', title: 'Asking for changes', body: 'Write a request, point at the page and follow the work.' },
{ icon: 'shield-check', title: 'Review and approve', body: 'Compare before and after, read the checks and publish.' },
{ icon: 'credit-card', title: 'Team and billing', body: 'Invite people, set roles and manage your plan.' },
{ icon: 'help', title: 'Troubleshooting', body: 'Fixes for the most common connection and preview problems.' },
]}
/> />
<PopularArticles /> <PopularArticles />
<ClosingCta /> <ClosingCta />
+10 -2
View File
@@ -2,6 +2,8 @@ import { GeistMono } from 'geist/font/mono';
import { GeistSans } from 'geist/font/sans'; import { GeistSans } from 'geist/font/sans';
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import type { ReactNode } from 'react'; 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/tokens.css';
import '../../src/styles/marketing.css'; import '../../src/styles/marketing.css';
import '../../src/styles/product.css'; import '../../src/styles/product.css';
@@ -13,8 +15,14 @@ export const metadata: Metadata = {
export default function MarketingLayout({ children }: { children: ReactNode }) { export default function MarketingLayout({ children }: { children: ReactNode }) {
return ( return (
<html lang="en" className={`${GeistSans.variable} ${GeistMono.variable}`}> // suppressHydrationWarning: the head script sets data-theme on <html> before React hydrates.
<body>{children}</body> <html lang="en" className={`${GeistSans.variable} ${GeistMono.variable}`} suppressHydrationWarning>
<head>
<script dangerouslySetInnerHTML={{ __html: themeInitScript }} />
</head>
<body>
<CommandPaletteProvider>{children}</CommandPaletteProvider>
</body>
</html> </html>
); );
} }
+3
View File
@@ -4,6 +4,9 @@ const apiOrigin = process.env.WURSOR_API_URL ?? 'http://localhost:3000';
const config: NextConfig = { const config: NextConfig = {
reactStrictMode: true, 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. // Do not let `next dev` write its own agent instructions into web/. The repository's AGENTS.md is the guide.
agentRules: false, agentRules: false,
// Interim: the Sprint 1 shell at /app still calls the API on the same origin. // Interim: the Sprint 1 shell at /app still calls the API on the same origin.
+2
View File
@@ -1,3 +1,5 @@
Single-colour line icons exported from the Figma file "Wursor - weown.net" (Icon/* components). 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 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. 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.
+5
View File
@@ -0,0 +1,5 @@
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Icon">
<path id="Vector" d="M14 8.52667C13.8828 9.79559 13.4066 11.0049 12.6277 12.0136C11.8489 13.0222 10.8001 13.7881 9.60331 14.2216C8.40652 14.6551 7.11116 14.7384 5.86854 14.4617C4.62591 14.1849 3.48765 13.5596 2.58652 12.6585C1.68538 11.7574 1.06015 10.6191 0.783359 9.37649C0.506571 8.13387 0.589915 6.83851 1.02339 5.64172C1.45687 4.44493 2.22275 3.39615 3.23142 2.61732C4.24009 1.83849 5.44941 1.36227 6.71833 1.24503C5.97543 2.25034 5.61787 3.48847 5.71073 4.73497C5.8036 5.98147 6.34073 7.15267 7.22402 8.03597C8.10732 8.91927 9.27852 9.4564 10.525 9.54926C11.7715 9.64213 13.0097 9.28457 14.015 8.54167L14 8.52667Z" stroke="#14120B" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 898 B

+5
View File
@@ -0,0 +1,5 @@
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Icon">
<path id="Vector" d="M8 11.3333C9.84095 11.3333 11.3333 9.84095 11.3333 8C11.3333 6.15905 9.84095 4.66667 8 4.66667C6.15905 4.66667 4.66667 6.15905 4.66667 8C4.66667 9.84095 6.15905 11.3333 8 11.3333ZM8 1.33333V2.66667M8 13.3333V14.6667M2.28667 2.28667L3.23333 3.23333M12.7667 12.7667L13.7133 13.7133M1.33333 8H2.66667M13.3333 8H14.6667M2.28667 13.7133L3.23333 12.7667M12.7667 3.23333L13.7133 2.28667" stroke="#14120B" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 679 B

+140
View File
@@ -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<Palette>({ 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 (
<PaletteContext.Provider value={value}>
{children}
{isOpen && <Palette onClose={close} />}
</PaletteContext.Provider>
);
}
const optionId = (id: string) => `palette-option-${id}`;
function Palette({ onClose }: { onClose: () => void }) {
const [query, setQuery] = useState('');
const [active, setActive] = useState(0);
const inputRef = useRef<HTMLInputElement>(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<HTMLDivElement>) => {
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 (
<div
className="palette-backdrop"
data-testid="palette-backdrop"
onMouseDown={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div role="dialog" aria-modal="true" aria-label="Search" className="palette" onKeyDown={onKeyDown}>
<div className="palette-field">
<Icon name="search" size={16} />
<input
ref={inputRef}
role="combobox"
aria-label="Search the site"
aria-expanded="true"
aria-controls="palette-results"
aria-autocomplete="list"
aria-activedescendant={activeEntry ? optionId(activeEntry.id) : undefined}
autoComplete="off"
spellCheck={false}
placeholder="Search pages and docs…"
className="palette-input"
value={query}
onChange={(e) => {
setQuery(e.target.value);
setActive(0);
}}
/>
</div>
{results.length > 0 ? (
<ul id="palette-results" role="listbox" aria-label="Results" className="palette-results">
{results.map((r, i) => (
<li key={r.id} role="presentation">
<Link
id={optionId(r.id)}
role="option"
aria-selected={i === active}
href={r.href}
className="palette-option"
tabIndex={-1}
onMouseMove={() => setActive(i)}
onClick={onClose}
>
<span className="palette-option-text">
<span className="t-label-m">{r.title}</span>
{r.description && <span className="t-body-s palette-option-desc">{r.description}</span>}
</span>
<span className="t-label-s palette-option-group">{r.group}</span>
</Link>
</li>
))}
</ul>
) : (
<p role="status" className="palette-empty t-body-m">
No results for “{query.trim()}”
</p>
)}
</div>
</div>
);
}
+7
View File
@@ -7,10 +7,13 @@ import { BrandMark } from './BrandMark.tsx';
import { Button } from './Button.tsx'; import { Button } from './Button.tsx';
import { NAV_LINKS, ROUTES } from './content.ts'; import { NAV_LINKS, ROUTES } from './content.ts';
import { Icon } from '../ui/Icon.tsx'; import { Icon } from '../ui/Icon.tsx';
import { useCommandPalette } from './CommandPalette.tsx';
import { ThemeToggle } from './ThemeToggle.tsx';
export function Nav() { export function Nav() {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const pathname = usePathname(); const pathname = usePathname();
const palette = useCommandPalette();
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
@@ -38,6 +41,10 @@ export function Nav() {
</nav> </nav>
<div className="nav-actions"> <div className="nav-actions">
<button type="button" className="nav-icon-button" aria-label="Search" aria-haspopup="dialog" aria-keyshortcuts="Control+K Meta+K" onClick={palette.open}>
<Icon name="search" />
</button>
<ThemeToggle />
<Link href={ROUTES.signIn} className="nav-signin t-label-m"> <Link href={ROUTES.signIn} className="nav-signin t-label-m">
Sign in Sign in
</Link> </Link>
+35
View File
@@ -0,0 +1,35 @@
'use client';
import { useEffect, useState } from 'react';
import { Icon } from '../ui/Icon.tsx';
import { applyTheme, currentTheme } from './theme.ts';
/**
* Light/dark switch. The sun/moon swap is pure CSS (like the brand mark), so the right icon is
* painted from the first frame; only `aria-pressed` waits for the browser, because the server
* can't know the visitor's saved or device theme.
*/
export function ThemeToggle() {
const [dark, setDark] = useState(false);
useEffect(() => {
setDark(currentTheme() === 'dark');
}, []);
return (
<button
type="button"
className="nav-icon-button"
aria-label="Dark theme"
aria-pressed={dark}
onClick={() => {
const next = dark ? 'light' : 'dark';
applyTheme(next);
setDark(next === 'dark');
}}
>
<Icon name="sun" className="theme-toggle-sun" />
<Icon name="moon" className="theme-toggle-moon" />
</button>
);
}
+12
View File
@@ -1,3 +1,5 @@
import type { IconName } from '../ui/Icon.tsx';
export type NavLink = { label: string; mobileLabel?: string; href: string }; export type NavLink = { label: string; mobileLabel?: string; href: string };
export const NAV_LINKS: readonly NavLink[] = [ export const NAV_LINKS: readonly NavLink[] = [
@@ -132,3 +134,13 @@ export const ARTICLES: ReadonlyArray<{ title: string; href?: string }> = [
{ title: 'Invite your team' }, { title: 'Invite your team' },
{ title: 'Change or cancel your plan' }, { title: 'Change or cancel your plan' },
]; ];
/** Docs topic cards. Shared by the Docs page and the site search. */
export const DOCS_TOPICS: ReadonlyArray<{ icon: IconName; title: string; body: string }> = [
{ icon: 'rocket', title: 'Getting started', body: 'Start or connect your site and make your first change.' },
{ icon: 'plug', title: 'Start or connect a site', body: 'Start on Wursor Cloud, link WordPress, move between hosts and manage access.' },
{ icon: 'sparkles', title: 'Asking for changes', body: 'Write a request, point at the page and follow the work.' },
{ icon: 'shield-check', title: 'Review and approve', body: 'Compare before and after, read the checks and publish.' },
{ icon: 'credit-card', title: 'Team and billing', body: 'Invite people, set roles and manage your plan.' },
{ icon: 'help', title: 'Troubleshooting', body: 'Fixes for the most common connection and preview problems.' },
];
+15
View File
@@ -0,0 +1,15 @@
'use client';
import { useEffect, useState } from 'react';
import { SearchField } from '../../product/SearchField.tsx';
import { useCommandPalette } from '../CommandPalette.tsx';
import { isApplePlatform, shortcutLabel } from '../search.ts';
/** The Docs hero search field. It opens the site search, so there is one search with one result list. */
export function DocsSearch() {
const { open } = useCommandPalette();
// The server can't know the visitor's platform, so it renders the design's ⌘K and the browser corrects it.
const [hint, setHint] = useState(shortcutLabel(true));
useEffect(() => setHint(shortcutLabel(isApplePlatform())), []);
return <SearchField label="Search the docs" shortcut={hint} placeholder="Search the docs…" onActivate={open} />;
}
+67
View File
@@ -0,0 +1,67 @@
import { ARTICLES, DOCS_TOPICS, NAV_LINKS, ROUTES } from './content.ts';
export type SearchGroup = 'Page' | 'Docs topic' | 'Article';
export type SearchEntry = {
id: string;
title: string;
description?: string;
group: SearchGroup;
href: string;
};
/**
* Everything the site search can open. Built from the same content the pages use, so it cannot drift.
* Only things with a destination are listed: an article that has no page yet would be a dead end.
*/
export const SEARCH_ENTRIES: readonly SearchEntry[] = [
{ id: 'home', title: 'Home', description: 'Your website, handled.', group: 'Page', href: '/' },
...NAV_LINKS.map((l): SearchEntry => ({
id: l.href.slice(1),
title: l.mobileLabel ?? l.label,
group: 'Page',
href: l.href,
})),
{ id: 'signup', title: 'Get started', description: 'Create your account.', group: 'Page', href: ROUTES.signUp },
{ id: 'signin', title: 'Sign in', description: 'Back to your sites.', group: 'Page', href: ROUTES.signIn },
...DOCS_TOPICS.map((t): SearchEntry => ({
id: `topic-${t.title.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`,
title: t.title,
description: t.body,
group: 'Docs topic',
href: '/docs',
})),
...ARTICLES.flatMap((a): SearchEntry[] =>
a.href ? [{ id: `article-${a.title.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`, title: a.title, group: 'Article', href: a.href }] : [],
),
];
/**
* Entries matching every word of the query, best first: titles that start with it, then titles that
* contain it, then description-only matches. With nothing typed, the pages are the suggestions.
*/
export function searchEntries(query: string, entries: readonly SearchEntry[] = SEARCH_ENTRIES): SearchEntry[] {
const q = query.trim().toLowerCase();
if (!q) return entries.filter((e) => e.group === 'Page');
const words = q.split(/\s+/);
return entries
.map((e, index) => ({ e, index, title: e.title.toLowerCase(), text: `${e.title} ${e.description ?? ''}`.toLowerCase() }))
.filter(({ text }) => words.every((w) => text.includes(w)))
.sort((a, b) => rank(a.title, q) - rank(b.title, q) || a.index - b.index)
.map(({ e }) => e);
}
const rank = (title: string, q: string) => (title.startsWith(q) ? 0 : title.includes(q) ? 1 : 2);
type KeyLike = Pick<KeyboardEvent, 'key' | 'metaKey' | 'ctrlKey' | 'shiftKey' | 'altKey'>;
/** Cmd+K or Ctrl+K, and nothing extra held. */
export function isPaletteShortcut(e: KeyLike): boolean {
return e.key.toLowerCase() === 'k' && (e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey;
}
export const shortcutLabel = (apple: boolean) => (apple ? '⌘K' : 'Ctrl K');
export function isApplePlatform(): boolean {
return /Mac|iPhone|iPad|iPod/.test(navigator.platform);
}
+37
View File
@@ -0,0 +1,37 @@
export type Theme = 'light' | 'dark';
/** localStorage key holding the visitor's explicit choice. Absent means "follow the device". */
export const THEME_KEY = 'wursor-theme';
const isTheme = (v: unknown): v is Theme => v === 'light' || v === 'dark';
/** The saved choice, or null if none (or storage is blocked or holds something unexpected). */
export function readStoredTheme(): Theme | null {
try {
const v = localStorage.getItem(THEME_KEY);
return isTheme(v) ? v : null;
} catch {
return null;
}
}
/** What the page is showing right now: the saved choice, else the device setting. */
export function currentTheme(): Theme {
return readStoredTheme() ?? (window.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
}
/** Show a theme (tokens.css keys off `data-theme`) and remember it. Works even if storage is blocked. */
export function applyTheme(theme: Theme): void {
document.documentElement.setAttribute('data-theme', theme);
try {
localStorage.setItem(THEME_KEY, theme);
} catch {
/* Private mode or blocked storage: the choice still applies for this visit. */
}
}
/**
* Inlined in <head> so a saved choice is in place before first paint (no flash of the wrong theme).
* Self-contained on purpose: it runs as a string, before any bundle loads.
*/
export const themeInitScript = `try{var t=localStorage.getItem('${THEME_KEY}');if(t==='light'||t==='dark')document.documentElement.setAttribute('data-theme',t)}catch(e){}`;
+22 -2
View File
@@ -1,3 +1,4 @@
import type { KeyboardEvent } from 'react';
import { Icon } from '../ui/Icon.tsx'; import { Icon } from '../ui/Icon.tsx';
import { Kbd } from './Kbd.tsx'; import { Kbd } from './Kbd.tsx';
@@ -7,14 +8,33 @@ type Props = {
shortcut?: string; shortcut?: string;
placeholder?: string; placeholder?: string;
disabled?: boolean; disabled?: boolean;
/** Makes the field a launcher: it cannot be typed in, and click, Enter or Space calls this instead. */
onActivate?: () => void;
}; };
/** Pill search field with a shortcut hint. Used in the top bar, the command palette and the docs. */ /** Pill search field with a shortcut hint. Used in the top bar, the command palette and the docs. */
export function SearchField({ label, shortcut, placeholder = 'Search or ask…', disabled = false }: Props) { export function SearchField({ label, shortcut, placeholder = 'Search or ask…', disabled = false, onActivate }: Props) {
return ( return (
<div className={`p-search${disabled ? ' p-search-disabled' : ''}`}> <div className={`p-search${disabled ? ' p-search-disabled' : ''}`}>
<Icon name="search" size={16} /> <Icon name="search" size={16} />
<input type="search" aria-label={label} placeholder={placeholder} disabled={disabled} className="p-search-input" /> <input
type="search"
aria-label={label}
placeholder={placeholder}
disabled={disabled}
className="p-search-input"
{...(onActivate && {
readOnly: true,
'aria-haspopup': 'dialog' as const,
onClick: onActivate,
onKeyDown: (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onActivate();
}
},
})}
/>
{shortcut && <Kbd>{shortcut}</Kbd>} {shortcut && <Kbd>{shortcut}</Kbd>}
</div> </div>
); );
+54
View File
@@ -69,6 +69,60 @@ button { font: inherit; color: inherit; }
color: var(--icon-color); color: var(--icon-color);
cursor: pointer; cursor: pointer;
} }
.nav-icon-button {
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--size-control-md);
height: var(--size-control-md);
border: 0;
border-radius: var(--radius-full);
background: transparent;
color: var(--icon-color);
cursor: pointer;
}
.nav-icon-button:hover { background: var(--color-bg-sunken); }
.theme-toggle-moon { display: none; }
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) .theme-toggle-sun { display: none; }
:root:not([data-theme="light"]) .theme-toggle-moon { display: inline-block; }
}
:root[data-theme="dark"] .theme-toggle-sun { display: none; }
:root[data-theme="dark"] .theme-toggle-moon { display: inline-block; }
/* ---------- Search palette (Cmd/Ctrl+K) ---------- */
.palette-backdrop {
position: fixed;
inset: 0;
z-index: 100;
display: flex;
align-items: flex-start;
justify-content: center;
padding: 12vh var(--space-4) var(--space-4);
background: color-mix(in srgb, var(--color-text-primary) 45%, transparent);
}
.palette {
display: flex;
flex-direction: column;
width: 560px;
max-width: 100%;
max-height: min(480px, 76vh);
overflow: hidden;
border-radius: var(--radius-xl);
background: var(--color-bg-surface);
border: 1px solid var(--color-border-default);
box-shadow: var(--shadow-modal);
}
.palette-field { display: flex; align-items: center; gap: var(--space-3); padding: 0 var(--space-4); height: 56px; flex: none; border-bottom: 1px solid var(--color-border-subtle); color: var(--color-text-secondary); }
.palette-input { flex: 1 0 0; min-width: 0; padding: 0; border: 0; outline: 0; background: transparent; color: var(--color-text-primary); font: 400 16px/24px var(--font-sans); }
.palette-input::placeholder { color: var(--color-text-secondary); opacity: 1; }
.palette-results { margin: 0; padding: var(--space-2); list-style: none; overflow-y: auto; }
.palette-option { display: flex; align-items: center; justify-content: space-between; gap: var(--space-4); padding: var(--space-2) var(--space-3); border-radius: var(--radius-md); color: var(--color-text-primary); }
.palette-option[aria-selected="true"] { background: var(--color-bg-sunken); }
.palette-option-text { display: flex; flex-direction: column; min-width: 0; }
.palette-option-desc { color: var(--color-text-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.palette-option-group { flex: none; color: var(--color-text-secondary); }
.palette-empty { margin: 0; padding: var(--space-6) var(--space-4); text-align: center; color: var(--color-text-secondary); }
@media (max-width: 767px) { .palette-backdrop { padding-top: var(--space-6); } }
.nav-menu { display: none; } .nav-menu { display: none; }
@media (max-width: 900px) { @media (max-width: 900px) {
+1 -1
View File
@@ -4,7 +4,7 @@ export type IconName =
| 'activity' | 'arrow-right' | 'bell' | 'board' | 'check' | 'camera' | 'check-circle' | 'chevron-down' | 'chevron-right' | 'chevron-up' | 'clock' | 'close' | 'credit-card' | 'dots' | 'activity' | 'arrow-right' | 'bell' | 'board' | 'check' | 'camera' | 'check-circle' | 'chevron-down' | 'chevron-right' | 'chevron-up' | 'clock' | 'close' | 'credit-card' | 'dots'
| 'eye' | 'globe' | 'help' | 'home' | 'key' | 'list' | 'loader' | 'lock' | 'menu' | 'mic' | 'monitor' | 'paperclip' | 'eye' | 'globe' | 'help' | 'home' | 'key' | 'list' | 'loader' | 'lock' | 'menu' | 'mic' | 'monitor' | 'paperclip'
| 'pencil' | 'plug' | 'plus' | 'pointer' | 'refresh' | 'rocket' | 'search' | 'shield-check' | 'sidebar' | 'sliders' | 'pencil' | 'plug' | 'plus' | 'pointer' | 'refresh' | 'rocket' | 'search' | 'shield-check' | 'sidebar' | 'sliders'
| 'shield' | 'smartphone' | 'sparkles' | 'split' | 'stop' | 'undo' | 'user' | 'x'; | 'shield' | 'smartphone' | 'sparkles' | 'split' | 'stop' | 'sun' | 'moon' | 'undo' | 'user' | 'x';
type Props = { name: IconName; size?: number; className?: string }; type Props = { name: IconName; size?: number; className?: string };