# Cmd/Ctrl+K Command Palette — Design

**Task:** t-paliad-044
**Author:** cronus (inventor)
**Date:** 2026-04-26
**Status:** Design — awaiting m's go/no-go before coder shift

---

## Decision: Option B — full command palette (with strict scope guardrails)

The brief offered two scopes. Rationale for picking B:

1. **`pwa-baseline.md` is explicit** — multi-entity sites ship a command palette;
   single-entity sites can skip it. Paliad has 8 entity types (projects, deadlines,
   appointments, glossary, courts, checklists, links, users), so it is squarely in
   the "ship a palette" bucket.
2. **80% of the infrastructure already exists.** `frontend/src/client/search.ts`
   has sectioned grouped results, keyboard navigation (↑↓ / ↵ / Esc), i18n
   group headers, debounce + AbortController, an in-flight cancellation pattern,
   and language-switch re-render. Adding an *Actions* section on top is
   incremental, not a rewrite.
3. **Patent lawyers are heavy keyboard users on desktop.** The HLC / HLC-Munich
   audience drafts long documents; Cmd+K → "Neue Frist" without leaving the
   keyboard is genuinely valuable. Sidebar nav is already always-visible on
   desktop, so the *navigate-to* actions are quality-of-life — but the *create*
   actions ("Neue Frist", "Neuer Termin", "Neues Projekt") are real time saves.
4. **Going A first feels like a half-step.** A "/" key alias for Cmd+K is five
   lines of code, but everyone who hits Cmd+K and sees only entity search will
   wonder where "Gehe zu Dashboard" / "Neue Frist anlegen" are. We'd be back
   here in two weeks anyway.
5. **Template value.** Paliad is the first paliad-stack PWA to fully implement
   the pwa-baseline `SearchPalette` reference. Doing it right here makes the
   pattern reusable for the next mAi PWA project.

---

## Scope guardrails (what is NOT in this design)

- ❌ Fuzzy matching library — substring match on DE+EN labels is sufficient
  for ~20 actions and small entity result sets. Add `fuse.js` only if the catalog
  passes ~50 entries, which is unlikely to happen in 2026.
- ❌ Recently-used persistence / localStorage MRU — defer. We can add a
  `paliad-palette-recent` key later if telemetry shows users repeating the same
  3-4 actions.
- ❌ Action groups beyond Aktionen / Projekte / Fristen / … — no
  meta-categories like "Werkzeuge", "Wissen". Keep flat.
- ❌ Extension API or plugin registry — the action catalog is a single static
  array in `palette-actions.ts`. Future sections can be added by editing that
  file; no need for a registration callback.
- ❌ Cross-project search — out of scope per task brief.
- ❌ AI-powered ranking — out of scope per task brief.
- ❌ Action shortcut keys beyond Cmd+K itself (e.g. `g d` to go to Dashboard).
  Maybe later; not now.
- ❌ Recent entities — show entity results only when the user types.

---

## Trigger surface

| Trigger             | Behavior                                     | Platform |
|---------------------|----------------------------------------------|----------|
| `Cmd+K` (Mac)       | Open palette + focus input. `preventDefault`. | desktop  |
| `Ctrl+K` (Win/Lin)  | Same.                                         | desktop  |
| `/` (slash)         | Same — kept for muscle memory (shipped t-paliad-026). | desktop  |
| Click sidebar input | Same — focuses the input directly.            | desktop  |
| `Esc`               | Close + clear input.                          | all      |
| BottomNav menu → drawer → search input | Existing path on mobile.            | mobile   |

### Why not a dedicated mobile slot

The BottomNav (5 slots: Start / Projekte / Anlegen / Agenda / Menü) is full.
Replacing one would degrade an established pattern. The mobile sidebar drawer
(opened via Menü or hamburger) already contains the same `#global-search-input`,
so a tap-search path exists. **Mobile users get the palette via the drawer, not
a dedicated button.** Revisit if telemetry shows mobile users searching often
enough to justify a topbar search icon.

### Browser-native Ctrl+K suppression

`Ctrl+K` in Firefox/Chrome focuses the URL bar's "search engine" submenu (rare
but exists). In Safari, `Cmd+L` focuses the URL bar but `Cmd+K` is unbound.
We `preventDefault()` on the document-level keydown handler whenever the key
combo matches and **only** when a textarea / input is not already focused with
a non-`#global-search-input` element — same skip-rule as the existing `/`
shortcut.

