# Cmd K Command Palette

> Build a Cmd+K command palette in a Frappe / frappe-ui Vue app: one dialog that jumps to any record, space or app page and runs actions. A checklist covering scope, the search endpoint, ranking, groups, keyboard handling, accessibility, recents and tests. Use when the user wants a command palette, quick switcher, global search, Cmd+K, Ctrl+K, or "jump to anything" in a Frappe app.

- Skill: `gajjug004/cmd-k-command-palette` (Agent Skill)
- Install (CLI): `npx skillmds@latest add gajjug004/cmd-k-command-palette`
- Raw SKILL.md: https://api.skillmd.com/api/skills/gajjug004/cmd-k-command-palette/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: gajjug004 (https://skillmd.com/u/gajjug004)
- Updated: 2026-09-22
- Page: https://skillmd.com/skills/gajjug004/cmd-k-command-palette

---


# Cmd K Command Palette

Checklist for building a Cmd+K palette in a frappe-ui Vue app. Built from doing it for wiki; Gameplan's `frontend/src/components/CommandPalette/` is the reference.

## Decide what it is before you build it

- [ ] **It navigates, it does not search.** It cuts clicks to a destination the user can already name. Full-text search needs a results page, snippets, paging and ranking: a different feature.
- [ ] **List the searches the app already has** and say what the palette replaces. Usually nothing. A filter over a visible list is a different job from jumping somewhere else.
- [ ] **It crosses boundaries.** If every existing box searches one container, the palette's whole reason to exist is finding the thing in another container.
- [ ] **No modes.** Context biases the ranking, it never scopes the results. A palette that hides destinations fails the case it was built for.

## Checklist

### 1. The search endpoint

- [ ] One whitelisted method, not the reader's search index. A published-only content index misses drafts, which is exactly what an editor works on.
- [ ] Use `frappe.get_list`, never `get_all`. It applies the app's permission query conditions, so restricted records stay hidden with no new permission code.
- [ ] Pull the parent's fields through the link join (`parent.title as parent_title`) instead of a second query.
- [ ] Filter out what cannot be opened: groups, external links, records with no parent.
- [ ] Decide what the query matches, route or title, and write it down. Matching the route means a renamed record is found by its old words, because `set_route` only runs while the route is empty.
- [ ] Normalise the query to the stored form (spaces to hyphens for routes).
- [ ] Order by `modified desc` and cap at ~20 rows. The client re-ranks.
- [ ] Annotate the argument (`query: str`), and frappe rejects a wrong type for you.

### 2. Ranking on the client

- [ ] Rank with `fuzzysort` (already a frappe-ui dependency). Do not add a search library.
- [ ] Put ranking in a plain `lib/` module that takes sources and returns groups. Testable with no component.
- [ ] Two thresholds: a loose one for short labels, a stricter one for long paths, because a short query scatter-matches letters across a long path.
- [ ] Match a page on its path inside its parent, not the full route: every sibling shares the parent segment.
- [ ] Bias, do not filter: give items in the user's current context a `scoreScale` (1.5 is enough to win a tie, not enough to bury a better match).
- [ ] Re-rank whatever rows are in hand against the current query. Stale rows from an in-flight request then drop out on their own, no request tracking.
- [ ] Give action items a `search` string of synonyms ("new page create page add page") and rank on that, not the label.
- [ ] Drop empty groups.

### 3. Groups and rows

- [ ] Fixed group order: Jump to, Spaces / parents, Actions, Pages. Flatten to one list for keyboard movement.
- [ ] **An empty query answers before a key is pressed:** app destinations, actions, and Recent.
- [ ] Two-line rows: title, then the `/route` muted below. Titles repeat across containers, the route is what tells them apart and what the query matched. A one-line row with the route right-aligned looks ragged: truncation starts each route at a different x.
- [ ] Mark state that changes where a click lands (unpublished, private) with an icon at the right edge.
- [ ] Fetch the local sources (spaces, parents) once, on first open, not on app boot.
- [ ] Debounce the server query 300ms and only fire at 2+ characters.

