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
+502
View File
@@ -0,0 +1,502 @@
# 03 · Modal System
How Astrolabe manages its modals: a single metadata-driven registry, a thin
lifecycle coordinator, and one rendering shell. This document is the
authoritative architecture for adding, opening, closing, and rendering modals.
## Goals
- **One source of truth** for modal metadata — no `switch` statements scattered
across the codebase keyed on the active modal.
- **At most one modal open at a time** (mandated by the product spec). Opening a
modal closes any other; the two never overlap.
- **Uniform dismissal**: close button, `Escape`, or backdrop click — never a
click inside the body.
- **Accessible by default**: focus moves into the modal on open and returns to
the trigger on close.
- **Unsaved-change safety** for editing modals, with an explicit opt-out for
modals that apply changes immediately.
The system is three layers, each with a single responsibility:
| Layer | Responsibility | Lives in |
|-------|----------------|----------|
| **Registry** | Static metadata per modal (title, validity, snapshot, init) | `src/app/modals/modal-registry.ts` |
| **Coordinator** | Lifecycle: open, close, URL sync, change detection | `src/app/modals/ModalCoordinator.ts` |
| **Shell** | Render exactly one modal; backdrop / Escape / focus trap | `src/app/App.tsx` + a `useFocusTrap` hook |
---
## The Modal Set
Astrolabe has a small, fixed set of modals. Model it as a closed union so the
registry, coordinator, and shell are exhaustively type-checked.
```ts
// src/app/modals/types.ts
export type ModalName =
| 'datasets' // Datasets manager (list / detail / new-dataset form)
| 'settings' // Appearance, editor, performance, formatting prefs
| 'about' // About & Help (shortcuts, privacy)
| 'donate' // Donate
| 'chartBuilder' // Visual no-JSON chart composition for a dataset
| 'extract'; // Extract inline spec data into a new dataset
export type ActiveModal = ModalName | null;
```
Two of these — `chartBuilder` and `extract` — are **opened from within
workflows** (the Datasets manager and the snippet editor), not from the header
toolbar. That is a UI wiring detail, not a structural one: every modal opens
through the same coordinator regardless of where the trigger lives.
---
## Layer 1 — Registry
Each modal is registered once with its metadata. The registry is a plain lookup
object keyed by `ModalName`; order is irrelevant. Utility queries
(title, validity, whether a modal participates in URL state) read from the
registry so there is exactly one place to change when behavior shifts.
### Config shape
```ts
// src/app/modals/modal-registry.ts
import type { ComponentType } from 'react';
import type { ModalName } from './types';
export interface ModalConfig {
name: ModalName;
title: string; // i18n key or literal
component: ComponentType<any>; // the body rendered inside the shell
/** Initialize transient modal state when it opens. `arg` carries an
* optional sub-target (e.g. a dataset id for chartBuilder/extract). */
init?: (arg?: string) => void;
/** Serializable snapshot of in-progress edits, used to detect unsaved
* changes on close. OMIT for modals that apply immediately (settings,
* about, donate) — omission opts out of the discard-confirmation. */
getState?: () => Record<string, unknown> | null;
/** Whether the modal's primary action (Save / Apply) should be blocked
* because the current input is invalid. Drives the disabled button. */
hasError?: () => boolean;
/** Human-readable reason for the disabled action, shown as a tooltip. */
getError?: () => string | null;
/** Whether this modal is reflected in the URL hash (back/forward, reload
* restore). Datasets and Chart Builder are navigable; Donate is not. */
isUrlNavigable?: boolean;
}
```
### Example entries
```ts
import { DatasetsModal } from '../components/DatasetsModal';
import { SettingsModal } from '../components/SettingsModal';
import { ChartBuilderModal } from '../components/ChartBuilderModal';
import { ExtractModal } from '../components/ExtractModal';
import { DonateModal } from '../components/DonateModal';
import { AboutModal } from '../components/AboutModal';
import { useDatasetStore } from '../stores/DatasetStore';
import { useChartBuilderStore } from '../stores/ChartBuilderStore';
import { useExtractStore } from '../stores/ExtractStore';
import { useSettingsStore } from '../stores/SettingsStore';
// Per-modal transient state lives in the relevant feature store; the registry
// reads it via `getState()` (Zustand), never through component hooks.
export const MODAL_REGISTRY: Record<ModalName, ModalConfig> = {
// Navigable, editing modal — snapshot guards unsaved work.
datasets: {
name: 'datasets',
title: 'modals.datasets.title',
component: DatasetsModal,
isUrlNavigable: true,
init: (datasetId) => useDatasetStore.getState().select(datasetId ?? null),
getState: () => {
const s = useDatasetStore.getState();
return {
view: s.view, // 'list' | 'detail' | 'new'
draft: s.draftForm, // in-progress new/edit form
};
},
hasError: () => useDatasetStore.getState().formError !== null,
getError: () => useDatasetStore.getState().formError,
},
// Opened from a workflow (a specific dataset), navigable, editing.
chartBuilder: {
name: 'chartBuilder',
title: 'modals.chartBuilder.title',
component: ChartBuilderModal,
isUrlNavigable: true,
init: (datasetId) => useChartBuilderStore.getState().initFor(datasetId),
getState: () => ({ encoding: useChartBuilderStore.getState().encoding }),
hasError: () => !useChartBuilderStore.getState().markType,
getError: () =>
useChartBuilderStore.getState().markType ? null : 'modals.chartBuilder.pickMark',
},
// Opened from the snippet editor with the inline data to lift out.
extract: {
name: 'extract',
title: 'modals.extract.title',
component: ExtractModal,
init: (sourceKey) => useExtractStore.getState().initFrom(sourceKey),
getState: () => ({ name: useExtractStore.getState().name }),
hasError: () => useExtractStore.getState().name.trim() === '',
getError: () =>
useExtractStore.getState().name.trim() ? null : 'modals.extract.nameRequired',
},
// Applies immediately — no getState, so closing never prompts.
settings: { name: 'settings', title: 'modals.settings.title', component: SettingsModal, isUrlNavigable: true, init: () => useSettingsStore.getState().loadFromPrefs() },
// Pure info modals — no state, no validity, not navigable for donate.
about: { name: 'about', title: 'modals.about.title', component: AboutModal, isUrlNavigable: true },
donate: { name: 'donate', title: 'modals.donate.title', component: DonateModal },
};
```
### Registry queries
All callers go through these helpers instead of inspecting the active modal
directly:
```ts
export const getModalConfig = (name: ActiveModal): ModalConfig | undefined =>
name ? MODAL_REGISTRY[name] : undefined;
export const getModalTitle = (name: ActiveModal): string =>
getModalConfig(name)?.title ?? '';
export const isUrlNavigable = (name: ActiveModal): boolean =>
getModalConfig(name)?.isUrlNavigable ?? false;
```
> **Why metadata-driven?** The alternative — branching on the active modal in
> the shell, the URL sync, the keyboard handler, and the close logic — spreads
> one decision across four files. Each new modal then means four edits and a
> chance to forget one. With the registry, a new modal is one entry plus its
> component.
**Do**
- Add a modal by appending one `MODAL_REGISTRY` entry and writing its component.
- Express validity through `hasError` / `getError` so the shell's action button
and tooltip stay generic.
- Omit `getState` for any modal that commits changes immediately.
**Don't**
- Don't `switch (activeModal)` outside the shell's body render. Lookups belong
in registry helpers.
- Don't put rendering or DOM concerns in the registry — it is pure metadata.
- Don't read rapidly-changing input state inside `hasError`/`getState` from
component render paths; compute them with a selector at the shell boundary (see
Shell layer) so a keystroke doesn't re-render the whole app.
---
## Layer 2 — Coordinator
The coordinator owns the modal lifecycle. It mutates a single piece of state —
the active modal name — plus a snapshot used for change detection, and keeps the
URL in sync. It is framework-light: pure functions over a Zustand store, unit
testable without a DOM.
### State
The active modal name is **cross-cutting UI chrome**, so it lives on the central
`useAppStore` (`activeModal` + the `setActiveModal` primitive — see
docs/architecture/01). The coordinator never gets its own store; the only extra
piece of state it needs is the change-detection **snapshot**, which is
coordinator-internal (no component reads it), so it stays as a module-local
variable rather than store state.
```ts
// useAppStore already exposes:
// activeModal: ModalName | null
// setActiveModal: (modal: ModalName | null) => void
```
### Open / close
```ts
// src/app/modals/ModalCoordinator.ts
import { useAppStore } from '../stores/AppStore';
import { MODAL_REGISTRY, getModalConfig } from './modal-registry';
import { syncModalToUrl, clearModalFromUrl } from './UrlStateSync';
let confirmDiscard: (msg: string) => Promise<boolean> = async () => true;
export const setConfirm = (fn: typeof confirmDiscard) => { confirmDiscard = fn; };
// Coordinator-internal: the getState() JSON captured at open, compared on close.
let stateSnapshot: string | null = null;
const snapshot = (name: ActiveModal) =>
getModalConfig(name)?.getState
? JSON.stringify(getModalConfig(name)!.getState!())
: null;
/** Open `name`, optionally with a sub-target (dataset id, source key). */
export function openModal(name: ModalName, arg?: string): void {
// Opening any modal replaces the previous one — at most one open at a time.
useAppStore.getState().setActiveModal(name);
getModalConfig(name)?.init?.(arg);
stateSnapshot = snapshot(name);
syncModalToUrl(name, arg); // no-op when !isUrlNavigable
}
/** Close the active modal. Prompts on unsaved changes unless `force`. */
export async function closeModal(force = false): Promise<void> {
const name = useAppStore.getState().activeModal;
if (!name) return;
if (!force && hasUnsavedChanges()) {
const ok = await confirmDiscard('modals.discardChanges');
if (!ok) return;
}
clearModalFromUrl(name);
useAppStore.getState().setActiveModal(null);
stateSnapshot = null;
getModalConfig(name)?.init?.(undefined); // optional: reset transient state
}
/** Cmd/Ctrl+K toggle for the Datasets manager. */
export function toggleDatasets(): void {
if (useAppStore.getState().activeModal === 'datasets') void closeModal();
else openModal('datasets');
}
```
### Change detection
```ts
export function hasUnsavedChanges(): boolean {
const name = useAppStore.getState().activeModal;
if (!name || stateSnapshot === null) return false; // no snapshot ⇒ opted out
const current = getModalConfig(name)?.getState?.();
if (current == null) return false;
return JSON.stringify(current) !== stateSnapshot;
}
```
The snapshot is taken once on open and compared on close. Modals without
`getState` (settings, about, donate) snapshot to `null`, so `hasUnsavedChanges`
short-circuits and they close instantly — correct, because they either apply
immediately or hold nothing to lose.
### Validity passthrough
```ts
export const activeModalHasError = (): boolean =>
getModalConfig(useAppStore.getState().activeModal)?.hasError?.() ?? false;
export const activeModalError = (): string | null =>
getModalConfig(useAppStore.getState().activeModal)?.getError?.() ?? null;
```
> **Why a coordinator instead of letting components open/close themselves?**
> Centralizing means the "close the previous one", snapshot, URL-sync, and
> discard-prompt rules are enforced once. A component that opened a peer modal
> directly could bypass the discard check or leave the URL stale.
**Do**
- Route every open/close through `openModal` / `closeModal`.
- Take the snapshot in `openModal` (after `init`) and compare in `closeModal`.
- Keep the coordinator DOM-free so it can be tested with plain Vitest.
**Don't**
- Don't mutate `activeModal` directly from components or handlers.
- Don't skip `closeModal`'s unsaved-change check by toggling state manually;
pass `force` only when the user has explicitly saved or confirmed.
---
## Layer 3 — Shell
`App` renders **exactly one** modal — whichever `activeModal` names — inside a
single reusable shell. The shell provides the backdrop, header, focus trap, and
the generic close/action affordances; the modal's registered `component` fills
the body.
```tsx
// src/app/App.tsx (modal portion)
import { useAppStore } from '../stores/AppStore';
import { getModalConfig, getModalTitle } from '../modals/modal-registry';
import { closeModal, activeModalHasError, activeModalError } from '../modals/ModalCoordinator';
import { useFocusTrap } from '../hooks/useFocusTrap';
export function App() {
const name = useAppStore((s) => s.activeModal);
const config = getModalConfig(name);
// Move focus into the modal on open, return it to the trigger on close.
const modalRef = useFocusTrap<HTMLDivElement>(name !== null);
// Derived at the shell boundary so per-keystroke store reads don't
// re-render the whole app tree.
const hasError = activeModalHasError();
const errorMsg = activeModalError();
return (
<div className={styles.app}>
{/* ...library · editor · preview panes, header... */}
{config && (
<div
className={styles.backdrop}
onClick={() => void closeModal()} // backdrop dismisses
onKeyDown={(e) => { if (e.key === 'Escape') void closeModal(); }}
>
<div
ref={modalRef}
className={styles.modal}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
onClick={(e) => e.stopPropagation()} // inside body never dismisses
>
<header className={styles.modalHeader}>
<h2 id="modal-title">{t(getModalTitle(name))}</h2>
<button aria-label={t('buttons.close')} onClick={() => void closeModal()}>×</button>
</header>
<div className={styles.modalBody}>
{/* The ONE place the active modal is mapped to a component. */}
<config.component />
</div>
{/* Optional generic action row for editing modals. A modal with no
primary action (about, donate) can render its own footer/none. */}
{config.getState && (
<footer className={styles.modalFooter}>
<button className="btn-secondary" onClick={() => void closeModal()}>
{t('buttons.cancel')}
</button>
<button
className="btn-primary"
aria-disabled={hasError || undefined}
title={errorMsg ? t(errorMsg) : undefined}
onClick={() => { if (!hasError) config.component /* invoke save handler */; }}
>
{t('buttons.save')}
</button>
</footer>
)}
</div>
</div>
)}
</div>
);
}
```
Rendering `<config.component />` from the registry is the only modal-name→view
mapping in the app. There is no `name === 'datasets' && <DatasetsModal/>` chain.
### Focus trap
A small hook saves the previously focused element, focuses the first focusable
child on open, wraps `Tab`/`Shift+Tab` within the modal, and restores focus on
close.
```ts
// src/app/hooks/useFocusTrap.ts
import { useRef, useEffect } from 'react';
const FOCUSABLE =
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), ' +
'textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
export function useFocusTrap<T extends HTMLElement = HTMLDivElement>(active: boolean) {
const ref = useRef<T>(null);
const returnTo = useRef<Element | null>(null);
useEffect(() => {
const el = ref.current;
if (!active || !el) return;
returnTo.current = document.activeElement;
el.querySelector<HTMLElement>(FOCUSABLE)?.focus();
const onKey = (e: KeyboardEvent) => {
if (e.key !== 'Tab') return;
const f = el.querySelectorAll<HTMLElement>(FOCUSABLE);
if (!f.length) return;
const first = f[0], last = f[f.length - 1];
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
};
el.addEventListener('keydown', onKey);
return () => {
el.removeEventListener('keydown', onKey);
(returnTo.current as HTMLElement | null)?.focus(); // restore focus on close
};
}, [active]);
return ref;
}
```
> **Why one shell instead of each modal rendering its own chrome?** Backdrop
> behavior, the focus trap, `aria-modal`, Escape handling, and the close button
> are identical for every modal and easy to get subtly wrong (e.g. a backdrop
> that dismisses on inner clicks). Centralizing guarantees consistency and means
> accessibility is fixed once.
**Do**
- Render the active modal via `<config.component />` — the single mapping point.
- Put `onClick={closeModal}` on the backdrop and `stopPropagation` on the body.
- Compute `hasError`/`getError`/preview reads with a selector at the shell level.
- Gate the generic Save button on `hasError` and surface `getError` as its
tooltip.
**Don't**
- Don't render two modals simultaneously, and don't stack a second backdrop.
- Don't attach the focus trap to the backdrop — attach it to the modal body so
the backdrop click stays outside the trap.
- Don't dismiss on clicks inside the body, and don't let Escape fire when no
modal is open (the handler only exists while a modal renders).
---
## URL & Keyboard Integration
The coordinator is the join point for navigation:
- `openModal` calls `syncModalToUrl`; navigable modals write a hash
(`#datasets`, `#datasets/dataset-<id>`, `#datasets/dataset-<id>/build`,
`#settings`). Non-navigable modals (donate) write nothing.
- `closeModal` calls `clearModalFromUrl`, returning to the underlying workspace
hash.
- On load, the URL restorer reads the hash and calls `openModal(name, arg)` to
rehydrate the right modal and sub-target.
- The global key handler maps `Cmd/Ctrl+K``toggleDatasets()`,
`Cmd/Ctrl+,``openModal('settings')`, and `Escape``closeModal()` (the
Escape binding is a no-op when `activeModal` is `null`).
Because all of these call the same coordinator functions, browser
Back/Forward, keyboard shortcuts, and in-app triggers stay consistent — they
share the open/close/snapshot/URL logic rather than reimplementing it.
---
## Adding a Modal: Checklist
1. Add the name to the `ModalName` union.
2. Add one `MODAL_REGISTRY` entry (title, component; `getState`/`hasError`/
`getError` if it edits; `isUrlNavigable` + `init(arg)` if navigable).
3. Write the body component; it reads/writes its feature store (e.g.
`useDatasetStore`, `useChartBuilderStore`) via a narrow selector.
4. If navigable, add its hash form to the URL sync and restore logic.
5. If it has a keyboard shortcut or workflow trigger, wire that to
`openModal(name, arg)` — never to `activeModal` directly.
No edits to the shell render, the close logic, or the change-detection code are
needed: those are generic and driven entirely by the registry.