---

## UX shape

### Empty state (Cmd+K just pressed, input empty)

Show all actions, sectioned under "Aktionen". Don't fetch entity search.
The user can see the catalog at a glance — this is the "discoverability mode"
of the palette.

```
┌──────────────────────────────────────────────────┐
│ 🔍  ____________________________________________ │  ← #global-search-input
├──────────────────────────────────────────────────┤
│ AKTIONEN                                          │
│   📊  Gehe zu Dashboard                           │
│   📁  Gehe zu Projekte                            │
│   ⏰  Gehe zu Fristen                             │
│   📅  Gehe zu Termine                             │
│   🗓   Gehe zu Agenda                             │
│   📖  Gehe zu Glossar                             │
│   🏛  Gehe zu Gerichte                            │
│   🔗  Gehe zu Links                               │
│   ✓   Gehe zu Checklisten                         │
│   ⬇   Gehe zu Downloads                           │
│   ⚙   Gehe zu Einstellungen                       │
│   ➕  Neue Frist anlegen                          │
│   ➕  Neuer Termin anlegen                        │
│   ➕  Neues Projekt anlegen                       │
│   🌐  Sprache umschalten (DE → EN)                │
│   📌  Sidebar anheften / lösen                    │
│   ✉   Kolleg:in einladen                          │
│   ↪   Abmelden                                    │
├──────────────────────────────────────────────────┤
│ ↑↓ Navigieren · ↵ Öffnen · Esc Schließen          │  ← footer hint
└──────────────────────────────────────────────────┘
```

### Filtered state (user typed at least 1 char)

Both Actions (filtered by substring on DE+EN labels) AND entity search results
(via existing `/api/search?q=...`) render together, Actions on top.

```
Query: "frist"
┌──────────────────────────────────────────────────┐
│ 🔍  frist                                         │
├──────────────────────────────────────────────────┤
│ AKTIONEN                                          │
│   ⏰  Gehe zu Fristen                             │
│   ➕  Neue Frist anlegen                          │
│ FRISTEN                                           │
│   ⏰  Klagebeantwortung — UPC-2024-0042           │
│   ⏰  Replik einreichen — Patent EP1234567        │
│ GLOSSAR                                           │
│   📖  Frist (Definition + Berechnung)             │
└──────────────────────────────────────────────────┘
```

### Footer keyboard hints

A small `<div class="palette-footer">` below the overlay results, visible
whenever the palette is open. Key hints in DE/EN:

- DE: `↑↓ Navigieren · ↵ Öffnen · Esc Schließen`
- EN: `↑↓ Navigate · ↵ Open · Esc Close`

---

## Action catalog (initial set)

20 actions, divided across two sub-types under "Aktionen" but rendered as one
flat list (no sub-headers):

### Navigate

| ID                    | DE                            | EN                          | URL                    |
|-----------------------|-------------------------------|-----------------------------|------------------------|
| `nav.dashboard`       | Gehe zu Dashboard             | Go to Dashboard             | `/dashboard`           |
| `nav.projects`        | Gehe zu Projekte              | Go to Projects              | `/projects`            |
| `nav.deadlines`       | Gehe zu Fristen               | Go to Deadlines             | `/deadlines`           |
| `nav.appointments`    | Gehe zu Termine               | Go to Appointments          | `/appointments`        |
| `nav.agenda`          | Gehe zu Agenda                | Go to Agenda                | `/agenda`              |
| `nav.team`            | Gehe zu Team                  | Go to Team                  | `/team`                |
| `nav.glossary`        | Gehe zu Glossar               | Go to Glossary              | `/glossary`            |
| `nav.courts`          | Gehe zu Gerichte              | Go to Courts                | `/courts`              |
| `nav.links`           | Gehe zu Links                 | Go to Links                 | `/links`               |
| `nav.checklists`      | Gehe zu Checklisten           | Go to Checklists            | `/checklists`          |
| `nav.downloads`       | Gehe zu Downloads             | Go to Downloads             | `/downloads`           |
| `nav.settings`        | Gehe zu Einstellungen         | Go to Settings              | `/settings`            |

### Create

