ADR 0040 — Command palette (Ctrl/Cmd+K)
- Status: accepted
- Date: 2026-10-05
- Slice: P40 (builder-roadmap: DSL-first, codegen-first, UI-first)
Context
Every generated web app grows more surfaces with each slice: projections,
aggregates, workflows, commands, schedules, flags, events, CQRS explorer,
task center, declared dashboards, admin pages, knowledge/search. The shell
nav lists them, but (a) in the collapsible layouts most sit behind
collapsed groups, (b) the header-center filter only narrows what is
already visible, and (c) command pages — the app's real "do something"
surface — have no shortcut path at all: an operator has to browse
/commands and click through. A command palette (the Ctrl/Cmd+K jump
overlay, as in the major editors and app shells) collapses the whole
navigation problem to "type what you want, press Enter", and it needs no
new server surface: every item is just a link to a page that already
exists.
Decision
1. app.ui.palette (DSL)
app.ui gains an optional palette block with a single enabled toggle
(default on):
app:
ui:
palette:
enabled: false # opts out of the overlay + the header toggle
Absent → on. There is deliberately no per-item DSL surface: the palette is a projection of the app's existing declared surface (nav entries + CLI commands), not a new list to maintain.
2. The overlay (web codegen)
The shell (Layout) emits, right after the nav:
- a header-right toggle (search icon + a
Ctrl/⌘kbd hint — the modifier label is corrected client-side) that opens the palette; - a fixed overlay (
.palette, hidden by default): a filter input plus a list ofa.palette-itemlinks, each with an icon, a label, and a kind badge (page/link/command):- pages — the same
NavEntryinventory the shell nav is built from (built-ins, declared dashboards, scene contributions, role-gated entries,app.ui.navextras); role-gated items carry the samedata-rolesattribute, so the advisory role filter inlayout.jsdrops them exactly like the nav links; - commands — one
run: {name}item per CLI command, linking to its command page (the P37 typed form); - external entries keep
target="_blank" rel="noopener".
- pages — the same
3. The behavior (layout.js)
The palette wiring is static JS (the same vehicle as the nav-search filter and the layout toggles), so it works in every hydration mode with no Leptos reactivity:
- Ctrl/Cmd+K toggles the palette from anywhere (the header toggle button does the same); opening focuses the input and resets the filter;
- input filters items by label (case-insensitive substring, the
data-labelattribute) and re-highlights the first visible item; - ↑/↓ move the active item through the visible items (wraparound); Enter navigates to the active item's href; Esc (or a backdrop click) closes and returns focus to the toggle;
- items removed by the role filter are excluded from navigation
(
isConnectedcheck), and the kbd hint shows⌘on macOS,Ctrlelsewhere.
Consequences
- Jumping to any page or any command form is ≤ keystrokes: no nav hunting, no scrolling, no collapsed groups — the palette is the flat index over the whole declared surface.
- Zero new server endpoints and zero new client state: the overlay is a static link list in the SSR HTML (e2e-assertable), and the behavior is a few dozen lines of shell JS — the pattern P32/P38 established for shell features.
- The palette's item list is derived, not authored: adding a projection page, a dashboard, or a CLI command automatically adds its palette entry; there is nothing to keep in sync (and nothing to typo).
- Apps with a deliberately minimal header can opt out with one boolean; the CSS stays global (hidden overlay costs nothing).
Verification
- Render tests: the overlay, filter input, header toggle, and the
layout.js wiring (shortcut, arrows, label filter) are emitted by
default; a nav entry surfaces as a
data-labelitem; a CLI command surfaces as arun: …item linked to its command page with thecommandkind badge;app.ui.palette: { enabled: false }emits none of it. - Full-stack e2e (
full_stack_webblock):GET /SSR-renders the palette input and arun: process_orderitem. - Conformance re-pinned: the shell change touches every example (pages overlay + toggle, layout.js wiring, palette CSS, MANIFEST) — the palette is a shell feature like the nav-search filter, not an app-specific surface.