21 KiB
04 · Routing & Global Events
Two small, related subsystems govern how the app talks to the browser shell:
- URL hash as view-state — the current view (selected snippet, open dataset
modal, etc.) lives in
location.hash. It is read on load to restore state, written on navigation, and Back/Forward step between prior states. Result: every meaningful view is shareable, bookmarkable, and reload-safe. - Global event / keyboard routing — a single router owns the
document-level
keydown/paste/clicklisteners. It runs an Escape priority chain, dispatches shortcuts, and consults a singleisInInteractiveContext()helper so global shortcuts and paste handlers never fire while the user is typing in an input or the Monaco editor.
Both are layered the same way:
src/app/infrastructure/url-hash.ts adapter: owns window.location & history
src/app/orchestration/UrlStateSync.ts mediator: hash <-> Zustand stores
src/app/orchestration/EventRouter.ts mediator: DOM events -> store actions
src/app/orchestration/focus-utils.ts single-source isInInteractiveContext()
Infrastructure modules touch browser globals; orchestration modules touch the
Zustand stores. Components never read location.hash or attach
window.addEventListener themselves — they go through these mediators.
1. URL Hash as View-State
1.1 The hash grammar
The hash is the serialized view. Astrolabe's forms:
| State | Hash |
|---|---|
| Default snippets view | (empty / absent) |
| A selected snippet | #snippet-<id> |
| Datasets manager (list) | #datasets |
| A specific dataset | #datasets/dataset-<id> |
| New-dataset form | #datasets/new |
| Chart Builder for a dataset | #datasets/dataset-<id>/build |
| Chart Builder, no dataset open | #build |
Snippet id is an opaque string; dataset id is the numeric dataset id
rendered as a decimal string. The hash is the only persisted view-routing
state — there is no in-memory "current route" that can drift from it. #build
serializes the builder's no-datasets state only: an un-targeted builder open
picks a dataset itself when any exist, so the derived view immediately
self-corrects to the dataset-build form.
One-shot action links are not view states. A hash form that requests an
action — #example-<id> (add that gallery example) and #spec-<payload> (add
the spec carried in the payload; @core/spec-link owns the base64url encoding,
shared with the learn pages that build such links) — stays out of the
ViewState union: each is parsed by its own function in url-hash.ts,
consumed once at startup (orchestration/startup.ts, after persistence wiring
so the created record write-throughs, before startRouting), and then
routing's settle step replaces the hash with the resulting view. Action links
never serialize back and never participate in Back/Forward; parseHash
degrades them like any unknown hash. Any future action link follows the same
shape rather than growing the view-state union.
1.2 The adapter: infrastructure/url-hash.ts
This is the only file that reads or writes window.location / history. It
exposes a parse function (hash string → typed ViewState), a serialize
function (ViewState → hash string), and write helpers. Keep it pure-ish:
parsing is a total function with no side effects; writing is the only place
history.replaceState is called.
// src/app/infrastructure/url-hash.ts
export type ViewState =
| { kind: 'snippets' } // empty hash
| { kind: 'snippet'; snippetId: string } // #snippet-<id>
| { kind: 'datasets' } // #datasets
| { kind: 'dataset'; datasetId: number } // #datasets/dataset-<id>
| { kind: 'dataset-new' } // #datasets/new
| { kind: 'dataset-build'; datasetId: number } // .../build
| { kind: 'build' }; // #build — builder, no dataset loaded
export function parseHash(rawHash: string): ViewState {
const hash = rawHash.replace(/^#/, '');
if (hash === '') return { kind: 'snippets' };
const snippet = /^snippet-(.+)$/.exec(hash);
if (snippet) return { kind: 'snippet', snippetId: snippet[1] };
if (hash === 'build') return { kind: 'build' };
const parts = hash.split('/').filter(Boolean);
if (parts[0] === 'datasets') {
if (parts.length === 1) return { kind: 'datasets' };
if (parts[1] === 'new') return { kind: 'dataset-new' };
const m = /^dataset-(\d+)$/.exec(parts[1]);
if (m) {
const id = Number(m[1]);
if (parts[2] === 'build') return { kind: 'dataset-build', datasetId: id };
return { kind: 'dataset', datasetId: id };
}
}
// Unknown hash -> fall back to default rather than throwing.
return { kind: 'snippets' };
}
export function serializeHash(view: ViewState): string {
switch (view.kind) {
case 'snippets':
return '';
case 'snippet':
return `#snippet-${view.snippetId}`;
case 'datasets':
return '#datasets';
case 'dataset':
return `#datasets/dataset-${view.datasetId}`;
case 'dataset-new':
return '#datasets/new';
case 'dataset-build':
return `#datasets/dataset-${view.datasetId}/build`;
case 'build':
return '#build';
}
}
export function readView(): ViewState {
return parseHash(window.location.hash);
}
/** Write without adding a history entry (in-place correction, restore). */
export function replaceView(view: ViewState): void {
const url = new URL(window.location.href);
url.hash = serializeHash(view);
url.search = '';
window.history.replaceState({}, '', url.toString());
}
/** Write and add a history entry (user navigation -> Back works). */
export function pushView(view: ViewState): void {
const url = new URL(window.location.href);
url.hash = serializeHash(view);
url.search = '';
window.history.pushState({}, '', url.toString());
}
pushState vs replaceState is the lever that makes Back/Forward feel
right. Use pushView for deliberate user navigation (selecting a snippet,
opening a dataset) so each becomes a Back-able step. Use replaceView for
restoring on load and for correcting a stale/invalid hash, where you do not want
to litter history.
1.3 The mediator: orchestration/UrlStateSync.ts
UrlStateSync is the bridge between the hash and the Zustand stores. It does
three jobs:
- On load — restore: read the view, validate referenced ids against the
stores, and drive the stores to match. If an id no longer exists, fall back
to the default view and
replaceViewto clean the URL. - Hash → state (Back/Forward): listen for
hashchangeand reconcile the stores to the new view. This is what makes the browser buttons work. - State → hash: expose typed
navigate*helpers the rest of the app calls when the user moves around. ThesepushView(orreplaceView).
Guard against feedback loops: writing the hash fires no hashchange when you
use the History API the way above, but a defensive applying flag keeps the
hashchange reconciler from re-triggering navigation it just caused.
// src/app/orchestration/UrlStateSync.ts
import { useSnippetStore } from '../stores/SnippetStore';
import { useDatasetStore } from '../stores/DatasetStore';
import { useAppStore } from '../stores/AppStore'; // activeModal, etc.
import { readView, replaceView, pushView, type ViewState } from '../infrastructure/url-hash';
let applying = false; // suppress re-entrancy while we drive the stores
let started = false;
// Restore/reconcile is the one path that writes `activeModal` with the bare
// `setActiveModal` primitive instead of the coordinator's openModal/closeModal:
// we are reflecting the URL *into* the stores, so we must NOT re-sync the URL or
// run the unsaved-change discard prompt (the `applying` guard blocks re-entrancy).
/** Make the stores reflect `view`. Falls back + cleans URL on dead ids. */
function applyView(view: ViewState): void {
applying = true;
try {
switch (view.kind) {
case 'snippets':
useAppStore.getState().setActiveModal(null);
return;
case 'snippet': {
const snippet = useSnippetStore.getState().byId(view.snippetId);
if (!snippet) {
replaceView({ kind: 'snippets' });
return;
}
useAppStore.getState().setActiveModal(null);
useSnippetStore.getState().select(view.snippetId);
return;
}
case 'datasets':
useAppStore.getState().setActiveModal('datasets');
return;
case 'dataset':
case 'dataset-build': {
const ds = useDatasetStore.getState().byId(view.datasetId);
if (!ds) {
replaceView({ kind: 'datasets' });
return;
}
useAppStore.getState().setActiveModal('datasets');
useDatasetStore.getState().select(view.datasetId);
if (view.kind === 'dataset-build') useAppStore.getState().setActiveModal('chartBuilder');
return;
}
case 'dataset-new':
useAppStore.getState().setActiveModal('datasets');
useDatasetStore.getState().beginNew();
return;
}
} finally {
applying = false;
}
}
export function startUrlStateSync(): void {
if (started) return;
started = true;
// 1. Restore from the URL on load.
applyView(readView());
// 2. Back/Forward -> reconcile stores.
window.addEventListener('hashchange', () => {
if (applying) return;
applyView(readView());
});
// 3. State -> hash. A store subscription keeps the URL honest if any code path
// changes the active view without calling a navigate* helper. Optional;
// explicit navigate* calls are the primary writer.
useAppStore.subscribe((state, prev) => {
if (applying) return;
// derive ViewState from state and replaceView(...) here if desired
});
}
// --- State -> hash: the API the app calls on user navigation -------------
export const navigate = {
toSnippet: (id: string) => pushView({ kind: 'snippet', snippetId: id }),
toSnippets: () => pushView({ kind: 'snippets' }),
toDatasets: () => pushView({ kind: 'datasets' }),
toDataset: (id: number) => pushView({ kind: 'dataset', datasetId: id }),
toNewDataset: () => pushView({ kind: 'dataset-new' }),
toChartBuilder: (id: number) => pushView({ kind: 'dataset-build', datasetId: id }),
};
Do
- Restore on load with
replaceView; navigate at runtime withpushView. - Validate every id from the hash against the stores; fall back + clean URL on a miss (deleted/shared-stale ids are normal, not exceptional).
- Keep
parseHash/serializeHashpure and round-trippable — unit-test thatparseHash(serializeHash(v)) === vfor everyViewState.
Don't
- Don't read or write
location.hashfrom components. - Don't
pushStateon load-restore (pollutes Back history). - Don't throw on an unrecognized hash; degrade to the default view.
Shipped (M6) — how the implementation refines this sketch
The routing mediator lives in modals/UrlStateSync.ts, not a new
orchestration/UrlStateSync.ts: that file was already the coordinator's modal↔URL
seam (syncModalToUrl / clearModalFromUrl), so M6 grew it into the whole router
rather than splitting routing across two modules. It must not import the
ModalCoordinator (cycle); restore drives useAppStore.setActiveModal directly, as
this sketch's applyView already does.
state → hash is derived from the stores, not pushed by navigate.* calls. A
deriveViewState() reads activeModal + DatasetStore.view/selectedId +
ChartBuilderStore.datasetId + activeSnippetId; a subscription on each of those
stores calls pushView when the derived view differs from the URL. Components never
call a navigate helper — they just mutate stores (select a snippet, open a dataset),
and the subscriber reflects it. This is required, not stylistic: the in-modal
dataset sub-views (#datasets/new, #datasets/dataset-<id>) are DatasetStore view
changes, not modal-open events, so only a derive-from-state writer captures them. Only
modals flagged isUrlNavigable in the registry own the hash; a non-navigable modal
(extract / about / donate) leaves the underlying snippet view in the URL. On load,
after restore, the active view is reflected with replaceView (not pushView) so
there's no dead Back step. startRouting() runs in startup.ts after store hydrate.
2. Global Event / Keyboard Routing
2.1 The router: orchestration/EventRouter.ts
One module binds the document-level listeners (keydown, paste, click) and
routes them. Centralizing this keeps ordering explicit and gives one place to
reason about priority. The router owns two things in particular:
- the Escape priority chain, and
- shortcut dispatch, gated by
isInInteractiveContext().
// src/app/orchestration/EventRouter.ts
import { useAppStore } from '../stores/AppStore';
import { useSnippetStore } from '../stores/SnippetStore';
import { navigate } from './UrlStateSync';
import { openModal, closeModal, toggleDatasets } from '../modals/ModalCoordinator';
import { isInInteractiveContext } from './focus-utils';
let started = false;
export function startEventRouter(): void {
if (started) return;
started = true;
window.addEventListener('keydown', onKeyDown);
window.addEventListener('paste', onPaste);
}
export function stopEventRouter(): void {
window.removeEventListener('keydown', onKeyDown);
window.removeEventListener('paste', onPaste);
started = false;
}
const isMac = /Mac|iPhone|iPad|iPod/.test(navigator.platform);
function onKeyDown(e: KeyboardEvent): void {
// --- Escape: highest priority, runs even inside editors/inputs ----------
if (e.key === 'Escape') {
if (handleEscapeChain()) e.preventDefault();
return;
}
const mod = isMac ? e.metaKey : e.ctrlKey;
// 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;
// Cmd/Ctrl + Shift + N -> new snippet
if (mod && e.shiftKey && e.key.toLowerCase() === 'n') {
e.preventDefault();
const created = useSnippetStore.getState().create();
navigate.toSnippet(created.id);
return;
}
// Cmd/Ctrl + K -> toggle Datasets manager (coordinator owns open/close + URL)
if (mod && !e.shiftKey && e.key.toLowerCase() === 'k') {
e.preventDefault();
toggleDatasets();
return;
}
// Cmd/Ctrl + , -> open the editor settings popover. Settings are distributed to
// per-pane disclosure popovers, not a modal (spec §07), so this opens the editor
// cluster (openSettingsPopover) — there is no 'settings' modal to open.
if (mod && e.key === ',') {
e.preventDefault();
openSettingsPopover('editor-settings');
return;
}
}
/** Returns true if it consumed the Escape (caller should preventDefault). */
function handleEscapeChain(): boolean {
// 1. Toast/message box would go here if it grew a blocking variant.
// 2. Active modal — 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.
if (useAppStore.getState().activeModal) {
void closeModal();
return true;
}
// 3. Open menu / popover.
if (useAppStore.getState().openMenu) {
useAppStore.getState().setOpenMenu(null);
return true;
}
// 4. Active selection (e.g. selected snippet in the library).
if (useSnippetStore.getState().selectionId) {
useSnippetStore.getState().clearSelection();
return true;
}
return false;
}
function onPaste(e: ClipboardEvent): void {
// Paste-to-import (e.g. paste a Vega-Lite spec) must NOT hijack a paste the
// user makes inside the editor or an input.
if (isInInteractiveContext()) return;
// ... route clipboard text to the import handler ...
}
The Escape chain is an explicit, ordered ladder, top-down. Each rung
returns as soon as it consumes the event, so only the topmost active layer
reacts. Order matters: a blocking message box outranks a modal, a modal
outranks an open menu, a menu outranks a selection. Add new dismissible layers
by inserting a rung at the right priority — never by sprinkling
document.addEventListener('keydown', …Escape…) in a component.
Shortcuts override browser defaults. Each handled combo calls
e.preventDefault() so Cmd/Ctrl+S does not trigger "save page", Cmd/Ctrl+K
does not focus the browser search bar, etc.
Note the asymmetry: Escape and Cmd/Ctrl+S are checked before the interactive-context gate. Escape, so it dismisses a modal even while focus is in 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
There is exactly one function that answers "is the user currently typing in an editable surface?" Every shortcut path and the paste handler call it. Never inline element-type checks — one place to get it right, one place to fix it when the DOM changes.
Monaco difference (important): Astrolabe's spec editor is Monaco, not CodeMirror. Monaco renders into a
.monaco-editorcontainer and keeps focus on a hidden<textarea class="inputarea">inside it. The detector must match Monaco's DOM — a.monaco-editorancestor (and/or the inputarea) — not a.cm-editor/.cm-contentselector. If you copy a CodeMirror check here it will silently fail and global shortcuts will fire while the user edits a spec.
// src/app/orchestration/focus-utils.ts
/**
* 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;
}
Do
- Route all global keyboard/paste/click through
EventRouter; bind listeners in exactly one place, started once at app init. - Express Escape as an ordered chain that returns on first consumption.
- Call
isInInteractiveContext()everywhere a global handler might collide with typing; keep it the only definition. preventDefault()on every shortcut the app claims, so it overrides the browser default.
Don't
- Don't add ad-hoc
window/documentkeydown listeners in components. - Don't inline
tagName === 'textarea'/ editor-class checks at call sites — call the helper. - Don't match a CodeMirror selector for the editor; Astrolabe is Monaco.
- Don't gate Escape behind
isInInteractiveContext()— Escape should still close a modal while the editor has focus.
3. Wiring at startup
Both subsystems start once, after the stores are hydrated from persistence, in the app's init/orchestration step:
// src/app/orchestration/bootstrap.ts (sketch)
import { startUrlStateSync } from './UrlStateSync';
import { startEventRouter } from './EventRouter';
export function initApp(): void {
// ... load settings + hydrate snippet/dataset stores from IndexedDB/localStorage ...
startUrlStateSync(); // restore view from hash, then keep hash <-> stores in sync
startEventRouter(); // bind global keyboard/paste routing
}
Order: hydrate stores first (so hash-restore can resolve ids), then
startUrlStateSync (it reads the hash and may drive the stores), then
startEventRouter. Each start* is idempotent and has a matching stop* for
teardown in tests.
4. Testing notes
parseHash/serializeHash: pure, so test directly. Cover everyViewState, the empty hash, and at least one malformed hash → default. Assert the round-trip identity.isInInteractiveContext: happy-dom test (the project's Vitest env). Mount an<input>, acontenteditablediv, and a<div class="monaco-editor"><textarea/></div>; focus each and asserttrue; assertfalsefor a focused<button>.- Escape chain: with stores in known states, dispatch a synthetic Escape and assert only the top active layer changed.
- Restore-on-load with dead id: seed an empty store, set
location.hash = '#snippet-gone', callstartUrlStateSync(), assert the view fell back to default and the hash was cleaned.