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.
23 KiB
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:
pwa-baseline.mdis 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.- 80% of the infrastructure already exists.
frontend/src/client/search.tshas 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. - 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.
- 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.
- Template value. Paliad is the first paliad-stack PWA to fully implement
the pwa-baseline
SearchPalettereference. 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.jsonly 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-recentkey 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 dto 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.tsis 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.
Footer
<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: fixedalready 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+Kis 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 buildclean - ✅ Mobile fallback documented (no new BottomNav slot)
Implementation plan (for the coder shift)
- Add
frontend/src/client/palette-actions.tswith the catalog + dispatcher. - 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
flatResultsto beArray<{ kind, … }>so Enter dispatches. - Render footer with kbd hints.
- Add Cmd+K binding (separate from
- Update
frontend/src/components/Sidebar.tsx#global-search-overlaymarkup if needed (probably none — overlay is built dynamically). - Add i18n keys in
frontend/src/client/i18n.ts(DE + EN, ~25 keys). - Add CSS for
.search-group-actions,.palette-footer, action icon colors. Reuse existing.search-result/.search-groupstyles where possible. - Add a small SVG icon for each action
iconKey(reuse sidebar nav icons where they map 1:1 —ICON_GAUGEfor dashboard,ICON_FOLDERfor projects, etc.). - 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
go build ./...,go vet ./...,go test ./...,bun run build.- Commit:
feat(palette): Cmd/Ctrl+K command palette with actions + entities (t-paliad-044). - Push to
mai/cronus/cmd-ctrl-k-command, self-merge into main with--no-ff(regression cleanup is in, no conflicts expected). - 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.