# 04 · Routing & Global Events Two small, related subsystems govern how the app talks to the browser shell: 1. **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. 2. **Global event / keyboard routing** — a single router owns the document-level `keydown` / `paste` / `click` listeners. It runs an Escape priority chain, dispatches shortcuts, and consults a single `isInInteractiveContext()` 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-` | | Datasets manager (list) | `#datasets` | | A specific dataset | `#datasets/dataset-` | | New-dataset form | `#datasets/new` | | Chart Builder for a dataset | `#datasets/dataset-/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. ### 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. ```ts // src/app/infrastructure/url-hash.ts export type ViewState = | { kind: 'snippets' } // empty hash | { kind: 'snippet'; snippetId: string } // #snippet- | { kind: 'datasets' } // #datasets | { kind: 'dataset'; datasetId: number } // #datasets/dataset- | { 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 `replaceView` to clean the URL. - **Hash → state (Back/Forward):** listen for `hashchange` and 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. These `pushView` (or `replaceView`). 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. ```ts // 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 with `pushView`. - 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` / `serializeHash` pure and round-trippable — unit-test that `parseHash(serializeHash(v)) === v` for every `ViewState`. **Don't** - Don't read or write `location.hash` from components. - Don't `pushState` on 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-`) 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()`. ```ts // 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-editor` container and keeps focus > on a hidden `