Files
paliad/docs/design-command-palette.md
m c226a8b14d docs(palette): design Cmd/Ctrl+K command palette (t-paliad-044)
Choose Option B (full palette: actions + entities) over Option A
(keybind-only) because:

- pwa-baseline.md canon for multi-entity sites (paliad has 8 entity types).
- 80% of infrastructure already exists in search.ts (sectioned results,
  keyboard nav, i18n, debounce, abortable fetch).
- Patent-lawyer audience benefits from keyboard-first creation flows.
- A-then-B would mean revisiting in 2 weeks anyway.

Scope guardrails: no fuzzy lib, no MRU persistence, no extension API,
no per-action shortcut keys (Cmd+K only). 20-action catalog (12 navigate,
3 create, 4 toggle/action). Mobile gets palette via drawer (no new
BottomNav slot). Desktop preventDefault on Ctrl+K to suppress URL-bar.

Doc covers: trigger surface, UX shape (empty + filtered), action catalog,
component architecture (extend search.ts, new palette-actions.ts data
file), render flow, i18n keys, mobile considerations, acceptance,
implementation plan, risks. Implementer choice deferred to m.

Awaiting m's go/no-go before coder shift.
2026-04-26 15:02:31 +02:00

23 KiB
Raw Blame History

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)             │
└──────────────────────────────────────────────────┘

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

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:

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

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 })

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.

<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

// 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.