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.
This commit is contained in:
m
2026-04-26 15:02:31 +02:00
parent 79d332d5b2
commit c226a8b14d

View File

@@ -0,0 +1,504 @@
# 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.**