| ID                    | DE                            | EN                          | URL                    |
|-----------------------|-------------------------------|-----------------------------|------------------------|
| `create.deadline`     | Neue Frist anlegen            | New deadline                | `/deadlines/new`       |
| `create.appointment`  | Neuer Termin anlegen          | New appointment             | `/appointments/new`    |
| `create.project`      | Neues Projekt anlegen         | New project                 | `/projects/new`        |

### Toggle / action

| ID                    | DE                            | EN                          | Behavior                      |
|-----------------------|-------------------------------|-----------------------------|-------------------------------|
| `toggle.lang`         | Sprache umschalten            | Toggle language             | Click `data-lang-toggle` for the OTHER language. |
| `toggle.pin`          | Sidebar anheften / lösen      | Pin / unpin sidebar         | Click `.sidebar-pin`.         |
| `app.invite`          | Kolleg:in einladen            | Invite a colleague          | Click `#sidebar-invite-btn`.  |
| `app.logout`          | Abmelden                      | Logout                      | Navigate to `/logout`.        |

The catalog lives as a single `const ACTIONS: PaletteAction[]` array in a new
file `frontend/src/client/palette-actions.ts`. Adding/removing actions = editing
the array. Keep the file small and obvious.

### Filtering rule

Substring match (case-insensitive, language-agnostic — match against BOTH the
current-language label AND the other-language label, so an English-speaker who
types "Frist" still finds the deadline action). No fuzzy distance, no token
permutations. Sort filtered results by `prefix-match > substring-match`, ties
broken by catalog order.

---

## Component architecture

### Files touched / created

| File                                            | Change                              |
|-------------------------------------------------|-------------------------------------|
| `frontend/src/client/search.ts`                 | Extended: Cmd+K binding, empty-state action render, footer hint, action filtering. |
| `frontend/src/client/palette-actions.ts`        | **NEW.** Static action catalog + `runAction(id)` dispatcher. |
| `frontend/src/components/Sidebar.tsx`           | Add palette footer markup inside `#global-search-overlay`. Update `<kbd>` shortcut hint to show both `/` and `⌘K` (or just keep `/` — minor UX choice). |
| `frontend/src/client/i18n.ts`                   | Add ~20 i18n keys: `palette.action.*`, `palette.section.actions`, `palette.footer.*`. |
| `frontend/src/styles/sidebar.css` (or wherever overlay styles live) | Add `.search-group-actions` (subtle accent), `.palette-footer` styles, action-icon styles. |

### Decision: extend `search.ts`, don't create a new `palette.ts`

- Single source of truth for the overlay's keyboard / focus / debounce logic.
- Avoids two files racing to mutate `#global-search-overlay`.
- The mental model is "search.ts owns the palette" — palette is just search
  with actions on top.
- `palette-actions.ts` is a **data file** (catalog + dispatcher), not a
  controller. Keeps the action catalog easy to browse and edit.

### High-level flow

```
[Cmd+K pressed]
   ↓
search.ts: focus input, openPalette(empty=true)
   ↓
render(): show all actions, no entity fetch, footer hint visible
   ↓
[user types "f"]
   ↓
input handler debounces 200ms → runSearch("f")
   ↓
runSearch():
   1. filteredActions = filterActions(query)        ← synchronous, instant
   2. entityResults   = fetch /api/search?q=f       ← async
   3. render({ actions: filteredActions, entities: entityResults })
   ↓
[user presses ↵]
   ↓
openActive():
   if active.kind === "action": runAction(active.id)
   else                       : window.location.href = active.url
   ↓
[user presses Esc] → closeOverlay()
```

### `PaletteAction` type

```ts
type PaletteAction = {
  id: string;                          // stable id, used for icon lookup
  i18nKey: string;                     // e.g. "palette.action.nav.dashboard"
  fallbackLabel: { de: string; en: string };
  iconKey: ActionIconKey;              // small svg, see below
  group: "navigate" | "create" | "toggle";  // for sort priority only
  run: () => void;                     // dispatcher closure
};
```

### `runAction(id)` dispatcher

A switch statement that maps action id → DOM action. Examples:

