Hermes 284313f908
Some checks are pending
Bidi Control Character Guard / bidi-control-guard (push) Waiting to run
Circular Dependency Check / Check for new circular dependencies (push) Waiting to run
Citus Migration Smoke / Combined migrations on single-node Citus (push) Waiting to run
E2E Fresh Install Tests / fresh-install-e2e (push) Waiting to run
ext-v2 guardrails / Run ext-v2 guard and ESLint (push) Waiting to run
Integration Tests / Check for relevant changes (push) Waiting to run
Integration Tests / ${{ (github.event_name == 'schedule' || github.event.inputs.suite == 'full') && 'Full integration suite' || 'Tier-1 integration subset' }} (push) Blocked by required conditions
Mobile checks / Mobile lint + typecheck (push) Waiting to run
Mobile checks / Mobile unit tests (push) Waiting to run
Mobile checks / Mobile dependency audit (report) (push) Waiting to run
Mobile checks / Mobile reproducibility checks (push) Waiting to run
Secrets guard (env backups) / Ensure no tracked env backup files (push) Waiting to run
Temporal Readiness / fast-readiness (push) Waiting to run
Temporal Readiness / docker-parity (push) Waiting to run
TypeScript Type Check / Nx affected typecheck (push) Waiting to run
Unit Tests / Skipped-test budget (push) Waiting to run
Unit Tests / Nx affected unit tests (push) Waiting to run
Unit Tests / Server unit coverage (informational) (push) Waiting to run
Validate Tenant Management Schema / Check for relevant changes (push) Waiting to run
Validate Tenant Management Schema / Validate Tenant Management Schema (push) Blocked by required conditions
EE Workflows Build Guard / ee-workflows-build-guard (push) Waiting to run
Initial import of AlgaPSA codebase from PSA server
Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz

Source: /opt/alga-psa on psa.joliet.tech
2026-06-22 16:12:17 -05:00

528 lines
30 KiB
Markdown
Raw Permalink 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.

