Initial scaffold: spec, architecture playbook, and M0 skeleton

This commit is contained in:
2026-06-04 22:14:33 +03:00
commit 056644450c
51 changed files with 13754 additions and 0 deletions
+463
View File
@@ -0,0 +1,463 @@
# 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` |
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.
### 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
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] };
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`;
}
}
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 — call `navigate.*`.
- Don't `pushState` on load-restore (pollutes Back history).
- Don't throw on an unrecognized hash; degrade to the default view.
---
## 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;
// --- 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 + S -> publish current draft
if (mod && e.key.toLowerCase() === 's') {
e.preventDefault(); // override the browser "save page" dialog
useSnippetStore.getState().publishDraft();
return;
}
// Cmd/Ctrl + , -> settings (through the coordinator: snapshot + URL sync)
if (mod && e.key === ',') {
e.preventDefault();
openModal('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 is checked before the interactive-context gate**
(you want Escape to dismiss a modal even while focus is in the editor), whereas
all other shortcuts are checked **after** the gate (so they don't fire mid-typing).
### 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.