Shared Action Registry
Shared action registry
When a UI exposes the same set of actions through multiple surfaces — command palette, global keybindings, help dialog, settings page — the surfaces will drift. The cure is one registry that all of them derive from.
The drift problem
Before mxr's web app had a registry, three independent surfaces held the same list:
CommandPalette.tsx— items shown inCmd-Kkeymap.ts— global keybindings viatinykeysshortcutHints.ts— help dialog rows
Drift was inevitable and shipped real bugs. One example: g a routed to archive in the keymap but was labelled "Analytics" in the palette. Two of the three surfaces agreed; the third lied. The lie persisted because nobody owned reconciling them.
This is the M-surfaces-times-N-actions problem at small scale. Three surfaces × eighty actions = 240 entries to keep in sync. The number is small enough that "we'll just be careful" sounds plausible. It doesn't survive contact with feature work.
The registry shape
One Action type, one catalog, every consumer derives from it:
type Action = {
id: string; // stable kebab, e.g. "mail.archive"
label: string;
description?: string;
group: ActionGroup;
icon?: LucideIcon;
shortcut?: ShortcutChord;
aliases?: ShortcutChord[];
paletteOnly?: boolean;
when?: (ctx: ActionContext) => boolean;
run: (ctx: ActionContext) => void | Promise<void>;
};
Consumers:
- Command palette:
useActionsByGroup(ctx)filters viawhen - Keybindings: registry-derived chord map fed to
tinykeys - Help dialog:
useActionShortcutSections(ctx) - Settings keybindings page: derived live — no hardcoded list
Adding a new action means adding one entry. The four surfaces update automatically. There's no "remember to also update X" footnote because there's no X to update.
What the registry is not
The second mxr keyboard audit sharpened the boundary. A registry is not "every enum value in the TUI." It is the catalog of durable product actions: archive, open compose, run search, open analytics, apply label, create rule. Page-local motion belongs with the page that owns the focus model: mailbox j/k, reader scroll, sidebar roving focus, CodeMirror vim state. Those keys still need help text, but they should not be forced into the global action registry just to satisfy a fake parity metric.
This matters because "TUI parity" can become an overclaim. The honest question is: can the user reach every product intention without the mouse? The registry answers part of that, but it does not prove charts, overflow panes, hover-only affordances, and page-local controls are keyboard reachable. See Keyboard Parity Is a Product Contract.
Predicates and runners split the right way
when predicates are composable pure functions: onRoute, onPane, withSelection, withFocusedThread. Multiple chained with and(...). This stays testable and serializable.
run functions never call React hooks. They reach into stores via getNavigateRef() and useModals.getState(). The reason: a runner has to fire from outside React (the tinykeys callback), from inside React (the palette), and programmatically (other code paths). Hooks would limit it to one of those.
Bundle impact and lazy escape hatch
The catalog imports every feature's runners eagerly. ~80 actions × ~200 bytes ≈ 16 KB pre-gzip. If this grows past a few percent of the main chunk, the escape hatch is to lazy-load runners with dynamic import() keyed by action.id while keeping Action metadata eager so palette filtering stays sync. The pattern survives the optimization because the registry is the seam.
Where this generalizes
Any app where the same actions appear in multiple invocation surfaces. Electron apps with a menu bar + keyboard + command palette. VS Code with the command palette + keybindings + menus. Mobile apps with toolbar + long-press menu + voice. The number of surfaces only grows; the drift cost compounds. Single registry, every surface derived.
The trigger for adopting it: the first time a surface lies and you notice it through a user bug rather than through your own testing. After that, the registry is cheaper than the next bug.
See also
- Headless Core + Multiple Clients — the protocol-level analogue: one canonical surface, many derived consumers
- Mxr — where this registry lives (
apps/web/src/lib/actions/)