The Spotlight command palette — Cmd+K from anywhere in the admin opens a fuzzy-matched action / search interface. It owns the editor's keyboard surface: every Spotlight-registered command works exactly the same way as a built-in command.
Spotlight is mounted by <SpotlightRoot> inside AuthenticatedAdmin (post-login chunk), so it's available across every workspace and across plugin admin pages.
- Mount point:
<SpotlightRoot>inAuthenticatedAdmin.tsx. Wraps the whole post-login app. - Trigger: ⌘K / Ctrl+K (global keydown). Esc closes (or clears query if non-empty).
- Built-in commands:
src/admin/spotlight/builtinCommands.ts. Returns the staticCommand[]. - Async providers:
src/admin/spotlight/providers/*.ts(pages, media, content, data, plugin pages, site files). Run in parallel as the query changes. - Plugin commands: register via the SDK at activation.
PluginCommanduses the SDK shape (label, optionalsubtitle,args,workspaces, etc.) and is synthesized into internalCommandrows under thepluginsgroup. - State:
useReducerinSpotlightRoot. Recent commands persisted inlocalStorageviarecentStore. - Scopes: a scope narrows the palette to a single domain (e.g. "Find page", "Run command on selected node").
- Lazy: the heavy
<Spotlight>chunk isReact.lazy— only downloads on first open.
src/admin/spotlight/
├── SpotlightRoot.tsx — context + ⌘K listener + state reducer
├── Spotlight.tsx — the dialog (lazy-loaded)
├── Spotlight.module.css — palette chrome (panel surface + blur)
├── SpotlightRow.tsx — single result row
├── SpotlightResults.tsx — grouped result list
├── SpotlightFooter.tsx — keyboard hints / status
├── SpotlightSkeleton.tsx — loading state shimmer
├── builtinCommands.ts — built-in commands registry
├── commandRegistry.ts — getScope, filterCommands, getPluginPaletteSpotlightProviders
├── providerRunner.ts — async provider scheduler (cache + abort)
├── providers/ — per-domain providers
│ ├── serverProvider.ts — shared factory for server-backed providers
│ ├── schemas.ts — TypeBox response schemas (one per endpoint)
│ ├── pagesProvider.ts — page search (local, reads editor store)
│ ├── siteFilesProvider.ts — site file search (local, reads editor store)
│ ├── mediaProvider.ts — media search (server)
│ ├── contentProvider.ts — data row search (server)
│ ├── dataProvider.ts — data table search (server)
│ └── pluginPagesProvider.ts — plugin admin page search (server)
├── __tests__/
│ └── serverProvider.test.ts — unit tests for the shared server provider factory
├── commands/ — command implementations grouped by domain
├── scopes/ — scope definitions (find page, find component, ...)
├── matcher.ts — fuzzy match scoring
├── recentStore.ts — localStorage-backed recently-used
├── keybindings.ts — declarative keybinding registry
├── state.ts — reducer state types
├── stateHandlers.ts — reducer action handlers
├── stateTypes.ts — reducer state/action type aliases
├── spotlightContext.ts — React context (separated for fast-refresh)
├── spotlightControls.ts — programmatic controls (open / close / set query)
├── spotlightSearch.ts — search query parsing (scope:, action: prefixes)
├── telemetry.ts — usage logging
├── groupAccent.ts — maps CommandGroup → categorical accent
├── HelpKeybindingsList.tsx — Cmd+? help screen
├── pendingAction.ts — confirm-destructive flow
├── types.ts — Command, SpotlightProvider, Scope, CommandContext
└── index.ts — barrel
interface Command {
id: CommandId // 'editor.publish', 'site.add-page'
title: string // "Publish site" — primary row label
subtitle?: string // shown under the label
group: CommandGroup // 'editor' | 'site' | 'content' | 'media' | 'pages' | …
iconName?: string // pixel-art-icons name, e.g. 'save-solid'
keywords?: string[] // extra search terms (low weight)
/** Capability gate — palette filters before display. */
capability?: string | readonly string[]
/** Workspace gate — only show on these workspaces. 'any' = always. */
workspaces?: ReadonlyArray<AdminWorkspace | 'any'>
/** Predicate run at query time — finer-grained gating. */
when?: (ctx: CommandContext) => boolean
/** Boosts ranking when `when` returns true. Default 1.0. */
priorityBoost?: number
/** Argument prompts — multi-step input flow. */
args?: CommandArg[]
/** Dangerous commands show danger styling + inline confirm. */
destructive?: boolean
/** If true, palette stays open after run. */
keepOpenAfterRun?: boolean
/**
* The execution function. May return `{ pushScope }` to start a scoped sub-flow.
* Shortcut hints are NOT stored here — they live in `keybindings.ts`.
*/
run: (ctx: CommandRunContext) => void | Promise<void> | { pushScope: string }
}A Command knows everything about itself: where it's visible, what arguments it needs, whether it's destructive. The palette is purely a UI for the registry.
Two related types flow through Spotlight:
/** Snapshot built once per palette open. Passed to search / when predicates. */
interface CommandContext {
workspace: AdminWorkspace
pathname: string
user: CmsCurrentUser
/** Populated by SpotlightRoot when the site editor is the active workspace. */
editor?: {
selectedNodeIds: ReadonlyArray<string>
activePageId: string | null
activeDocument: ActiveDocument | null
canUndo: boolean
canRedo: boolean
activeBreakpointId: string
activeInlineEdit: boolean
}
}
/** Extended context injected into command.run(). Adds action callbacks. */
interface CommandRunContext extends CommandContext {
args: Record<string, string> // collected sub-command arguments
navigate: (path: string) => void
closeSpotlight:() => void
pushScope: (scopeId: string, args?: Record<string, string>) => void
popScope: () => void
/** Wraps an action in the step-up re-auth flow. */
runStepUp: <T>(action: () => Promise<T>) => Promise<T>
}Built by SpotlightRoot on every open. Inside the editor (workspace === 'site'), SpotlightRoot subscribes to the editor store via subscribeWithSelector to track the active page / selected node / mode.
The subscription is dropped on close to avoid spurious re-renders.
src/admin/spotlight/builtinCommands.ts exports the static command set. Common groups:
| Group | Examples |
|---|---|
editor |
Publish, Save, Undo, Redo, Wrap in container, Toggle preview |
pages |
Add page, Open page settings |
content |
New post, Edit post |
navigation |
Go to dashboard, Go to site, Go to media, Go to plugins, … |
settings |
Open framework scale, Open site settings |
ai |
Open / focus AI assistant |
account / users |
Account security, session revocation, user management |
Each command's when(ctx) / workspaces / capability fields filter by user capability + workspace context. filterCommands(commands, ctx) runs once per palette open.
Providers run as the user types. Each provider produces results for one domain:
interface SpotlightProvider {
id: string
/** Becomes the group header in results. */
label: string
/**
* Called with the current query + context. Returns Commands to merge into
* the palette. Should be quick — debounced + cached by the runner.
*/
search: (query: string, ctx: CommandContext, signal: AbortSignal) => Promise<Command[]> | Command[]
/** Debounce in ms — applied per provider. 0 = synchronous each keystroke. */
debounceMs?: number
}ProviderRunner in providerRunner.ts:
- Fires all providers in parallel on query change
- Debounces per provider
- Caches results per
(provider, query)for 30 seconds, and clears the cache on close / scope reset AbortControllercancels in-flight requests on close or query change
There are two kinds of provider:
Local providers (pagesProvider, siteFilesProvider) read data from the editor store synchronously. No HTTP call, debounceMs: 0.
Server providers (mediaProvider, contentProvider, dataProvider, pluginPagesProvider) fetch via /admin/api/cms/.... They are built with shared scaffolding in serverProvider.ts (see below).
serverProvider.ts exports two primitives that all server-backed providers use:
makeServerProvider(config) — the common case factory. Builds a SpotlightProvider from a TypeBox schema, an array selector, and a Command mapper. Handles the empty-query guard, ?query=&limit= URL construction, apiRequest fetch, abort handling, and result mapping:
export const dataProvider = makeServerProvider({
id: 'data',
label: 'Data',
debounceMs: 150,
endpoint: '/admin/api/cms/data/tables',
schema: DataTablesListResponseSchema,
select: (body) => body.tables,
toCommand: (table): Command => ({
id: `data:${table.id}`,
title: table.name,
group: 'data',
iconName: 'table-solid',
run: (ctx) => { ctx.closeSpotlight(); ctx.navigate(`/admin/data?table=${table.id}`) },
}),
})fetchOnAbortEmpty(url, schema, signal) — the lower-level primitive. Fetches and validates the URL, returning null on abort instead of throwing. Use this directly when the provider has a genuinely different shape (e.g. no ?query= param, custom client-side filtering) that makeServerProvider can't model. pluginPagesProvider is the canonical example.
Both helpers validate the response body against a TypeBox schema via apiRequest — no as Foo past the HTTP boundary. Response schemas live in providers/schemas.ts.
MAX_RESULTS = 25 is exported from serverProvider.ts and used by all providers as the result cap.
Plugins with editor.commands permission can register Spotlight providers via the SDK. Plugin providers go through getPluginPaletteSpotlightProviders(), are wrapped as provider ids like plugin:<pluginId>:<providerId>, run in the same ProviderRunner as built-ins, and are included on every active scope so plugin search stays available while the user drills into scopes. Provider errors are logged and converted to empty result groups.
A scope narrows the palette to a single domain. Scopes are stacked (ScopeFrame[]) — a deeper scope pushed by a command knows how to pop back when its action completes.
interface Scope {
id: string // 'root' | 'pages' | 'content' | …
title?: string // header text in argument mode
placeholder?: string
/** Synchronous static commands offered by this scope. */
commands: () => Command[]
/** Async providers — called with debounced query + AbortSignal. */
providers?: SpotlightProvider[]
}When a scope is active:
- The header shows the scope title.
- Only the scope's
commands()andprovidersare queried. - Backspace on an empty query pops back to the unscoped state.
- A command's
runmay return{ pushScope: 'scope-id' }to enter a scope programmatically.
Spotlight.tsx is the dialog itself — search input, scope chips, result list, footer with keyboard hints. It uses the global panel, overlay, accent, and state tokens from docs/reference/design-tokens.md, with only z-index and width scoped to Spotlight.
Key behaviors:
- Lazy chunk.
SpotlightisReact.lazy-loaded insideSpotlightRoot; first-time open downloads the palette code. - Backdrop blur (
--backdrop-blur: 8px). - Card rows. Result rows have a border-radius and are laid out with a 1px gap — the same tile-card language as the module inserter and dashboard.
- Categorical group accents. Each command group gets a stable numbered accent identity (e.g.
editor→--accent-2,navigation→--accent-3,media→--accent-4). The accent drives the icon chip color and the group-header accent bar. Mapping lives ingroupAccent.ts. - Row selection highlight via
--overlay-10. - Fuzzy match highlighting — matched characters wrapped in
<mark>with--success-20. - Skeleton shimmer while providers are in flight.
Commands with destructive: true enter a two-press confirm flow:
First Enter: row background turns red (--danger-10), label shows the confirm prompt
Second Enter: command runs
Esc / move: resets to the normal state
Used by destructive commands: delete user, sign out all devices, revoke session, delete VC.
| Key | Action |
|---|---|
| ⌘K / Ctrl+K | Open / close |
| ⌘I / Ctrl+I | Open the AI assistant panel |
| ⌘, / Ctrl+, | Open Settings |
| Esc | Clear query (or close if empty) |
| Arrow up / down | Move selection |
| Enter | Run selected (twice for destructive commands) |
| Tab / Arrow right | Enter arg mode or run the selected scope-pushing command |
| Backspace (empty) | Pop scope |
| ? | Show all keybindings |
| Custom command shortcuts | Per-command entry in keybindings.ts |
Selected-layer shortcuts are command shortcuts too. ⌘C / Ctrl+C, ⌘X / Ctrl+X, ⌘V / Ctrl+V, and ⌘D / Ctrl+D run when focus is on the canvas or the Layers tree. ⌘⌫ / Ctrl+Backspace deletes the selected layer from either surface through the normal delete confirmation flow; plain Delete / Backspace remains accepted by the canvas handler for selected canvas nodes.
The keybindings registry is the single source of truth for shortcuts — gated by keybindings-registry-single-source.test.ts. Register each command shortcut in keybindings.ts; component-owned handlers may consume those registered bindings when a surface needs local selection or confirmation behavior, but they must not hard-code a second shortcut definition.
recentStore.ts persists the last N executed command ids in localStorage (instatic-spotlight-recents). When the palette opens with an empty query, the recents float to the top.
The store is per-device, not per-user, because it sits in localStorage.
Add the command to the relevant domain factory in src/admin/spotlight/commands/. If it is a new command family, import that factory in src/admin/spotlight/builtinCommands.ts so getAllCommands() includes it:
// src/admin/spotlight/commands/navigation.ts
{
id: 'navigation.openDashboard',
title: 'Open Dashboard',
group: 'navigation',
iconName: 'dashboard-solid',
keywords: ['home', 'admin'],
workspaces: ['any'],
run: (ctx) => {
ctx.closeSpotlight()
ctx.navigate('/admin/dashboard')
},
}To add a keyboard shortcut, register the command id in src/admin/spotlight/keybindings.ts — shortcut hints are looked up at render time, not stored on the Command.
Use makeServerProvider from src/admin/spotlight/providers/serverProvider.ts. Supply the TypeBox schema for the endpoint's response, a selector that picks the array from the body, and a mapper that converts each item to a Command:
// src/admin/spotlight/providers/myThingsProvider.ts
import { makeServerProvider } from './serverProvider'
import { MyThingsResponseSchema } from './schemas'
import type { Command } from '../types'
export const myThingsProvider = makeServerProvider({
id: 'myThings',
label: 'Things',
debounceMs: 150,
endpoint: '/admin/api/cms/things',
schema: MyThingsResponseSchema,
select: (body) => body.things,
toCommand: (thing): Command => ({
id: `thing:${thing.id}`,
title: thing.name,
subtitle: thing.category,
group: 'results',
iconName: 'star-solid',
run: (ctx) => {
ctx.closeSpotlight()
ctx.navigate(`/admin/things/${thing.id}`)
},
}),
})Add the response schema to providers/schemas.ts, expose the provider through the relevant command factory in src/admin/spotlight/commands/, and include that factory in src/admin/spotlight/builtinCommands.ts when it is a new top-level command family. src/admin/spotlight/commandRegistry.ts remains the scope/filtering boundary.
For a provider that needs custom filtering or no ?query= param, use fetchOnAbortEmpty directly (see pluginPagesProvider.ts as a reference).
// src/admin/spotlight/scopes/myThingsScope.ts
const myThingsScope: Scope = {
id: 'find-thing',
title: 'Find thing',
placeholder: 'Search things…',
commands: () => [],
providers: [myThingsProvider],
}Register in src/admin/spotlight/scopes/. Then a command can enter the scope:
{
id: 'site.find-thing',
title: 'Find thing…',
group: 'navigation',
run: () => ({ pushScope: 'find-thing' }),
}Plugins with editor.commands permission register commands from their editor entrypoint at activation:
// plugin editor/index.ts
export function activate(api) {
api.editor.palette.registerCommand({
id: 'acme.do-thing',
label: 'Do the thing',
subtitle: 'Runs a plugin command',
workspaces: ['any'],
run: async () => { /* … */ },
})
}api.editor.commands.register(...) and api.editor.palette.registerCommand(...) both register the same SDK PluginCommand shape. The host maps label to the internal row title, namespaces the id as plugin:<pluginId>.<commandId>, preserves subtitle, iconName, keywords, destructive, args, and workspaces, then runs the command through pluginRuntime.runCommand(...).
See docs/features/plugin-system.md.
Inside the editor workspace, ctx.editor carries a snapshot of active page / selection. For deeper reads call useEditorStore.getState() directly:
run: async (ctx) => {
const state = useEditorStore.getState()
const node = state.site.activePage.nodes[state.selection.selectedNodeId]
// ...
}run: async (ctx) => {
await ctx.runStepUp(async () => {
await apiRequest('/admin/api/cms/sensitive', { method: 'POST', schema: OkSchema })
})
}runStepUp wraps the action with the step-up re-auth dialog. On cancel it throws Error('step_up_cancelled') — Spotlight catches and ignores that automatically.
| Pattern | Use instead |
|---|---|
Adding a raw keydown listener for a global shortcut |
Register in keybindings.ts. Gated. |
Direct store mutation inside a provider's search |
Providers are read-only — mutate in commands' run. Gated by spotlight-no-direct-store-mutation.test.ts. |
| Persisting recents server-side | They're per-device in localStorage. Cross-device recents need a real feature, not a Spotlight detail. |
| Lazy-importing the editor store at module-eval time | The store mounts only when SitePage mounts — eager import would force the chunk. Use require(...) inside search (see pagesProvider.ts). |
Long-running providers without signal handling |
The runner aborts on close — return [] when signal.aborted. |
| Multi-screen flow inside a single command | Use scopes — each step pushes a new scope frame. |
Hand-rolling fetch + isAbortError in a server provider |
Use makeServerProvider or fetchOnAbortEmpty from serverProvider.ts. |
Using as Foo past a JSON boundary in a provider |
Pass a TypeBox schema to makeServerProvider or fetchOnAbortEmpty. Gated by boundary-validation.test.ts. |
- docs/architecture.md — admin shell mount points
- docs/editor.md —
SpotlightRootplacement - docs/features/plugin-system.md — plugin commands + providers
- docs/reference/design-tokens.md — global UI token reference
- Source-of-truth files:
src/admin/spotlight/SpotlightRoot.tsx— mount + statesrc/admin/spotlight/Spotlight.tsx— the dialogsrc/admin/spotlight/builtinCommands.ts— built-in registrysrc/admin/spotlight/commandRegistry.ts— scopes + filteringsrc/admin/spotlight/providerRunner.ts— async provider schedulersrc/admin/spotlight/providers/serverProvider.ts— shared server-provider factorysrc/admin/spotlight/providers/schemas.ts— TypeBox response schemassrc/admin/spotlight/providers/*.ts— per-domain providerssrc/admin/spotlight/matcher.ts— fuzzy matchsrc/admin/spotlight/types.ts—Command,SpotlightProvider,Scope,CommandContextsrc/admin/spotlight/keybindings.ts— keybinding registrysrc/admin/spotlight/groupAccent.ts— CommandGroup → categorical accent mapping
- Gate tests:
src/__tests__/architecture/spotlight-no-direct-store-mutation.test.tssrc/__tests__/architecture/keybindings-registry-single-source.test.ts