```ts
function runAction(id: string): void {
  switch (id) {
    case "nav.dashboard":
      window.location.href = "/dashboard";
      break;
    case "create.deadline":
      window.location.href = "/deadlines/new";
      break;
    case "toggle.lang": {
      const cur = document.documentElement.getAttribute("lang") || "de";
      const next = cur === "de" ? "en" : "de";
      document.querySelector<HTMLButtonElement>(`[data-lang-toggle="${next}"]`)?.click();
      break;
    }
    case "toggle.pin":
      document.querySelector<HTMLButtonElement>(".sidebar-pin")?.click();
      break;
    case "app.invite":
      document.getElementById("sidebar-invite-btn")?.click();
      break;
    case "app.logout":
      window.location.href = "/logout";
      break;
    // …
  }
}
```

The dispatcher reuses existing DOM handlers (lang toggle, pin, invite modal)
so we don't duplicate state. If the underlying button moves, the dispatcher
breaks — that's acceptable: each action is one line of indirection, and the
file is small enough to grep.

### Keyboard binding for Cmd+K

```ts
document.addEventListener("keydown", (e) => {
  const isCmdK = (e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k";
  if (!isCmdK) return;
  // Allow inputs to consume Cmd+K when they want to (e.g. a future rich-text
  // editor's link insert) ONLY if they explicitly handle it. By default, we
  // intercept globally — paliad has no Cmd+K conflict elsewhere today.
  e.preventDefault();
  e.stopPropagation();
  input.focus();
  input.select();
  openPaletteEmpty();
});
```

