# 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-<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.

### 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-<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 `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-<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()`.

```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 `<textarea class="inputarea">` inside it. The detector must match
> Monaco's DOM — a `.monaco-editor` ancestor (and/or the inputarea) — **not** a
> `.cm-editor` / `.cm-content` selector. If you copy a CodeMirror check here it
> will silently fail and global shortcuts will fire while the user edits a spec.

```ts
// 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`/`document` keydown 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:

```ts
// 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 every
  `ViewState`, 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>`, a `contenteditable` div, and a `<div class="monaco-editor"><textarea/></div>`;
  focus each and assert `true`; assert `false` for 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'`, call `startUrlStateSync()`, assert the
  view fell back to default and the hash was cleaned.