# PRD — Shared Keyboard Shortcuts System
- Slug: `2026-05-18-keyboard-shortcuts-shared-system`
- Date: `2026-05-18`
- Status: Draft
- Source: `.ai/keyboard-shortcuts-shared-system-plan.md`
## Summary
Introduce a shared, client-side keyboard shortcut system for the MSP app. Features
register **stable semantic actions** (e.g. `page.save`, `global.search`) rather than
component-specific key handlers; the system resolves which key combination invokes
which action, with scope/priority arbitration, user customization, a settings UI,
a discoverable help dialog, and visible `kbd` hints. The system is correct and
identical on macOS and Windows/Linux, including for a user who configures shortcuts
on one OS and signs in from another. All user-facing text is internationalized in
the app's eight production locales plus pseudo-locales.
Delivery covers all six phases of the source plan: engine, global migration,
panels/drawers/record nav, editors/designers, user customization, and
discoverability/accessibility — including the `g`-then-key sequence engine and
visible hints.
## Problem
Keyboard handling is scattered across ad hoc `window`/`document` listeners:
- `DefaultLayout.tsx` (`mod+l`, `mod+ArrowUp`), `SearchPalette.tsx` (`mod+k`),
`AssetDashboardClient.tsx` (`mod+k` — collides with global search), two
`DrawerContext` files (`Escape`, `Alt+Arrow`), `TicketNavigation.tsx`
(`Alt+Arrow`), `useDesignerShortcuts.ts`.
- Conflicts are accidental (`mod+k` today). Editable-target filtering is
inconsistent. There is no scope/priority model, no user customization, no
central list of shortcuts. `Alt+Arrow` collides with browser Back/Forward on
Windows/Linux. No matcher correctness across OS/keyboard layouts.
## Goals
- One shared registry + single delegated dispatcher under `packages/ui`.
- Stable action IDs; bindings configurable and decoupled from handlers.
- Scope (`global`/`shell`/`page`/`panel`/`dialog`/`editor`) + priority + active
region arbitration; drawer/dialog/editor win over page over global.
- Cross-platform correctness: dual matching (`event.code` for physical keys,
`event.key` for character keys), `mod` resolved per device, per-platform
defaults, international-layout safety.
- Single-chord and **multi-key sequence** bindings (`g` then `t`).
- User customization persisted as platform-neutral deltas via
`useUserPreference`; localStorage + debounced server sync; validated per
target platform on load.
- Settings UI consistent with the rest of the app (settings tab shell, shared
`Card`/`Table`/`Switch`/`Button`/`ConfirmationDialog`, `react-hot-toast`,
`handleError`, `LoadingIndicator`).
- Discoverable help dialog + `aria-keyshortcuts` + visible `kbd` hints.
- Fully internationalized in en, fr, es, de, nl, it, pl, pt (+ xx/yy pseudo);
passes `validate-translations.cjs`.
- All existing AI/search/drawer/ticket/designer shortcuts keep working after
migration.
## Non-goals
- Auth and client-portal screens (MSP `/msp` only for v1).
- Tenant-level shortcut defaults / admin lock (kept as future open question).
- Export/import of user override sets.
- Replacing browser-owned combos we explicitly choose not to own
(`mod+r`/reload, `mod+f`/find, `mod+p`/print, `mod+w`/`mod+t`/`mod+n`).
- Migrating component-local widget key handling (DatePicker, SearchableSelect,
TagInput, comboboxes, Radix internals) — these keep their local handlers; the
system must not steal their keys.
- Server-side keyboard logic (system is client-only).
## Users and Primary Flows
**Personas:** Internal MSP technician (power user, keyboard-driven); occasional
MSP user (discovers shortcuts via help/hints); accessibility user (screen reader
+ `aria-keyshortcuts`); cross-device user (configures on Mac laptop, uses Windows
desktop).
**Primary flows:**
1. **Use a default shortcut.** User presses `mod+k`; global search focuses. The
resolved combo shows in the help dialog and as a `kbd` hint on the control.
2. **Discover shortcuts.** User presses `?` (or opens it from a menu) → read-only
help dialog lists active shortcuts grouped by group/scope, resolved for the
current OS, custom bindings flagged.
3. **Sequence navigation.** User presses `g` then `t` in a non-editable page
region → routes to Tickets; the chord buffer times out if the second key is
late.
4. **Customize.** Settings → Keyboard Shortcuts tab: list of actions with action
label, group, current effective binding, default; user rebinds via key
capture, clears a custom binding, disables an action, resets one, resets all.
Conflicts are detected (platform-aware) before save.
5. **Scope arbitration.** A detail drawer is open over a ticket list; `[`/`]`
move between adjacent records (drawer/panel scope), not list rows; `Escape`
closes the drawer and integrates with Radix, not a competing listener.
6. **Cross-device.** User rebinds `global.search` to `mod+j` on macOS; signs in
on Windows; it resolves to `Ctrl+J` automatically (neutral `mod+j` delta).
A binding hostile on the new OS surfaces a non-blocking advisory.
## UX / UI Notes
**Settings panel** lives as a new tab in the existing settings shell
(`server/src/components/settings/SettingsPage.tsx`), added to `baseTabContent`
with `{ id: 'keyboard-shortcuts', label: t(...), icon: KeyboardIcon, content }`,
wrapped in the standard `Card`/`CardHeader`/`CardContent`. Component:
`server/src/components/settings/general/KeyboardShortcutsSettings.tsx`.
- Layout mirrors existing list-settings panels (e.g.
`ExperimentalFeaturesSettings.tsx`, `GeneralSettings.tsx`): a `Table`
(`@alga-psa/ui/components/Table`) grouped by action group, columns: Action
(label + description), Scope, Default, Effective binding, Enabled
(`Switch`), Reset (`Button variant="ghost" size="sm"` with `RotateCcw`).
- Rebind uses a capture affordance (button → "press keys" inline capture) that
records `event.code` for code-kind and `event.key` for char-kind, matching the
matcher. Conflicts shown inline before commit; replacing an existing binding
requires explicit confirm (`ConfirmationDialog`).
- "Reset all" uses `ConfirmationDialog` (destructive intent).
- Buttons use standard variants (`default` primary, `outline` secondary,
`ghost` icon, `destructive` reset-all). Every interactive element has an `id`.
- Feedback: `react-hot-toast` success + `handleError(error, t(...))` on failure,
consistent with other settings panels. `LoadingIndicator` while loading.
- Saving model: immediate, debounced via `useUserPreference` (no explicit Save
button), consistent with preference-backed panels; `UnsavedChangesProvider`
not required since there is no staged form.
**Help dialog** uses the shared `Dialog` (`@alga-psa/ui/components/Dialog`),
read-only, grouped sections, bindings rendered via a shared `<Kbd>` component
that displays platform glyphs (⌘/⌥/⇧/⌃ on macOS; `Ctrl`/`Alt`/`Shift` on
others), resolved after mount to avoid hydration mismatch.
**Visible hints:** a shared `<Kbd>` / `ShortcutHint` component renders next to
key actions (e.g. search field, primary buttons, menu items) and in tooltips;
controls also get `aria-keyshortcuts` with a value derived from the effective
binding via a dedicated mapping (its format differs from the internal syntax).
**i18n:** new namespace `msp/keyboard-shortcuts` for action labels, group names,
help dialog, and settings panel chrome; settings-tab label added to
`msp/settings`. All keys added to en first, then fr/es/de/nl/it/pl/pt; pseudo
xx/yy regenerated; `ROUTE_NAMESPACES` updated so the namespace preloads for
`/msp`. No raw user-facing strings; action labels are i18n keys passed at
registration (`labelKey`/`groupKey`).
## Requirements
### Functional Requirements
**Engine (Phase 1)**
- FR1. `parseBinding(str)` parses modifiers + a key token, classifying the token
as `code` (letters/digits/named keys/brackets) or `char` (glyphs like `?`).
Supports literal `ctrl`/`meta` (not remapped) plus `mod`.
- FR2. `parseSequence(str)` parses space-separated chords into an ordered
sequence (e.g. `"g t"`), each element a single chord from FR1.
- FR3. `matchEvent(event, descriptor, platform)` dual matches: code-kind →
`event.code` + exact modifier set; char-kind → `event.key` produced char
(Shift implied by glyph), with `mod`/`alt` still required if specified.
- FR4. `mod` resolves to Meta on macOS, Ctrl elsewhere, at runtime per device.
Platform detected client-only (post-mount), never during SSR.
- FR5. Provider installs exactly one capture-phase `keydown` listener on
`document`. Registration hooks only mutate the registry.
- FR6. `useShortcutAction(def)` registers/unregisters an action on
mount/unmount; `def` carries `id`, `labelKey`, `groupKey`,
`defaultBindings: { mac, other }`, `scope`, `priority`, `enabled`,
`allowInEditable`, `handler`, optional `sequence` flag.
- FR7. `useShortcutScope(scope)` pushes/pops a ref-counted active scope; cleared
on route change.
- FR8. Dispatch: skip if `event.defaultPrevented`; match enabled actions; filter
by active scopes, editable-target rule, and active-region rule for
selection/single-key actions; pick highest priority; tie → most-local active
scope; still tied → report conflict (dev/settings), never silent registration
order. `preventDefault()` only when an enabled action actually handles it.
- FR9. Editable-target suppression for `input`/`textarea`/`select`/
`contenteditable`/`role=textbox`/`role=combobox`/editor roots unless
`allowInEditable`.
- FR10. Active-region: `selection.*` actions fire only when a registered
roving-focus region is active. Single-letter page actions
(`page.create`/`global.quickCreate`) are **not** region-gated — they fire
whenever their page scope is active and the target is non-editable, so the
affordance works on page load without first focusing the list.
- FR11. Sequence dispatch: a chord buffer accumulates keys; resolves a matching
sequence; resets on timeout (configurable, default ~1s), on a non-matching
key, on scope/route change, or in editable targets.
- FR12. Radix Escape integration: `dialog.cancel`/`panel.close` coordinate with
Radix `onEscapeKeyDown` + `ModalityContext`/`InsideDialogContext`; no
competing global Escape listener while a Radix modal owns Escape.
**Migrations (Phases 24)**
- FR13. Provider mounted in `MspLayoutClient.tsx` wrapping `DefaultLayout` and
`AlgaDeskMspShell`; not on auth/client-portal.
- FR14. `global.search` migrated from `SearchPalette.tsx`; `global.toggleChat`
and `ai.quickAsk` from `DefaultLayout.tsx` (preserving `aiAssistantAvailable`
gating); new `global.openShortcuts` (help) and `global.quickCreate`
(`QuickCreateDialog`).
- FR15. `mod+k` resolution: stays global search; asset palette becomes
page-scoped `assets.commandPalette` with a distinct default binding;
`AssetDashboardClient.tsx` window listener removed.
- FR16. Both `DrawerContext` files migrated together; `panel.close` (Escape,
Radix-integrated), `drawer.historyBack`/`Forward`.
- FR17. `TicketNavigation.tsx` adjacent-record nav migrated to
`record.previous`/`record.next` defaulting to `[`/`]` (Alt+Arrow only as an
opt-in alternate); drawer/panel scope wins over ticket page scope.
- FR18. Invoice designer (`useDesignerShortcuts.ts`), workflow designer, and
rich-text local shortcuts migrated/wrapped into `editor` scope (high
priority, per-platform redo, careful `allowInEditable`); BlockNote retains its
own internal undo/redo.
**Customization (Phase 5)**
- FR19. Overrides persisted via `useUserPreference` under key
`keyboard_shortcuts_v1`: delta-only, platform-neutral syntax, versioned, with
a `disabled` list; localStorage + debounced server sync.
- FR20. Resolution = `userOverride[id] ?? platformDefault(id, platform)`; a
user-set value equal to the current platform default is dropped, not frozen.
- FR21. On load, overrides validated against the current platform; hostile/
reserved combos surface a non-blocking advisory; never silently rewritten.
- FR22. Versioned blob with a migration function (v1 → vN).
- FR23. Settings panel: list actions grouped by group; show label, description,
scope, default, effective binding (resolved for device); rebind via key
capture; clear custom; disable; reset one; reset all (confirm); inline
platform-aware conflict detection before commit.
**Discoverability / Accessibility (Phase 6)**
- FR24. Help dialog opened by `global.openShortcuts`: read-only, active
shortcuts only, grouped by group/scope, custom flagged, disabled hidden,
bindings resolved per device.
- FR25. `<Kbd>`/`ShortcutHint` shared component renders platform glyphs; visible
hints added to key controls (search field, primary action buttons, relevant
menu items) and tooltips.
- FR26. `aria-keyshortcuts` set on instrumented controls via a mapping layer
that converts the effective binding to the ARIA value format.
**i18n (cross-cutting)**
- FR27. New `msp/keyboard-shortcuts` namespace; settings-tab label in
`msp/settings`. Action `labelKey`/`groupKey` are i18n keys from Phase 1.
- FR28. English keys authored first; fr/es/de/nl/it/pl/pt translated; pseudo
xx/yy regenerated; `validate-translations.cjs` passes; `ROUTE_NAMESPACES`
updated so the namespace preloads for `/msp`.
- FR29. Platform glyph labels (⌘/Ctrl etc.) and ARIA shortcut text are
localized/handled per locale; no hardcoded user-facing strings anywhere in
the system, settings, or help.
**Architecture boundary (dependency direction)**
- FR30. The engine in `packages/ui/src/keyboard-shortcuts` persists only
through a `ShortcutStorage` adapter interface and **must not import**
`@alga-psa/user-composition` or any feature package
(`@alga-psa/tickets|billing|assets|projects|clients|scheduling|...`). The
`useUserPreference`-backed adapter is implemented in the MSP wrapper
(`server`/user-composition layer) and injected into the provider. A
CI-runnable dependency-boundary guard enforces no forbidden imports and no
new circular dependency vs `.github/known-cycles.json` (consistent with
`.github/workflows/circular-deps.yml` and `eslint-plugin-custom-rules`).
**Page-scoped create/save (commitGroup `page-actions`)**
- FR31. `page.create` is a page-scoped action defaulting to **`c`**
(non-editable; fires on page scope, not active-region gated). `mod+n` is offered only as a
user-configurable *alternate* with a documented caveat: `Ctrl/⌘+N` is a
browser "new window" accelerator and is not reliably interceptable
(especially Chrome on Windows), so `c` is the working default. Each page
with a Create/New control registers `page.create` (via a
`usePageCreateShortcut` helper) wired to that page's create dialog;
`global.quickCreate` has its own distinct default binding **`n`** (the
shared `c` default caused a silent unresolved dispatch conflict) and opens
the multi-type `QuickCreateDialog`; it is the create affordance on pages
with no `page.create` registered and coexists with `page.create` (`c`)
elsewhere. `page.save` (`mod+s`, `preventDefault` browser save) is
registered by pages with a primary Save. Both are suppressed in editable
targets and when a dialog/drawer owns scope.
**Create-dialog keyboard usability (commitGroup `dialog-a11y`)**
- FR32. Every page's create dialog is fully operable without a mouse: focus
the first field on open and restore focus to the invoker on close; a focus
trap; `mod+Enter` submit (textarea/BlockNote-safe); `Escape` cancel
(Radix-integrated); all controls (selects, pickers, comboboxes, toggles)
keyboard-reachable; correct `role=dialog`/`aria-modal`/`aria-labelledby`.
**Command palette / Spotlight (commitGroup `command-palette`)**
- FR33. A keyboard-driven command palette opened by **`mod+k`** (this
**resolves and supersedes** the prior open decision — `mod+k` no longer just
focuses the sidebar search; the palette's default free-text mode includes
record search so existing behavior is preserved/enhanced; the asset palette
stays rescoped to `mod+shift+k`). Results merge navigation destinations
(`menuConfig`), runnable registered shortcut actions, and record search,
ranked with recents/frequency. The query grammar follows the TeamCity search
model (see Appendix). The parser is a pure, UI-decoupled, unit-tested module
obeying the FR30 boundary. Fully accessible (combobox/listbox roles,
`aria-activedescendant`, live result count), i18n in all 8 locales +
pseudo, with in-palette syntax help linked from the global help dialog.
**End-to-end customization wiring (commitGroup `customization-wiring`)**
- FR34. The persistence/override layer must be **functionally connected**, not
only unit-tested in isolation. The provider is the single source of
preference state: `MspLayoutClient` injects the `useUserPreference`-backed
`ShortcutStorage`; the provider loads `PersistedShortcuts`, dispatch resolves
bindings via `resolveActionBindings` (override → platform default), merges
the persisted `disabled` list, and exposes resolved bindings + mutators via
context; `display.tsx` hints/`aria-keyshortcuts` and the settings panel all
read from that one source. **An integration test is required** asserting:
rebind in settings → the new combo dispatches, the old default stops, the
settings "Effective" column, `ShortcutHint`, and `aria-keyshortcuts` all
reflect it; disable stops dispatch and hides from help; reset live-updates.
Unit/contract green on isolated pieces is **not** sufficient to mark
FR19/FR20/FR23/FR26 or F043/F140 done.
**Gap-analysis remediation (commitGroup `gap-hardening`)**
These three requirements came out of a post-implementation review of the
branch. They are distinct from FR34 (which covers only the
persistence/dispatch wiring) and address structural issues that let the
review-discovered bugs ship "green".
- FR35. **Catalog is the single source of truth for action metadata.**
`scope`, `priority`, `defaultBindings`, `labelKey`, `groupKey`, `sequence`,
and `allowInEditable` live only in `catalog.ts`. Registration sites build
actions through a catalog-derived factory (e.g.
`createShortcutAction(id, handler, { enabled? })` / `useCatalogShortcut`)
supplying only `id` + `handler` (+ optional runtime `enabled`); hand-authored
metadata literals are removed from `SearchPalette`, `DefaultLayout`, both
`DrawerContext` files, `TicketNavigation`, `AssetDashboardClient`, and
`useDesignerShortcuts`. A guard (unit + the boundary script) fails when a
registered/used action id is absent from the catalog or its
scope/priority/sequence/bindings diverge from the catalog entry. This
resolves the current divergence where `useDesignerShortcuts` hardcodes
`priority: 60` while every other site omits `priority` (runtime `0`),
contradicting `catalog.ts` `DEFAULT_PRIORITY` — so the priorities the
settings UI/help show are not the priorities dispatch uses.
- FR36. **Active-region gating is enforced, not nominal.** `DefaultLayout`
must not call `useShortcutActiveRegion(true)` unconditionally. An active
region is registered only by genuine roving-focus list/selection containers
(via a shared region wrapper/hook applied to the real list views). As a
result `selection.*` actions (`j`/`k`/`Enter`) are inert on arbitrary
non-editable focus anywhere in `/msp` and fire only when such a region is
focused/active. Single-letter page actions (`page.create` `c`,
`global.quickCreate` `n`) are **not** region-gated: they are page-wide
affordances guarded by scope eligibility and editable-target suppression
only, so they work on page load without first focusing the list (the
earlier active-region requirement made `c`/`n` dead until the list was
clicked into — rejected as a UX regression). This turns FR10 from
documented intent into enforced behavior.
- FR37. **Behavioral test coverage replaces source greps.** The
`readFileSync` + `toContain` `*.contract.test.ts` suites
(`global-migration`, `panels-drawers`, `editors`, `persistence-bridge`,
`settings-ui`, `regression`, `i18n`) are replaced/augmented with tests that
assert observable dispatch/registration/resolution behavior rather than
source-string presence; a thin source-presence smoke is kept only where the
behavior genuinely cannot be simulated. A meta test-guard flags any new
`*.contract.test.ts` that only greps source without a behavioral assertion,
so "green CI" cannot again mean "the feature was never exercised".
### Non-functional Requirements
- One global listener; O(registered actions) match per keydown; no measurable
input latency.
- No SSR/hydration mismatch (platform + bindings resolved post-mount).
- `packages/ui` keeps the engine dependency-light; preference wiring lives in
the MSP wrapper/user-composition layer.
- No regression to component-local widget key handling.
- Accessibility: screen readers announce shortcuts via `aria-keyshortcuts`.
**Shortcuts UI redesign + Profile placement (commitGroup `shortcuts-ui-redesign`)**
These requirements supersede the original list-Table settings panel
(`KeyboardShortcutsSettings.tsx`, now deleted) with the design-handoff visual
keyboard cheatsheet, and correct its placement so users can actually reach it.
Sourced from `~/Downloads/design_handoff_keyboard_shortcuts/` (variation-c) and
manual-test feedback that the prior tab never appeared in the sidebar.
- FR38. **Placement: Profile, not Settings.** The keyboard-shortcuts pane is a
`CustomTabs` sub-tab under `/msp/profile` (`UserProfile.tsx tabContent`),
matching the per-user-preference convention (Profile / Security / SSO / API
Keys / Notifications). The old `SettingsPage.tsx baseTabContent` entry and
`KeyboardShortcutsSettings.tsx` are removed. `keyboard-shortcuts` is added to
`BASE_PROFILE_TABS` in `packages/integrations/src/lib/calendarAvailability.ts`
so `/msp/profile?tab=keyboard-shortcuts` deep-links work. The Sidebar
settings-mode menu is unaffected (the pane is not a Settings tab).
- FR39. **Visual keyboard panel.** `KeyboardShortcutsPanel.tsx` recreates the
design handoff (variation-c) using product CSS variables and Radix primitives,
driven by the **real catalog** + provider single source (no parallel data):
full keyboard grid (`KB_ROWS`), layer toggle (Plain / ⌘ Mod / ⇧ Shift /
⌘⇧) with per-layer counts, category-tinted bound keys with conflict dots,
hover/selection KeyDetail strip (action name + scope chip + Modified badge +
description + BindingDisplay + Rebind/Cancel + Enabled switch + per-action
Reset), right-rail chord list with search and `Reset all to {profile}`
footer, real `keydown` capture wired through `useKeyboardShortcutPreferences`,
Copy cheatsheet (print window), `ConfirmationDialog` for reset-all and the
**prompt-to-reassign** conflict UX (the new binding takes over, the previous
owner is left unbound), `react-hot-toast` + `handleError` parity, every
interactive element has an `id`. Bindings whose key isn't on the keyboard
(`?`, `Escape`, `Delete`, sequences) route to the chord rail rather than
silently disappearing (improvement over the prototype).
- FR40. **Profile presets in the engine single source.** `PersistedShortcuts`
gains `profile: string` (schema v2, with a v1→v2 migration that defaults to
`'default'` and preserves a valid stored profile). `SHORTCUT_PROFILES` ships
`default`, `vim`, `emacs` with parser-valid neutral single-chord deltas keyed
by real catalog ids — multi-chord Emacs sequences are deliberately not
assigned to non-sequence actions (they would silently never dispatch).
Resolution order is **user override → active-profile delta → platform
default**, applied inside `resolveActionBindings` so dispatch, hints, ARIA,
and the panel all read the same effective binding. `setActionBindingsDelta`
drops an override equal to the **profile baseline** (not raw factory), so
per-action reset returns to the active profile's baseline. The provider
exposes `profile` + `setProfile`. Override scope stays **per-account only**
(`useUserPreference`); the prototype's per-device split is intentionally
deferred. Vim/Emacs delta maps are best-guess pending team confirmation.
## Data / API / Integrations
- Persistence: engine exposes a `ShortcutStorage` adapter interface; the
concrete adapter is `useUserPreference`-backed
(`packages/user-composition/src/hooks/useUserPreference.ts`), key
`keyboard_shortcuts_v1`, localStorage + debounced server sync, unauthenticated
path supported. The adapter and its `@alga-psa/user-composition` import live
in the MSP wrapper (`server`), injected into the provider — never imported
into `packages/ui`. No new tables/endpoints (reuses existing
`user_preferences`).
- Stored shape:
`{ "version": 1, "bindings": { "<id>": ["<neutral-binding>"...] }, "disabled": ["<id>"...] }`
storing only deltas in neutral syntax (`mod`, logical tokens).
- i18n integration: `useTranslation('msp/keyboard-shortcuts')`
(`@alga-psa/ui/lib/i18n/client`); locale files under
`server/public/locales/{lang}/msp/keyboard-shortcuts.json`; namespace added to
`ROUTE_NAMESPACES` in `packages/core/src/lib/i18n/config.ts`.
- Settings shell: `server/src/components/settings/SettingsPage.tsx`
`baseTabContent`; product-tab allowlist (`settingsProductTabs.ts`) reviewed
(no change expected for MSP-only).
## Security / Permissions
- No new privileged operations. Shortcuts only invoke handlers the user can
already trigger via UI; `enabled` honors existing gating (e.g.
`aiAssistantAvailable`). Preference reads/writes are user-scoped via existing
`useUserPreference` server actions.
## Observability
- None beyond dev-time conflict reporting in the registry/settings UI (no new
metrics/logging per scope philosophy).
## Rollout / Migration
- Phased per source plan: P1 engine → P2 global → P3 panels/drawers/record →
P4 editors → P5 customization → P6 discoverability/a11y.
- Each migrated handler is removed only after its action is registered and
regression-verified, so behavior is preserved at every step.
- `mod+k` decision is settled (global search; asset palette rescoped) and is a
hard prerequisite for P2 completion.
- Preference blob is versioned with a migration function for forward changes.
## Open Questions
- Tenant-level defaults / admin lock (deferred; not in scope).
- Export/import of overrides (deferred).
- Whether client portal adopts the system later (deferred).
- Final default binding for `assets.commandPalette` after rescoping
(implemented: `mod+shift+k`).
- (Resolved) `mod+k` = the command palette / Spotlight (FR33), not just
sidebar search; record search lives inside the palette.
- `$` magic-keyword set and the exact field-alias list are finalized during
`command-palette` implementation (Appendix is the starting spec).
## Acceptance Criteria (Definition of Done)
- All handlers from the inventory are registered through the shared system; one
delegated capture listener; component-local widget handlers untouched.
- Matching correct on macOS and Windows/Linux incl. `alt+letter`, international
layouts, `mod` resolution; respects `event.defaultPrevented`.
- Single-chord and `g`-sequence bindings work; sequence buffer times out/resets
correctly and is suppressed in editable targets.
- Scope/priority/active-region prevent global/page/panel/dialog/editor
conflicts; drawer wins over page; Escape integrates with Radix.
- `mod+k` conflict removed; asset palette rescoped and functional.
- `packages/ui/src/keyboard-shortcuts` has zero imports of
`@alga-psa/user-composition` or feature packages; persistence flows through
the injected `ShortcutStorage` adapter; `npx nx graph` shows no new cycle
vs `.github/known-cycles.json`; the dependency-boundary guard passes.
- Existing AI/search/drawer/ticket/designer shortcuts preserved post-migration
(regression + Playwright verified).
- User can rebind/clear/disable/reset; overrides persist as neutral deltas
locally and sync to server; Mac→Windows cross-device resolves correctly with
advisories for hostile combos.
- Settings UI is visually/structurally consistent with existing settings panels
(shared components, variants, toast/error handling, `id`s, loading state).
- Help dialog lists active shortcuts resolved per device; `aria-keyshortcuts`
and visible `kbd` hints present on instrumented controls.
- All user-facing strings internationalized in 8 locales + pseudo;
`validate-translations.cjs` passes; namespace preloads via `ROUTE_NAMESPACES`.
- No SSR/hydration mismatch.
- `page.create` (`c`) opens the current page's create dialog on every page
with a Create control; `page.save` (`mod+s`) saves on pages with a Save;
both suppressed while typing / when a dialog owns scope.
- Every page's create dialog is completable end-to-end with the keyboard only.
- `mod+k` opens the command palette; field-scoped syntax + operators per the
Appendix work; results span nav + actions + records; fully keyboard/SR
accessible; palette module passes the FR30 dependency-boundary guard.
- Customization is wired end-to-end: a rebind/disable/reset made in settings
immediately changes what the dispatcher fires and what hints/`aria` show,
from a single provider-owned source; the FR34 integration test passes.
## Appendix: Command Palette Syntax (TeamCity-derived)
Adapted from JetBrains TeamCity search
(`https://www.jetbrains.com/help/teamcity/search.html`).
- **Field-scoped:** `field:value` with short aliases. Initial registry:
`ticket:`/`t:`, `client:`/`c:`, `contact:`, `project:`/`p:`, `asset:`/`a:`,
`user:`/`u:` (and `@name`), `nav:` (and `/path`), `action:` (and `>cmd`).
Record-id sigil `#1234`. Final alias list confirmed in implementation.
- **Operators:** double-quoted phrases (`status:"in progress"`); `-` / `NOT`
exclusion; `*` and `?` wildcards (no leading `*`); fuzzy `term~`;
**prefix-match by default**; **OR** is the default between unscoped terms;
**AND** only within the same field scope (`tag:a AND tag:b`).
- **Magic keywords:** `$`-prefixed, abbreviable to first syllable
(`$mine`/`$m`, `$recent`/`$rec`, `$open`) — mirrors TeamCity `$pinned`/`$p`.
- **Sigils (leading):** `>` run action/command, `#` open by record id,
`@` people, `/` navigation.
- **No field given:** free-text fuzzy/prefix across nav + actions + records,
ranked with recents/frequency.