`/` keeps its existing skip-when-typing rule (don't fire if user is mid-edit).
`Cmd+K` does NOT skip — power users explicitly intend to open the palette
even from inside a text input.

---

## Render changes inside `search.ts`

### `render()` becomes `renderResults({ actions, entities, query })`

```ts
function renderResults(opts: {
  actions: PaletteAction[],
  entities: SearchResponse | null,
  query: string,
}, overlay: HTMLElement): void {
  flatResults = [];
  activeIndex = -1;

  const sections: string[] = [];

  if (opts.actions.length > 0) {
    sections.push(renderActionsSection(opts.actions, opts.query));
  }
  if (opts.entities) {
    for (const group of GROUP_ORDER) {
      const items = opts.entities[group.key] as SearchResult[];
      if (!items || items.length === 0) continue;
      sections.push(renderEntityGroup(group, items, opts.query));
    }
  }

  if (sections.length === 0) {
    overlay.innerHTML = `<div class="search-empty">…</div>`;
  } else {
    overlay.innerHTML = sections.join("") + renderFooter();
    bindResultClicks(overlay);
  }
  overlay.style.display = "block";
}
```

The `flatResults` array becomes `Array<{ kind: "action" | "entity", … }>` so
`openActive()` can dispatch correctly.

### Footer

```html
<div class="palette-footer">
  <span><kbd>↑↓</kbd> <span data-i18n="palette.footer.navigate">Navigieren</span></span>
  <span><kbd>↵</kbd> <span data-i18n="palette.footer.open">Öffnen</span></span>
  <span><kbd>Esc</kbd> <span data-i18n="palette.footer.close">Schließen</span></span>
</div>
```

---

## Mobile considerations

- BottomNav stays as-is (5 slots, full).
- Hamburger / drawer path to search remains the existing pattern — no change
  required.
- The palette overlay's `position: fixed` already handles small viewports.
  Verify the Actions section + footer fit without scrolling on a 360×640
  test viewport during implementation; if not, drop the footer on mobile via
  `@media (max-width: 480px) { .palette-footer { display: none; } }`.
- `Cmd+K` is desktop-only; mobile users never see the keybind.

---

## i18n additions

```ts
// de
"palette.section.actions": "Aktionen",
"palette.action.nav.dashboard": "Gehe zu Dashboard",
"palette.action.nav.projects": "Gehe zu Projekte",
"palette.action.nav.deadlines": "Gehe zu Fristen",
// … (all 20 actions)
"palette.footer.navigate": "Navigieren",
"palette.footer.open": "Öffnen",
"palette.footer.close": "Schließen",

// en
"palette.section.actions": "Actions",
"palette.action.nav.dashboard": "Go to Dashboard",
// …
"palette.footer.navigate": "Navigate",
"palette.footer.open": "Open",
"palette.footer.close": "Close",
```

Use the existing `t(key)` helper from `i18n.ts`. Match the pattern from
`GROUP_ORDER` in search.ts.

---

## Acceptance criteria (re-stated from task brief)

- ✅ `Cmd+K` (Mac) opens palette
- ✅ `Ctrl+K` (Win/Lin) opens palette
- ✅ `/` shortcut still works (existing behavior preserved)
- ✅ `preventDefault()` suppresses browser-native Ctrl+K
- ✅ Actions filter live as user types
- ✅ Entity results render alongside actions when query non-empty
- ✅ ↑↓ navigates the merged result list (actions + entities)
- ✅ ↵ opens / runs active item
- ✅ Esc closes
- ✅ Footer shows kbd hints, switches language with global toggle
- ✅ Console clean (no errors, no warnings beyond what's already there)
- ✅ `go build/vet/test ./...` clean
- ✅ `bun run build` clean
- ✅ Mobile fallback documented (no new BottomNav slot)

---

## Implementation plan (for the coder shift)

1. Add `frontend/src/client/palette-actions.ts` with the catalog + dispatcher.
2. Extend `frontend/src/client/search.ts`:
   - Add Cmd+K binding (separate from `/` binding).
   - Change `runSearch()` to also produce filtered actions; render actions
     section first.
   - Add empty-state branch (open palette → show all actions, no fetch).
   - Update `flatResults` to be `Array<{ kind, … }>` so Enter dispatches.
   - Render footer with kbd hints.
3. Update `frontend/src/components/Sidebar.tsx` `#global-search-overlay`
   markup if needed (probably none — overlay is built dynamically).
4. Add i18n keys in `frontend/src/client/i18n.ts` (DE + EN, ~25 keys).
5. Add CSS for `.search-group-actions`, `.palette-footer`, action icon
   colors. Reuse existing `.search-result` / `.search-group` styles where
   possible.
6. Add a small SVG icon for each action `iconKey` (reuse sidebar nav icons
   where they map 1:1 — `ICON_GAUGE` for dashboard, `ICON_FOLDER` for
   projects, etc.).
7. Manual smoke (local `bun run build` + `go run ./cmd/server`):
   - Cmd+K with empty input → all actions visible
   - Type "frist" → action filter + entity search both render
   - ↑↓ wraps; ↵ on action runs; ↵ on entity navigates
   - Esc clears; clicking outside clears
   - DE/EN toggle re-renders labels
   - `/` still works
   - Browser URL bar does NOT focus on Cmd+K
8. `go build ./...`, `go vet ./...`, `go test ./...`, `bun run build`.
9. Commit: `feat(palette): Cmd/Ctrl+K command palette with actions + entities (t-paliad-044)`.
10. Push to `mai/cronus/cmd-ctrl-k-command`, self-merge into main with
    `--no-ff` (regression cleanup is in, no conflicts expected).
11. Verify on prod paliad.de via Playwright after Dokploy auto-deploy
    (test creds: `tester@hlc.de` / `xdMmC7iCeDSTFmPXAlAyY0`).

---

## Risks + mitigations

| Risk                                                  | Mitigation                                                           |
|-------------------------------------------------------|----------------------------------------------------------------------|
| Cmd+K conflicts with future rich-text editor         | Single global handler; if a real conflict appears, gate by `closest('.no-cmdk')` opt-out. |
| Action labels wrong language on first render         | Reuse `t()` + `onLangChange()` handler that already exists in search.ts. |
| Browser URL-bar focus on Ctrl+K (Firefox)            | `preventDefault()` + `stopPropagation()` in document keydown handler. |
| Action dispatcher breaks if sidebar button moves     | One-line indirection per action; trivial to fix; covered by manual smoke. |
| Mobile BottomNav doesn't expose palette              | Documented decision — drawer path exists; revisit if usage shows need. |
| Existing `flatResults` consumers (Enter handler)     | Updated in same change — single file controls navigation. |
| Action catalog grows beyond ~50                      | Add fuzzy match later; not now. |

---

## Implementer recommendation

cronus (myself) is a good fit to implement this:
- Designed it; minimum context-loading cost.
- Single-file-cluster change (search.ts + 1 new + 1 i18n + a few markup tweaks).
- No DB / backend touch; pure frontend client-side change.
- Can self-merge once m approves and t-paliad-043 stays green.

Alternative: knuth (frontend-strong, shipped t-paliad-026 global search).
Either works; this design doc carries enough detail that the implementer choice
is fungible.

**Decision is m's. I will not start coding until the head signals approval.**
