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
+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 { NAV_LINKS, ROUTES } from './content.ts';
import { Icon } from '../ui/Icon.tsx';
import { useCommandPalette } from './CommandPalette.tsx';
import { ThemeToggle } from './ThemeToggle.tsx';
export function Nav() {
const [open, setOpen] = useState(false);
const pathname = usePathname();
const palette = useCommandPalette();
useEffect(() => {
if (!open) return;
@@ -38,6 +41,10 @@ export function Nav() {
</nav>
<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">
Sign in
</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 const NAV_LINKS: readonly NavLink[] = [
@@ -132,3 +134,13 @@ export const ARTICLES: ReadonlyArray<{ title: string; href?: string }> = [
{ title: 'Invite your team' },
{ 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 { Kbd } from './Kbd.tsx';
@@ -7,14 +8,33 @@ type Props = {
shortcut?: string;
placeholder?: string;
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. */
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 (
<div className={`p-search${disabled ? ' p-search-disabled' : ''}`}>
<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>}
</div>
);
+54
View File
@@ -69,6 +69,60 @@ button { font: inherit; color: inherit; }
color: var(--icon-color);
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; }
@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'
| 'eye' | 'globe' | 'help' | 'home' | 'key' | 'list' | 'loader' | 'lock' | 'menu' | 'mic' | 'monitor' | 'paperclip'
| '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 };