Mosaic one model, many lenses

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 of a.palette-item links, each with an icon, a label, and a kind badge (page / link / command):
    • pages — the same NavEntry inventory the shell nav is built from (built-ins, declared dashboards, scene contributions, role-gated entries, app.ui.nav extras); role-gated items carry the same data-roles attribute, so the advisory role filter in layout.js drops 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".

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-label attribute) 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 (isConnected check), and the kbd hint shows ⌘ on macOS, Ctrl elsewhere.

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-label item; a CLI command surfaces as a run: … item linked to its command page with the command kind badge; app.ui.palette: { enabled: false } emits none of it.
  • Full-stack e2e (full_stack_web block): GET / SSR-renders the palette input and a run: process_order item.
  • 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.