Skip to content

9. Command Palette

Press Cmd-K (or Ctrl-K). A popover opens with a search input and a list of actions. Type to filter. Enter to dispatch. The palette doesn’t know what the actions do — they’re just commands.

In index.html:

<div id="palette" popover="manual">
<kit-boundary surface="command-palette">
<input id="palette-input" type="search" placeholder="Type a command…" autocomplete="off" />
<ul id="palette-list" role="listbox"></ul>
</kit-boundary>
</div>

popover="manual" means we control open/close in JS. popover="auto" (the default) auto-closes on light dismiss; for a palette we want explicit control.

src/palette-actions.ts:

export type PaletteAction = {
id: string
label: string
command: string
payload?: Record<string, unknown>
shortcut?: string
}
export const PALETTE_ACTIONS: PaletteAction[] = [
{
id: 'new-note',
label: 'New note…',
command: 'dialog.open',
payload: { target: 'new-note-dialog' },
shortcut: 'N',
},
{
id: 'clear-toasts',
label: 'Clear notifications',
command: 'notification.clear',
},
{
id: 'simulate-toast',
label: 'Simulate a toast',
command: 'notification.show',
payload: { message: 'Hello from the palette', tone: 'info' },
},
]

Each action is a row of what command should fire when this is picked. Adding a new entry doesn’t touch the palette logic.

src/palette.ts:

import { PALETTE_ACTIONS, type PaletteAction } from './palette-actions.js'
import type { KitRuntime } from '@atheory-ai/kitsune-core'
export function mountPalette(runtime: KitRuntime) {
const palette = document.getElementById('palette') as HTMLDivElement & { showPopover(): void; hidePopover(): void }
const input = document.getElementById('palette-input') as HTMLInputElement
const list = document.getElementById('palette-list') as HTMLUListElement
let active = 0
let filtered: PaletteAction[] = PALETTE_ACTIONS
const draw = () => {
list.innerHTML = filtered
.map((action, i) => `
<li role="option" data-id="${action.id}" aria-selected="${i === active}">
<span>${action.label}</span>
${action.shortcut ? `<kbd>${action.shortcut}</kbd>` : ''}
</li>
`)
.join('')
}
const filter = (query: string) => {
const q = query.trim().toLowerCase()
filtered = q
? PALETTE_ACTIONS.filter((a) => a.label.toLowerCase().includes(q))
: PALETTE_ACTIONS
active = 0
draw()
}
const dispatch = async (action: PaletteAction) => {
palette.hidePopover()
await runtime.command({ type: action.command, payload: action.payload })
}
input.addEventListener('input', () => filter(input.value))
input.addEventListener('keydown', (e) => {
if (e.key === 'ArrowDown') {
active = Math.min(active + 1, filtered.length - 1)
draw()
e.preventDefault()
} else if (e.key === 'ArrowUp') {
active = Math.max(active - 1, 0)
draw()
e.preventDefault()
} else if (e.key === 'Enter') {
const action = filtered[active]
if (action) void dispatch(action)
e.preventDefault()
} else if (e.key === 'Escape') {
palette.hidePopover()
}
})
list.addEventListener('click', (e) => {
const li = (e.target as HTMLElement).closest('li')
if (!li) return
const id = li.dataset.id
const action = filtered.find((a) => a.id === id)
if (action) void dispatch(action)
})
document.addEventListener('keydown', (e) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
e.preventDefault()
input.value = ''
filter('')
palette.showPopover()
input.focus()
}
})
draw()
}

In main.ts:

import { mountPalette } from './palette.js'
mountPalette(shell.runtime)
#palette {
border: none;
background: transparent;
inset: auto;
margin-block-start: 4rem;
margin-inline: auto;
padding: 0;
inline-size: min(36rem, calc(100vw - 2rem));
}
#palette > kit-boundary {
background: light-dark(white, oklch(25% 0 250));
border: 1px solid light-dark(oklch(85% 0 0), oklch(35% 0 250));
border-radius: 0.5rem;
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.18);
display: block;
padding: 0.5rem;
}
#palette-input {
appearance: none;
border: none;
background: transparent;
color: inherit;
font: inherit;
inline-size: 100%;
outline: none;
padding: 0.5rem 0.75rem;
}
#palette-list {
border-block-start: 1px solid light-dark(oklch(90% 0 0), oklch(40% 0 250));
list-style: none;
margin: 0;
max-block-size: 24rem;
overflow-y: auto;
padding: 0.25rem 0;
}
#palette-list li {
align-items: center;
border-radius: 0.25rem;
cursor: pointer;
display: flex;
gap: 0.5rem;
justify-content: space-between;
padding: 0.5rem 0.75rem;
}
#palette-list li[aria-selected='true'] {
background: light-dark(oklch(95% 0.02 248), oklch(35% 0.04 248));
}
#palette kbd {
font-size: 0.75rem;
padding: 0.125rem 0.375rem;
border: 1px solid currentColor;
border-radius: 0.25rem;
opacity: 0.6;
}
  • Cmd-K opens the palette.
  • “New note” opens the new-note dialog (via dialog.open).
  • “Clear notifications” clears any visible toasts (via notification.clear).
  • “Simulate a toast” pops an info toast (via notification.show).

The palette dispatched commands. It didn’t import any modules. Whatever’s installed handles them.

Quill has a working palette in around 80 lines of glue. Everything routes through the runtime.

A few things you didn’t write:

  • A focus trap (the popover provides one within the popover layer).
  • A backdrop (we don’t need one — palette is non-modal by design).
  • A way for screen readers to know it’s a listbox (the role="listbox" and role="option" markup did that).

The architectural payoff chapter. We’ll add a fully working analytics module without touching a single existing file. You’ll see what a properly modular frontend buys you.

Next: Chapter 10 — Analytics, Without Touching the UI →