### 4. Keyboard

- [ ] Register with frappe-ui `useKeyboardShortcut({ combo: 'Mod+K' })`. `Mod` is Cmd on macOS, Ctrl elsewhere.
- [ ] `allowInInput: true` so it works inside the editor (contenteditable counts as input), plus `allowInDialog: true`.
- [ ] `preventDefault: false`, and bail out on `event.defaultPrevented`. That is what lets a feature that already claimed Cmd+K (an editor link popup) keep it where it means something, with no change to the other keymap.
- [ ] Do not "fix" the other keymap to give the key back conditionally. If its handler already returns `false` when it has nothing to do, the key falls through by itself, and a conditional break the case where it should win.
- [ ] Arrow keys, Enter and Escape on the input. Escape comes free from the dialog.
- [ ] **Track the active row by item key, not list index.** A group that loads late otherwise moves the row under the user's finger. Unknown key falls back to the top row.
- [ ] Reset query and active key on close, not on open, so the closing animation does not flash an empty list.
- [ ] `scrollIntoView({ block: 'nearest' })` on move.

### 5. Accessibility

- [ ] Input is `role="combobox"` with `aria-autocomplete="list"`, `aria-controls` and `aria-activedescendant` pointing at the active row's id.
- [ ] Results container is `role="listbox"`, each group `role="group"` with `aria-labelledby`, each row `role="option"` with `aria-selected`.
- [ ] Dialog needs a title even when it is not drawn: `<Dialog.Title class="sr-only">`.
- [ ] Icons that repeat the text are `aria-hidden="true"`.
- [ ] Footer hints with `KeyboardShortcut` so the shortcuts are discoverable.

### 6. Recents

- [ ] Recents are **the pages this user opened**, not the records recently modified. A `modified desc` query is the app's edit activity and fills the palette with pages the user has never seen.
- [ ] So they belong in localStorage, next to pins, written when a page opens and has a title.
- [ ] **Key the storage by user id.** localStorage outlives a logout, so one key leaks the previous user's titles to the next person in that browser.
- [ ] Store everything a row needs (id, title, parent, route). A row that has to look up the route renders the wrong subtitle for old entries.
- [ ] Cap at five, newest first, a revisit moves to top, leave out the page the user is on: it is not somewhere to go.

### 7. Discoverability

- [ ] A shortcut alone tells nobody the palette exists. Add one sidebar row with a `Mod+K` hint.
- [ ] One entry point only. A second search affordance next to an existing filter box is clutter.
- [ ] No shortcut on mobile, so no palette there.

### 8. Do not build

- [ ] **No command registry.** One palette registering its own items is not a framework. Add a registry when a second place needs to register into it.
- [ ] No match highlighting, no aliases with score penalties, no scope chips, no full-text results row unless there is a results page to land on.

### 9. Tests

- [ ] Endpoint integration tests: the match rule, excluded kinds, the parent join, and **a restricted record hidden from an outsider and shown to a member**. Swap `get_list` for `get_all` and that test must fail.
- [ ] Unit tests on the ranking lib: empty-query groups, empty groups dropped, path match not title, spaced query against a hyphenated path, stale rows, minimum query length, context bias.
- [ ] Unit tests on recents: newest first, revisit moves to top, rename, cap, untitled ignored, per-user lists.
- [ ] Playwright: a record in another container, open from the sidebar row, Escape, recents without the current page, the active row staying put while a group loads, and **the shortcut shared with the other feature that binds it**.

## Spec notes worth keeping

Record the wrong turns in the spec. The ones that came up here:

- ⌘K focusing the existing search box instead of opening a palette. Neither box crossed a container, which was the missing thing.
- Context modes (spaces only here, pages only there). Hides the record in the other container, the gap the feature closes.
- A recents endpoint ordered by `modified`. That is edit activity, not history.

