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

505 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.**