9. Command Palette
What we want
Section titled “What we want”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.
The palette markup
Section titled “The palette markup”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.
A small action registry
Section titled “A small action registry”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.
Wire it up
Section titled “Wire it up”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;}Try it
Section titled “Try it”Cmd-Kopens 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.
What we have now
Section titled “What we have now”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"androle="option"markup did that).
What’s next
Section titled “What’s next”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.