The ticket, as it will be pasted
Compiled from the bones above by a deterministic function. No model in the loop: the same selection produces the same bytes every time.
You are working inside this repository. Before you edit anything, read the files that own navigation, keyboard shortcuts and the design tokens, and say which ones you read.
Work test-first: write the tests named below, watch them fail, then implement until they pass.
# Build: Command bar
Pattern: the Linear command bar (Cmd+K)
Source: https://www.vibebrator.com/cuts/command-bar
## What it is
A keyboard-first command bar that opens with Cmd/Ctrl+K, searches pages, terms and actions as you type, groups the results, and puts focus back exactly where it found it when it closes.
## Where it goes
Placement: a global overlay, opened from a button in the header and from the keyboard shortcut anywhere on the page
Stack: the stack already in this codebase; do not add a framework or a UI library for this
Design tokens: the existing tokens; do not introduce new colours, radii or fonts
## Bones (17 of 19)
### State
- STATE-01 Open state lives in one place
Spec: The bar has exactly one open/closed state. Every affordance that opens or closes it (shortcut, trigger button, Escape, backdrop) sets that state and nothing edits the DOM directly. The trigger button's aria-expanded mirrors it.
Test: Open with the shortcut, close with a backdrop click, open with the button: the bar is never half-open and aria-expanded on the trigger matches at each step.
- STATE-02 Results are a pure function of the query
Spec: The results list is derived from (query, index, highlighted index) on every change. Nothing appends to or removes from the rendered list; it is rebuilt from the query.
Test: Type 'lex', delete to 'le', type 'x' again: the list is identical both times, including order.
- STATE-03 Highlight clamps to real rows
Spec: The highlighted row index resets to 0 whenever the result set changes and never exceeds the last row.
Test: Highlight row 4, then narrow the query to 2 results: the highlight is on row 1, not off the end.
### Edge cases
- EDGE-01 Empty query shows a default set
Spec: With an empty query the bar shows the Actions and Pages groups in a fixed order. It is never an empty list.
Test: Open the bar without typing: at least 5 rows are visible under labelled group headings.
- EDGE-02 Case and whitespace are normalised
Spec: Matching lowercases both sides and trims and collapses whitespace, so ' Vibe CHECK ' matches the same rows as 'vibe check'.
Test: Type ' LEXicon ' and then 'lexicon': same rows in the same order.
- EDGE-03 No results echoes the query
Spec: When nothing matches, the list is replaced by one line that repeats the query verbatim: No results for “<query>”. The query is inserted as text, never as markup.
Test: Type zzzz<b>: the empty state shows those literal characters and no bold element exists in the DOM.
### Motion
- MOTION-01 Open fades and rises in 120ms, close is instant
Spec: Opening animates opacity 0 to 1 and translateY 8px to 0 over 120ms ease-out. Closing does not animate. When prefers-reduced-motion is set, opening is instant too.
Test: Emulate prefers-reduced-motion: reduce and open the bar: no transform and no opacity transition runs.
### Keyboard
- KEYS-01 Cmd/Ctrl+K opens from anywhere and toggles
Spec: Cmd+K on macOS and Ctrl+K elsewhere open the bar from any focus position except inside another text field, where K must type. Pressing the shortcut while open closes the bar.
Test: Focus the page body and press the shortcut: the bar opens with its input focused. Press it again: the bar closes.
- KEYS-02 Arrows move, Enter chooses, ends wrap
Spec: ArrowDown and ArrowUp move the highlight and wrap around at either end. Enter activates the highlighted row. Home and End jump to the first and last row.
Test: On the last row press ArrowDown: the first row is highlighted. Press Enter: its action runs.
- KEYS-03 Escape clears, then closes, then returns focus
Spec: Escape with a non-empty query clears the query. Escape with an empty query closes the bar and returns focus to the element that opened it (the trigger button or the previously focused element).
Test: Open from the button, type 'a', press Escape twice: the bar is closed and focus is on the button.
- KEYS-04 Tab stays inside the bar
Spec: While open, Tab and Shift+Tab cycle within the bar (input and rows). Focus never lands on the page behind it.
Test: Open the bar and press Tab 20 times: document.activeElement is inside the bar every time.
### Accessibility
- A11Y-01 Dialog and combobox semantics
Spec: The bar is role=dialog with aria-modal=true and an aria-label. The input is role=combobox with aria-expanded, aria-controls pointing at the list, and aria-activedescendant tracking the highlighted row. Rows are role=option inside role=listbox.
Test: With the bar open, axe-core reports no violations and aria-activedescendant equals the highlighted row's id.
- A11Y-02 Result count is announced
Spec: A visually hidden aria-live=polite region announces '<n> results' after each query change, debounced so typing does not spam the reader.
Test: Type 'le': the live region text carries the same number as the visible rows.
### Copy
- COPY-01 Placeholder names the scope
Spec: The input placeholder names what is searchable: 'Search pages, terms and actions…'. Group headings are plain nouns: Actions, Pages, Recent.
Test: Read the placeholder: it names at least two searchable things.
- COPY-02 Shortcut hint matches the platform
Spec: The trigger button shows ⌘K on macOS and Ctrl K elsewhere, detected from the platform once at load and rendered inside a kbd element.
Test: With a Mac user agent the hint reads ⌘K; on Windows it reads Ctrl K.
### Performance
- PERF-01 The index is built once
Spec: The search index (pages, terms, actions) is built once at load into a flat array of {label, hay, group, action}. Querying reads that array and never rebuilds it or reads the DOM.
Test: Run 100 queries: the index array is the same object before and after.
- PERF-02 Input is debounced 60ms, last keystroke wins
Spec: Keystrokes update the query immediately but the list re-renders at most once per 60ms, inside a requestAnimationFrame. The final render always reflects the last keystroke.
Test: Type 8 characters within 40ms: the list renders once, with the full query, never with a stale prefix.
## Rules
- Implement every bone listed above and nothing that is not listed.
- Each Test line is an acceptance test. Write it as a real, runnable test before the code it proves.
- Put the bone ID in a comment next to the code that implements it.
- If a bone conflicts with something that already exists, stop and ask. Do not pick silently.
When you finish, reply with a table: bone ID, the file:line that implements it, the test that proves it. Then run the full test suite and paste its summary line.