frontend · intermediate · 5d
Command palette
A ⌘K command palette with fuzzy ranking, async action sources, and complete keyboard control (arrows, Enter, Escape, scoping) — the interaction layer every power-user tool needs.
Deliverable
A palette that opens with ⌘K, fuzzy-filters (pure scored function, highlighted matches) across sync + async sources, supports nested scoping with breadcrumbs, and is fully keyboard/a11y operable with virtualized rendering for 10k+ items.
Milestones
0/5 · 0%- 01Registry + keyboard control
Build the command registry and the keyboard state machine that drives the palette. The registry is a flat map of `{id, label, action, keywords?, scope?}` with register/unregister at runtime so any feature can add commands. The palette opens/closes with ⌘K (and Ctrl+K on non-Mac), captures arrow keys to move the active item, Enter to run it, and Escape to close — all without the mouse. Focus is trapped inside the open palette (Tab cycles within it) and restored to the trigger element on close so keyboard users don't lose their place. The keyboard logic is a pure reducer `(state, key) → state` with no DOM access: selection wraps correctly in both directions, works with a single-item or empty list, and handles rapid key presses without dropping events. Prove it: open with ⌘K, navigate with arrows (including wrapping at boundaries), run with Enter, close with Escape, and show focus returns to the trigger — all in a headless test without a DOM.
Definition of done- ⌘K/Ctrl+K opens/closes the palette; arrow keys move the active item with wrapping at both ends (including 1-item and empty lists), Enter runs the active command, Escape closes — all without the mouse and with the reducer as a pure function.
- Focus is trapped inside the open palette (Tab cycles within) and restored to the trigger on close — verified with a focus-trap test and a headless reducer test with no DOM.
Feeds fromSelf-review
Show the pure reducer test (wrapping, empty, single-item, rapid keys) and the focus-trap/restore test. A senior reviewer checks the reducer has no DOM access and that focus management is wired outside it.
- 02Fuzzy ranking (pure scoring function)
Add fuzzy filtering where the ranking is a pure function `(items, query) → sorted items` with no side effects — no state mutations, no DOM, no async. Subsequence matching (every character of the query appears in order, not necessarily adjacent) is cheaper and more intuitive than Levenshtein: 'fc' matches 'fuzzyCheck' because 'f' appears before 'c', and scoring rewards prefix matches and contiguous runs while penalizing scattered matches and gaps (e.g. bonus for matching at word boundaries/camelCase initials). The function highlights matched characters, handles empty query (show all) and no-match (explicit empty state), breaks ties deterministically, and handles Unicode boundaries correctly. Its complexity is O(n·m) per item (n=query len, m=item len) and O(N·m log N) total — state it and justify it. Keep it separately testable in Node: same inputs → same output, so memoization is trivial and the rendering layer stays a thin view.
Definition of done- Scoring is a pure function (items, query) → sorted array with highlighted match indices; prefix/contiguous matches rank above scattered ones, empty query shows all, no match shows an empty state, ties are deterministic, and Unicode boundaries are handled.
- The function is unit-tested in Node without a DOM (including Unicode and tie cases) and its O(N·m log N) complexity is stated with justification for why subsequence beats Levenshtein here.
Feeds fromSelf-review
Show the pure-function tests (prefix > scattered, Unicode, ties, empty/no-match) and explain why subsequence O(n) per candidate beats Levenshtein for command search. A senior reviewer checks the function has no side effects and complexity is stated.
- 03Async sources with debounce + cancellation
Support async action sources (e.g. a search API) alongside sync commands without race conditions. Two mechanisms matter: (1) debouncing — delay invoking the async source until the user pauses typing (150–250 ms) so you don't fire a network round trip on every keystroke; (2) cancellation — use AbortController to cancel the in-flight fetch when the next keypress fires before the previous result arrives, so a slow response from an earlier query never overwrites a faster response from a later one (the classic out-of-order race). The palette shows a loading indicator while an async source is in-flight, merges sync + async results through the same pure ranking function, and never shows stale results. Measure: with debounce 200 ms and simulated 300 ms vs 50 ms responses, prove the 300 ms result from query 'a' never overwrites the 50 ms result from query 'ab'.
Definition of done- An async source is debounced (150–250 ms), shows a loading state while in-flight, and is cancelled via AbortController when input changes — a stale slow response never overwrites a newer fast response, proven with out-of-order timing test.
- Sync + async results merge through the same pure ranking function; empty async results don't hide sync commands and loading/error states are explicit.
Feeds fromSelf-review
Show the out-of-order test: query 'a' (slow) → 'ab' (fast), prove the slow result is discarded. A senior reviewer checks debounce delay is justified, AbortController is used, and sync+async merge through the pure scorer.
- 04Nested scoping with breadcrumbs
Add scoping so a command can push a new context with its own filtered list — e.g. entering 'Switch project >' shows only project names, 'Theme >' shows only themes. Each scope is a stack frame `{query, activeIndex, results, breadcrumb}`; Enter on a scoping command pushes, Backspace on empty query (or a dedicated Back) pops, and breadcrumbs show the path (e.g. 'All > Switch project'). The keyboard reducer extends to handle scope push/pop as pure state transitions, and the ranking function re-runs against the scoped item set. Prove it: register a scoping command with 20 scoped items, enter it, filter within the scope, pop with Backspace, and show the breadcrumb updates and focus stays trapped. Document why scoping is a navigation model, not just a filter — it changes the item set, not just the query.
Definition of done- A scoping command pushes a new context with its own filtered list and breadcrumb; Backspace on empty query (or Back) pops the scope and restores the previous query/activeIndex — verified with a nested navigation test.
- Scoping is a pure reducer transition; the breadcrumb trail is visible and focus remains trapped across push/pop.
Feeds fromSelf-review
Show entering a scope, filtering within it, and popping with Backspace including breadcrumb and focus-trap. A senior reviewer checks the reducer handles scope as pure state and that the item set — not just the query — changes on push.
- 05Accessibility + virtualized performance
Make the palette accessible and fast at scale. Accessibility: correct ARIA — the input is `role=combobox` with `aria-expanded`, `aria-controls` pointing to the `role=listbox`, each item is `role=option` with `aria-selected`, and a live region (`aria-live=polite`) announces result count changes ('3 results' / 'No results') so screen readers don't miss updates. The focus trap from milestone 1 stays enforced. Performance: a list of 10,000+ commands must render without jank — virtualize so only the 8–12 visible rows plus a small overscan buffer are in the DOM, keeping the DOM constant-size regardless of list length. The tradeoff is scroll-position bookkeeping complexity; for lists under ~200 items virtualization overhead exceeds benefit, so measure before adding it. Benchmark: render 10k commands and record INP/keystroke latency with and without virtualization, and record memory for pre-indexed (trie/inverted index built once on registration, per-keystroke sub-linear) vs brute-force ranking. Justify the choice with numbers.
Definition of done- ARIA is correct (combobox + listbox + option + aria-selected, live region announces count) — verified with axe or manual screen-reader check; focus trap still holds.
- 10k commands render without jank: virtualized (constant DOM) or pre-indexed (sub-linear per keystroke) with INP/keystroke latency and memory benchmark recorded and the virtualize-vs-brute-force tradeoff justified.
Feeds fromSelf-review
Show axe/manual a11y check (roles + live region) and the 10k-item INP benchmark virtualized vs brute-force. A senior reviewer checks roles are correct, live region announces, and the perf choice is justified with numbers — not 'virtualize always'.
Starter
fallowlone/skein-projects
projects/command-palette
- README.md
- src/palette.ts
- test/palette.test.ts
npx degit fallowlone/skein-projects/projects/command-palette command-palette Implement the stubs, then run the tests until they pass: bun test
Fork the repo and push your work — the grade workflow runs the suite plus static checks on your own runners.
Rubric
| Junior | Mid | Senior | |
|---|---|---|---|
| Match quality | Filters items by substring inclusion; all matching items surface regardless of position in the string. | Implements subsequence matching (not substring) and ranks prefix / contiguous matches above scattered ones so the intended item appears first. | Scoring function is pure and separately testable; ties are broken deterministically; the function handles Unicode boundaries correctly and its time complexity is stated and justified (O(n·m) per item, total O(N·m log N)). |
| Keyboard model correctness | Arrow keys move the selection and Enter triggers the highlighted item; wrapping is absent or breaks at boundaries. | Selection wraps correctly in both directions including with a single-item or empty list; reduce is a pure function with no side effects. | Reducer is the single source of truth for all keyboard state; focus management (trap on open, restore on close) is wired outside the reducer so the pure logic remains testable in Node without a DOM; edge cases (empty list, rapid key presses) are covered by unit tests. |
| Performance under load | Re-ranks the full list on every keypress synchronously; no noticeable issue with short lists (< 50 items). | Async sources are debounced; the ranking function is memoized or skipped when the query is unchanged; stale in-flight requests are cancelled. | A list of 10 000+ commands renders without jank: either virtualized (only visible rows in the DOM) or pre-indexed (trie / inverted index built once on registration so per-keystroke work is sub-linear). The choice is justified with a measured trade-off between memory and CPU. |
Reference walkthrough (spoiler)
Subsequence vs substring: fuzzy matching checks that every character of the query appears in the item in order, but not necessarily adjacently — 'fc' matches 'fuzzyCheck' because 'f' appears before 'c'. Levenshtein edit-distance is a different measure (minimum insertions/deletions/substitutions) that penalises gaps uniformly; subsequence scoring is cheaper (O(n) per candidate) and feels more natural for command search because it rewards matching the initials of camelCase words.
Keeping ranking a pure function: the scoring logic should take (items, query) and return a sorted array with no side effects — no state mutations, no DOM access, no async work. This boundary keeps it unit-testable in Node, makes memoization trivial (same inputs → same output), and allows the rendering layer to stay a thin view that only maps ranked results to DOM nodes.
Debouncing and cancellation for async sources: debounce delays invoking the async source until the user pauses typing (typically 150–250 ms), avoiding a network round trip on every keystroke. AbortController lets you cancel the in-flight fetch when the next keypress fires before the previous result arrives, preventing a slow response from an earlier query overwriting a faster response from a later one.
Virtualizing a large command list: rendering thousands of DOM nodes at once causes layout thrashing. A virtualizer (e.g. only rendering the 8–12 visible rows plus a small overscan buffer) keeps the DOM constant-size regardless of list length. The trade-off is scroll-position bookkeeping complexity; for lists under ~200 items the overhead of virtualization exceeds its benefit, so measure before adding it.
Make it senior
- Add recent/frequent ranking: boost commands the user ran recently and measure how it changes the top-3 hit rate on a simulated usage log.
- Add a command to register async sources lazily (only when the scope is entered) and prove no network work happens until the scope is pushed.
- Run a chaos test that rapidly registers/unregisters commands mid-typing and prove the ranking never shows a stale or removed command.