Add global keyboard EventRouter and unify publish (M6, §01D)

One module owns the document keydown listener and dispatches the shortcut map
(Cmd/Ctrl+Shift+N / +K / +S / +, / Escape), platform-aware, via the single-source
focus-utils interactive-context gate. Escape and Cmd/Ctrl+S run before the gate so
save works while editing; the rest are suppressed mid-typing. Publish + its toast
move into services/snippet-actions so the button and Cmd/Ctrl+S behave identically.
Removes the ad-hoc keydown handler from App and Monaco's Cmd+S command.
This commit is contained in:
2026-06-07 17:18:55 +03:00
parent f365258fcc
commit 807d3c8e3c
12 changed files with 401 additions and 48 deletions
+22 -12
View File
@@ -320,7 +320,17 @@ function onKeyDown(e: KeyboardEvent): void {
const mod = isMac ? e.metaKey : e.ctrlKey; const mod = isMac ? e.metaKey : e.ctrlKey;
// --- Shortcuts: never fire while typing in an input or Monaco ---------- // Cmd/Ctrl + S -> publish current draft. Checked BEFORE the interactive-context
// gate: it is the canonical "save" shortcut (spec §01D mandates it override the
// browser default), and publishing happens *while editing the draft in Monaco* —
// gating it behind "not typing" would defeat its purpose.
if (mod && !e.shiftKey && e.key.toLowerCase() === 's') {
e.preventDefault(); // override the browser "save page" dialog
useSnippetStore.getState().publishDraft();
return;
}
// --- Remaining shortcuts: never fire while typing in an input or Monaco ----
if (isInInteractiveContext()) return; if (isInInteractiveContext()) return;
// Cmd/Ctrl + Shift + N -> new snippet // Cmd/Ctrl + Shift + N -> new snippet
@@ -336,16 +346,12 @@ function onKeyDown(e: KeyboardEvent): void {
toggleDatasets(); toggleDatasets();
return; return;
} }
// Cmd/Ctrl + S -> publish current draft // Cmd/Ctrl + , -> open the editor settings popover. Settings are distributed to
if (mod && e.key.toLowerCase() === 's') { // per-pane disclosure popovers, not a modal (spec §07), so this opens the editor
e.preventDefault(); // override the browser "save page" dialog // cluster (openSettingsPopover) — there is no 'settings' modal to open.
useSnippetStore.getState().publishDraft();
return;
}
// Cmd/Ctrl + , -> settings (through the coordinator: snapshot + URL sync)
if (mod && e.key === ',') { if (mod && e.key === ',') {
e.preventDefault(); e.preventDefault();
openModal('settings'); openSettingsPopover('editor-settings');
return; return;
} }
} }
@@ -392,9 +398,13 @@ by inserting a rung at the right priority — never by sprinkling
`e.preventDefault()` so Cmd/Ctrl+S does not trigger "save page", Cmd/Ctrl+K `e.preventDefault()` so Cmd/Ctrl+S does not trigger "save page", Cmd/Ctrl+K
does not focus the browser search bar, etc. does not focus the browser search bar, etc.
Note the asymmetry: **Escape is checked before the interactive-context gate** Note the asymmetry: **Escape and Cmd/Ctrl+S are checked before the
(you want Escape to dismiss a modal even while focus is in the editor), whereas interactive-context gate.** Escape, so it dismisses a modal even while focus is in
all other shortcuts are checked **after** the gate (so they don't fire mid-typing). the editor; Cmd/Ctrl+S, because publishing the draft is something you do _while_
editing it — gating "save" behind "not typing" would defeat it (and it must
override the browser's "save page" regardless). The remaining shortcuts (new
snippet, toggle Datasets, settings) are checked **after** the gate, so they never
fire mid-typing or steal a key the editor wants (e.g. Monaco's own Cmd+K chord).
### 2.2 The single-source helper: `orchestration/focus-utils.ts` ### 2.2 The single-source helper: `orchestration/focus-utils.ts`
@@ -65,6 +65,13 @@ nature of the message, not by convenience.
_inline on the control_ ("Copied") with a polite `aria-live` announcement for assistive _inline on the control_ ("Copied") with a polite `aria-live` announcement for assistive
tech, never a toast-per-copy. This refines the spec's earlier blanket "every action tech, never a toast-per-copy. This refines the spec's earlier blanket "every action
toasts" (spec §01F/§02/§05, reconciled). toasts" (spec §01F/§02/§05, reconciled).
- **An action's confirmation belongs with the action, not its call site.** When an action is
reachable from more than one trigger (e.g. publish is both a toolbar button _and_
Cmd/Ctrl+S), pair the store mutation and its toast in **one `services/` helper**
(`publishActiveSnippet`) that every trigger calls — otherwise the toast rides one path and
the other publishes silently (the exact inconsistency this rule prevents). The shortcut is
owned globally by the EventRouter (arch 04), so the helper is the only place the outcome is
confirmed.
- **The same failure can light up two channels.** An unrenderable spec shows the _same_ - **The same failure can light up two channels.** An unrenderable spec shows the _same_
message inline in both the editor (§03E) and the preview (§04) — one producer message inline in both the editor (§03E) and the preview (§04) — one producer
(`PreviewStore`), two subscribers. That's intentional, not duplication. (`PreviewStore`), two subscribers. That's intentional, not duplication.
+4
View File
@@ -67,6 +67,10 @@ Notes:
- Cmd/Ctrl + K is a toggle: if the Datasets manager is already open it closes it; otherwise it opens it. - Cmd/Ctrl + K is a toggle: if the Datasets manager is already open it closes it; otherwise it opens it.
- Escape only acts when a modal is open; with no modal open it does nothing. - Escape only acts when a modal is open; with no modal open it does nothing.
- Cmd/Ctrl + S publishes the active draft regardless of where focus is — **including
while the editor has focus** — so it behaves as a "save" you reach for mid-edit. The
other shortcuts (new snippet, toggle Datasets, settings) are suppressed while the
user is typing in the editor or an input, so they don't interrupt text entry.
- The shortcut actions override the browser's default behavior for those key combinations. - The shortcut actions override the browser's default behavior for those key combinations.
## E. Navigation & Shareable URL State ## E. Navigation & Shareable URL State
+1 -20
View File
@@ -8,8 +8,7 @@ import { SnippetLibrary } from './components/SnippetLibrary';
import { SpecEditor } from './components/SpecEditor'; import { SpecEditor } from './components/SpecEditor';
import { ThemeToggle } from './components/ThemeToggle'; import { ThemeToggle } from './components/ThemeToggle';
import { Toaster } from './components/Toaster'; import { Toaster } from './components/Toaster';
import { openModal, setConfirm, toggleDatasets } from './modals/ModalCoordinator'; import { openModal, setConfirm } from './modals/ModalCoordinator';
import { openSettingsPopover } from './stores/SettingsPopoverStore';
import { confirm } from './stores/ConfirmStore'; import { confirm } from './stores/ConfirmStore';
import { usePanesStore } from './stores/PanesStore'; import { usePanesStore } from './stores/PanesStore';
import { exportWorkspace, importWorkspace } from './services/transfer'; import { exportWorkspace, importWorkspace } from './services/transfer';
@@ -47,24 +46,6 @@ export function App() {
setConfirm((message) => confirm({ title: 'Discard changes?', message, danger: true })); setConfirm((message) => confirm({ title: 'Discard changes?', message, danger: true }));
}, []); }, []);
// Cmd/Ctrl+K toggles the Datasets manager (spec §05); Cmd/Ctrl+, opens the
// editor settings popover — the primary preference cluster, now that settings
// are distributed per pane (spec §07). Full key router lands in M6 (arch 04).
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (!(e.metaKey || e.ctrlKey)) return;
if (e.key === 'k' || e.key === 'K') {
e.preventDefault();
toggleDatasets();
} else if (e.key === ',') {
e.preventDefault();
openSettingsPopover('editor-settings');
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, []);
const handleImportFile = (e: React.ChangeEvent<HTMLInputElement>) => { const handleImportFile = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
// Reset the input so picking the same file again still fires onChange. // Reset the input so picking the same file again still fires onChange.
+8 -16
View File
@@ -27,6 +27,7 @@ import { openModal } from '../modals/ModalCoordinator';
import { useAppStore } from '../stores/AppStore'; import { useAppStore } from '../stores/AppStore';
import { confirm } from '../stores/ConfirmStore'; import { confirm } from '../stores/ConfirmStore';
import { hasInlineData } from '../stores/ExtractStore'; import { hasInlineData } from '../stores/ExtractStore';
import { publishActiveSnippet } from '../services/snippet-actions';
import { notify } from '../stores/NotificationStore'; import { notify } from '../stores/NotificationStore';
import { usePreviewStore } from '../stores/PreviewStore'; import { usePreviewStore } from '../stores/PreviewStore';
import { selectActiveSnippet, selectShownText, useSnippetStore } from '../stores/SnippetStore'; import { selectActiveSnippet, selectShownText, useSnippetStore } from '../stores/SnippetStore';
@@ -152,18 +153,9 @@ function EditorToolbar() {
(s) => s.activeSnippetId !== null && hasInlineData(s.draftText), (s) => s.activeSnippetId !== null && hasInlineData(s.draftText),
); );
const handlePublish = () => { // Publish + its success toast live in one place (services/snippet-actions) so
if (!useSnippetStore.getState().activeSnippetId) return; // the button and the Cmd/Ctrl+S shortcut (EventRouter) behave identically.
useSnippetStore.getState().publish(); const handlePublish = publishActiveSnippet;
// Success confirmation (spec §03D). Per the council's toast-copy rule
// (docs/architecture/10 → Toast copy), the title states the action and the
// message adds the consequence rather than paraphrasing it.
notify({
kind: 'success',
title: 'Snippet published',
message: 'Your draft is now the published version.',
});
};
const handleRevert = async () => { const handleRevert = async () => {
const ok = await confirm({ const ok = await confirm({
@@ -275,10 +267,10 @@ export function SpecEditor() {
} }
}); });
// Cmd/Ctrl+S publishes the current draft (spec §03D → Publish). // Cmd/Ctrl+S is owned globally by the EventRouter (docs/architecture/04 →
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => { // "bind listeners in exactly one place"), which publishes before the
if (useSnippetStore.getState().activeSnippetId) useSnippetStore.getState().publish(); // interactive-context gate so it works while the editor has focus. Monaco
}); // binds no default for Cmd+S, so the keystroke bubbles to that one handler.
return () => { return () => {
sub.dispose(); sub.dispose();
+94
View File
@@ -0,0 +1,94 @@
import { afterEach, beforeEach, describe, expect, test } from 'vitest';
import { createSnippet } from '@core/snippet';
import { useAppStore } from '../stores/AppStore';
import { useSnippetStore } from '../stores/SnippetStore';
import { startEventRouter, stopEventRouter } from './EventRouter';
const T = new Date('2026-06-01T00:00:00Z');
const active = () => useSnippetStore.getState();
/** Dispatch a global keydown with both modifiers set so it fires on any platform. */
function press(key: string, opts: Partial<KeyboardEventInit> = {}): void {
window.dispatchEvent(
new KeyboardEvent('keydown', {
key,
bubbles: true,
cancelable: true,
ctrlKey: true,
metaKey: true,
...opts,
}),
);
}
beforeEach(() => {
useSnippetStore.getState().reset();
useAppStore.getState().setActiveModal(null);
document.body.innerHTML = '';
window.location.hash = '';
useSnippetStore.getState().hydrate([createSnippet({ id: 'a', now: T })], 'a');
startEventRouter();
});
afterEach(() => {
stopEventRouter();
});
describe('shortcut dispatch (spec §01D)', () => {
test('Cmd/Ctrl+Shift+N creates and activates a new snippet', () => {
expect(active().snippets).toHaveLength(1);
press('n', { shiftKey: true });
expect(active().snippets).toHaveLength(2);
expect(active().activeSnippetId).not.toBe('a');
});
test('Cmd/Ctrl+K toggles the Datasets manager open then closed', async () => {
press('k');
expect(useAppStore.getState().activeModal).toBe('datasets');
press('k');
await Promise.resolve(); // closeModal is async (no unsaved changes → no prompt)
expect(useAppStore.getState().activeModal).toBeNull();
});
test('Cmd/Ctrl+S publishes the active draft', () => {
active().updateDraft('{"published":1}');
press('s');
expect(active().snippets.find((s) => s.id === 'a')?.spec).toBe('{"published":1}');
});
test('Escape closes the active modal', async () => {
useAppStore.getState().setActiveModal('datasets');
press('Escape');
await Promise.resolve();
expect(useAppStore.getState().activeModal).toBeNull();
});
test('Escape with no modal open does nothing', () => {
expect(useAppStore.getState().activeModal).toBeNull();
press('Escape'); // must not throw or change state
expect(useAppStore.getState().activeModal).toBeNull();
});
});
describe('interactive-context gating', () => {
test('N is suppressed while an input is focused', () => {
const input = document.createElement('input');
document.body.appendChild(input);
input.focus();
press('n', { shiftKey: true });
expect(active().snippets).toHaveLength(1); // no new snippet
});
test('Cmd/Ctrl+S still publishes while the editor/input is focused (deliberate divergence)', () => {
const input = document.createElement('input');
document.body.appendChild(input);
input.focus();
active().updateDraft('{"saved-while-typing":1}');
press('s');
expect(active().snippets.find((s) => s.id === 'a')?.spec).toBe('{"saved-while-typing":1}');
});
});
+112
View File
@@ -0,0 +1,112 @@
/**
* Global keyboard routing (spec §01D, docs/architecture/04 → "Global Events").
*
* One module owns the document-level `keydown` listener and dispatches the app's
* shortcuts. Centralizing it keeps ordering explicit and gives one place to
* reason about priority — components never attach their own `window` keydown
* handlers (see the "Don't" list in docs/architecture/04 §2).
*
* The shortcut map (spec §01D), platform-aware (Cmd on Mac, Ctrl elsewhere):
* Cmd/Ctrl + Shift + N → new snippet
* Cmd/Ctrl + K → toggle the Datasets manager
* Cmd/Ctrl + S → publish the active snippet's draft
* Cmd/Ctrl + , → open the editor settings popover
* Escape → close the active modal
*
* Interactive-context gating (the `isInInteractiveContext` seam):
*
* - **Escape** is checked *before* the gate — it must dismiss a modal even while
* focus is in the editor or an input.
* - **Cmd/Ctrl+S** is also checked before the gate. It is the canonical "save"
* shortcut and the spec mandates it override the browser default; publishing is
* something the user does *while editing the draft in Monaco*, so gating it
* behind "not typing" would defeat its purpose. (Deliberate divergence from the
* arch sketch, which placed Cmd+S after the gate.)
* - **N / K / ,** are checked *after* the gate, so they never steal a keystroke
* from the editor or an input (e.g. Monaco's own Cmd+K chord).
*/
import { closeModal, toggleDatasets } from '../modals/ModalCoordinator';
import { useAppStore } from '../stores/AppStore';
import { useSnippetStore } from '../stores/SnippetStore';
import { openSettingsPopover } from '../stores/SettingsPopoverStore';
import { publishActiveSnippet } from '../services/snippet-actions';
import { isInInteractiveContext } from './focus-utils';
let started = false;
const isMac = /Mac|iPhone|iPad|iPod/.test(navigator.platform);
function modifier(e: KeyboardEvent): boolean {
return isMac ? e.metaKey : e.ctrlKey;
}
/** Returns true if it consumed the Escape (caller should `preventDefault`). */
function handleEscape(): boolean {
// Route through the coordinator so the unsaved-change discard prompt runs and
// the URL is cleared. NEVER setActiveModal(null) here — that would silently
// drop in-progress dataset / chart-builder edits. Escape only acts when a
// modal is open; with no modal it does nothing (spec §01D).
if (useAppStore.getState().activeModal) {
void closeModal();
return true;
}
return false;
}
function onKeyDown(e: KeyboardEvent): void {
// Escape: highest priority, runs even while the editor / an input has focus.
if (e.key === 'Escape') {
if (handleEscape()) e.preventDefault();
return;
}
if (!modifier(e)) return;
const key = e.key.toLowerCase();
// Cmd/Ctrl+S: publish. Handled before the interactive-context gate so it works
// while editing the draft, and overrides the browser "save page" dialog.
if (key === 's' && !e.shiftKey) {
e.preventDefault();
publishActiveSnippet();
return;
}
// Everything below must not fire while the user is typing.
if (isInInteractiveContext()) return;
// Cmd/Ctrl+Shift+N: new snippet (selection drives the URL via the routing
// subscription, so no explicit navigate call is needed).
if (key === 'n' && e.shiftKey) {
e.preventDefault();
useSnippetStore.getState().createSnippet();
return;
}
// Cmd/Ctrl+K: toggle the Datasets manager (coordinator owns open/close + URL).
if (key === 'k' && !e.shiftKey) {
e.preventDefault();
toggleDatasets();
return;
}
// Cmd/Ctrl+,: open the editor settings popover. Settings are distributed to
// per-pane disclosure popovers, not a modal (spec §07); the editor cluster is
// the primary one (the arch sketch's openModal('settings') predates that).
if (e.key === ',') {
e.preventDefault();
openSettingsPopover('editor-settings');
return;
}
}
/** Bind the global keyboard listener. Idempotent; pair with `stopEventRouter`. */
export function startEventRouter(): void {
if (started) return;
started = true;
window.addEventListener('keydown', onKeyDown);
}
/** Unbind the global keyboard listener (teardown for tests). */
export function stopEventRouter(): void {
window.removeEventListener('keydown', onKeyDown);
started = false;
}
+48
View File
@@ -0,0 +1,48 @@
import { afterEach, describe, expect, test } from 'vitest';
import { isInInteractiveContext } from './focus-utils';
afterEach(() => {
document.body.innerHTML = '';
});
/** Append `el` to the document, focus it, and return it. */
function mountAndFocus<T extends HTMLElement>(el: T): T {
document.body.appendChild(el);
el.focus();
return el;
}
describe('isInInteractiveContext', () => {
test('false when nothing is focused', () => {
expect(isInInteractiveContext()).toBe(false);
});
test('true for a focused <input>', () => {
mountAndFocus(document.createElement('input'));
expect(isInInteractiveContext()).toBe(true);
});
test('true for a focused contenteditable element', () => {
const div = document.createElement('div');
div.contentEditable = 'true';
mountAndFocus(div);
expect(isInInteractiveContext()).toBe(true);
});
test('true when focus is inside the Monaco editor', () => {
const editor = document.createElement('div');
editor.className = 'monaco-editor';
const textarea = document.createElement('textarea');
textarea.className = 'inputarea';
editor.appendChild(textarea);
document.body.appendChild(editor);
// Focus an inner node: the .monaco-editor ancestor must still count.
textarea.focus();
expect(isInInteractiveContext()).toBe(true);
});
test('false for a focused <button>', () => {
mountAndFocus(document.createElement('button'));
expect(isInInteractiveContext()).toBe(false);
});
});
+38
View File
@@ -0,0 +1,38 @@
/**
* Single-source "is the user typing in an editable surface?" helper
* (docs/architecture/04 §2.2).
*
* App layer (browser APIs allowed): reads `document.activeElement`. Every global
* shortcut path and the paste-to-import handler call this so they never fire
* while the user is typing in an input or the Monaco editor. Do not inline these
* element-type checks at call sites — one place to get it right, one to fix.
*
* Monaco difference (important): Astrolabe's spec editor is Monaco, not
* CodeMirror. Monaco renders into a `.monaco-editor` container and keeps focus on
* a hidden `<textarea class="inputarea">` inside it. We detect via the
* `.monaco-editor` ancestor — never a `.cm-editor` / `.cm-content` CodeMirror
* selector, which would silently fail and let shortcuts fire mid-edit.
*/
/**
* True when focus is in an editable surface where global shortcuts and
* paste-to-import must be suppressed: `<input>`, `<textarea>`, `<select>`,
* contenteditable, or the Monaco editor.
*
* This is the SINGLE source of truth — do not inline these checks elsewhere.
*/
export function isInInteractiveContext(): boolean {
const el = document.activeElement as HTMLElement | null;
if (!el) return false;
const tag = el.tagName.toLowerCase();
if (tag === 'input' || tag === 'textarea' || tag === 'select') return true;
if (el.isContentEditable) return true;
// Monaco renders into a .monaco-editor container; its focused element is a
// hidden <textarea class="inputarea"> (already caught above) but guard the
// container explicitly so focus on any inner node still counts.
if (el.closest?.('.monaco-editor')) return true;
return false;
}
+4
View File
@@ -18,6 +18,7 @@ import { useDatasetStore } from '../stores/DatasetStore';
import { wirePersistence } from './persistence'; import { wirePersistence } from './persistence';
import { wireDatasetPersistence } from './dataset-persistence'; import { wireDatasetPersistence } from './dataset-persistence';
import { startRouting } from '../modals/UrlStateSync'; import { startRouting } from '../modals/UrlStateSync';
import { startEventRouter } from './EventRouter';
let started = false; let started = false;
@@ -72,4 +73,7 @@ export async function initApp(): Promise<void> {
// Routing starts AFTER hydrate so the on-load hash restore can resolve snippet // Routing starts AFTER hydrate so the on-load hash restore can resolve snippet
// / dataset ids against the loaded stores (spec §01E, docs/architecture/04). // / dataset ids against the loaded stores (spec §01E, docs/architecture/04).
startRouting(); startRouting();
// Global keyboard shortcuts (spec §01D). Order after routing so a shortcut that
// navigates (new snippet, toggle Datasets) writes through a started router.
startEventRouter();
} }
+35
View File
@@ -0,0 +1,35 @@
import { beforeEach, describe, expect, test } from 'vitest';
import { createSnippet } from '@core/snippet';
import { useNotificationStore } from '../stores/NotificationStore';
import { useSnippetStore } from '../stores/SnippetStore';
import { publishActiveSnippet } from './snippet-actions';
const T = new Date('2026-06-01T00:00:00Z');
const snippets = () => useSnippetStore.getState();
const toasts = () => useNotificationStore.getState().notifications;
beforeEach(() => {
useSnippetStore.getState().reset();
useNotificationStore.getState().clear();
});
describe('publishActiveSnippet', () => {
test('publishes the active draft and raises one success toast', () => {
snippets().hydrate([createSnippet({ id: 'a', now: T })], 'a');
snippets().updateDraft('{"published":1}');
publishActiveSnippet();
expect(snippets().snippets.find((s) => s.id === 'a')?.spec).toBe('{"published":1}');
expect(toasts()).toHaveLength(1);
expect(toasts()[0]).toMatchObject({ kind: 'success', title: 'Snippet published' });
});
test('is a silent no-op when no snippet is active', () => {
snippets().hydrate([], null);
publishActiveSnippet();
expect(toasts()).toHaveLength(0);
});
});
+28
View File
@@ -0,0 +1,28 @@
/**
* Snippet action helpers that pair a store mutation with its user-facing
* confirmation, so every trigger of an action behaves identically.
*
* `publishActiveSnippet` is the single publish-with-confirmation path: the
* Publish button (SpecEditor) and the Cmd/Ctrl+S shortcut (EventRouter) both go
* through it, so the success toast (spec §03D) fires no matter how the user
* published. Without this, Cmd+S published silently while the button toasted — a
* trigger-dependent inconsistency.
*/
import { notify } from '../stores/NotificationStore';
import { useSnippetStore } from '../stores/SnippetStore';
/**
* Publish the active snippet's draft and confirm it. No-op (no toast) when no
* snippet is active. The toast copy follows the council's rule (architecture 10
* → Toast copy): the title states the action, the message adds the consequence.
*/
export function publishActiveSnippet(): void {
if (!useSnippetStore.getState().activeSnippetId) return;
useSnippetStore.getState().publish();
notify({
kind: 'success',
title: 'Snippet published',
message: 'Your draft is now the published version.',
});
}