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:
co-authored by
Claude Sonnet 5.5
parent
0cff3902b2
commit
f69ce33dd0
@@ -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,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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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.' },
|
||||
];
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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){}`;
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
|
||||
|
||||
Reference in New Issue
Block a user