From 056644450c789cf53cf3184a8775f53402a3ef90 Mon Sep 17 00:00:00 2001 From: Oleh Omelchenko Date: Thu, 4 Jun 2026 22:14:33 +0300 Subject: [PATCH] Initial scaffold: spec, architecture playbook, and M0 skeleton --- .../memory/MEMORY.md | 2 + .../memory/stack-moving-to-react.md | 29 + .../memory/vega-editor-reference-clone.md | 27 + .claude/skills/alignment/SKILL.md | 185 + .claude/skills/doc-update/SKILL.md | 111 + .claude/skills/release/SKILL.md | 94 + .gitignore | 9 + .prettierrc | 6 + AGENTS.md | 107 + CLAUDE.md | 32 + README.md | 33 + SOUL.md | 102 + docs/IMPLEMENTATION-PLAN.md | 288 + docs/WHY-A-SEPARATE-REBUILD.md | 204 + docs/architecture/00-overview.md | 50 + docs/architecture/01-state-and-stores.md | 432 + docs/architecture/02-persistence.md | 401 + docs/architecture/03-modal-system.md | 502 ++ docs/architecture/04-routing-and-events.md | 463 + .../05-rendering-theming-preview.md | 448 + docs/architecture/06-type-inference.md | 346 + .../07-naming-and-relationships.md | 432 + .../architecture/08-vega-editor-techniques.md | 286 + docs/spec/00-product-overview.md | 60 + docs/spec/01-application-shell.md | 115 + docs/spec/02-snippet-library.md | 82 + docs/spec/03-editor-and-drafts.md | 78 + docs/spec/04-live-preview.md | 81 + docs/spec/05-datasets.md | 107 + docs/spec/06-chart-builder.md | 70 + docs/spec/07-settings.md | 79 + docs/spec/08-import-export.md | 83 + docs/spec/09-data-model.md | 107 + docs/spec/10-non-functional.md | 54 + docs/spec/README.md | 31 + index.html | 13 + package-lock.json | 7733 +++++++++++++++++ package.json | 36 + public/favicon.svg | 7 + src/app/App.module.css | 58 + src/app/App.tsx | 33 + src/app/stores/AppStore.ts | 41 + src/core/format-detection.test.ts | 53 + src/core/format-detection.ts | 61 + src/core/theme.ts | 10 + src/main.tsx | 16 + src/vite-env.d.ts | 9 + styles/base.css | 28 + styles/tokens.css | 47 + tsconfig.json | 32 + vite.config.ts | 41 + 51 files changed, 13754 insertions(+) create mode 100644 .claude/projects/-Users-oleh-code-astrolabe/memory/MEMORY.md create mode 100644 .claude/projects/-Users-oleh-code-astrolabe/memory/stack-moving-to-react.md create mode 100644 .claude/projects/-Users-oleh-code-astrolabe/memory/vega-editor-reference-clone.md create mode 100644 .claude/skills/alignment/SKILL.md create mode 100644 .claude/skills/doc-update/SKILL.md create mode 100644 .claude/skills/release/SKILL.md create mode 100644 .gitignore create mode 100644 .prettierrc create mode 100644 AGENTS.md create mode 100644 CLAUDE.md create mode 100644 README.md create mode 100644 SOUL.md create mode 100644 docs/IMPLEMENTATION-PLAN.md create mode 100644 docs/WHY-A-SEPARATE-REBUILD.md create mode 100644 docs/architecture/00-overview.md create mode 100644 docs/architecture/01-state-and-stores.md create mode 100644 docs/architecture/02-persistence.md create mode 100644 docs/architecture/03-modal-system.md create mode 100644 docs/architecture/04-routing-and-events.md create mode 100644 docs/architecture/05-rendering-theming-preview.md create mode 100644 docs/architecture/06-type-inference.md create mode 100644 docs/architecture/07-naming-and-relationships.md create mode 100644 docs/architecture/08-vega-editor-techniques.md create mode 100644 docs/spec/00-product-overview.md create mode 100644 docs/spec/01-application-shell.md create mode 100644 docs/spec/02-snippet-library.md create mode 100644 docs/spec/03-editor-and-drafts.md create mode 100644 docs/spec/04-live-preview.md create mode 100644 docs/spec/05-datasets.md create mode 100644 docs/spec/06-chart-builder.md create mode 100644 docs/spec/07-settings.md create mode 100644 docs/spec/08-import-export.md create mode 100644 docs/spec/09-data-model.md create mode 100644 docs/spec/10-non-functional.md create mode 100644 docs/spec/README.md create mode 100644 index.html create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 public/favicon.svg create mode 100644 src/app/App.module.css create mode 100644 src/app/App.tsx create mode 100644 src/app/stores/AppStore.ts create mode 100644 src/core/format-detection.test.ts create mode 100644 src/core/format-detection.ts create mode 100644 src/core/theme.ts create mode 100644 src/main.tsx create mode 100644 src/vite-env.d.ts create mode 100644 styles/base.css create mode 100644 styles/tokens.css create mode 100644 tsconfig.json create mode 100644 vite.config.ts diff --git a/.claude/projects/-Users-oleh-code-astrolabe/memory/MEMORY.md b/.claude/projects/-Users-oleh-code-astrolabe/memory/MEMORY.md new file mode 100644 index 0000000..afefcd7 --- /dev/null +++ b/.claude/projects/-Users-oleh-code-astrolabe/memory/MEMORY.md @@ -0,0 +1,2 @@ +- [vega-editor reference clone](vega-editor-reference-clone.md) — local vega/editor at /Users/oleh/code/reference/vega-editor for Monaco/render/validation techniques +- [Stack: React + Zustand](stack-moving-to-react.md) — final UI stack is React + Zustand (migrated off Preact + signals at M0); doc sweep done diff --git a/.claude/projects/-Users-oleh-code-astrolabe/memory/stack-moving-to-react.md b/.claude/projects/-Users-oleh-code-astrolabe/memory/stack-moving-to-react.md new file mode 100644 index 0000000..217a1f2 --- /dev/null +++ b/.claude/projects/-Users-oleh-code-astrolabe/memory/stack-moving-to-react.md @@ -0,0 +1,29 @@ +--- +name: stack-moving-to-react +description: Astrolabe's UI stack is React + Zustand (migrated from Preact + signals at M0) +metadata: + type: project +--- + +The UI stack is **React + Zustand** (user decision, 2026-06-03, executed at M0 before feature +work). It started as Preact + `@preact/signals`; a brief intermediate step migrated to React + +`@preact/signals-react`, but the final call is **React + Zustand** — signals were dropped. + +**Why React:** the Preact pain was React-ecosystem friction (real-React-only libraries not +cooperating with `preact/compat`) — *not* the signals model. Real React removes that whole +class of problem and makes borrowing from the reference [[vega-editor-reference-clone]] (a +React app) port directly. + +**Why Zustand over keeping signals:** signals were never the problem, but switching framework +was the one cheap moment (M0, ~nothing implemented) to also pick the lowest-future-migration-risk +state lib. Zustand is idiomatic React, has first-class outside-React access +(`getState`/`setState`/`subscribe`) that fits the "logic lives in core/services, not components" +architecture, and carries no build-time transform. The signals→Zustand cost was only rewriting +unimplemented docs. + +**How to apply:** Stores are `create()` modules exporting a `useXStore` hook (state + +actions in one object); components read via `useXStore(selector)` (+ `useShallow` for object +selections); non-component code uses `getState()/setState()/subscribe()`; derive in selectors, +never store derived fields. See `docs/architecture/01-state-and-stores.md`. The repo-wide +Preact/signals→React/Zustand doc sweep is **done** — no stale "Preact"/"signals" wording should +remain except where it describes the sibling project Syto, Vega's own signals, or plain English. diff --git a/.claude/projects/-Users-oleh-code-astrolabe/memory/vega-editor-reference-clone.md b/.claude/projects/-Users-oleh-code-astrolabe/memory/vega-editor-reference-clone.md new file mode 100644 index 0000000..3025403 --- /dev/null +++ b/.claude/projects/-Users-oleh-code-astrolabe/memory/vega-editor-reference-clone.md @@ -0,0 +1,27 @@ +--- +name: vega-editor-reference-clone +description: Location and nature of the local vega/editor clone used as a technique reference for Astrolabe's Monaco/vega-embed/validation work +metadata: + type: reference +--- + +The canonical Vega-Lite editor (vega/editor) is cloned locally at +`/Users/oleh/code/reference/vega-editor` (shallow clone of `main`, HEAD 4fdbb59). It is the +reference for the "editor + renderer" wiring Astrolabe's M1/M2 call out ("mine vega-editor +for how it wires the schema"). Re-clone with `git clone --depth 1 https://github.com/vega/editor`. + +Key divergences to remember when borrowing — it is **React + Redux/context**, uses +`@monaco-editor/react` + `@monaco-editor/loader` (CDN-loaded Monaco, no explicit worker +config), and — surprisingly — **does NOT use vega-embed for its live preview** (it hand-rolls +`vegaLite.compile` → `vega.parse` → `new vega.View().runAsync()`; vega-embed is imported only +for types + the exported standalone HTML). Astrolabe is React (see [[stack-moving-to-react]]) + +Zustand stores + raw `monaco-editor` + `vegaEmbed()`. Since both are now React, vega/editor's +component lifecycle patterns port fairly directly; the friction is state (their flat Redux → +our Zustand stores) and Monaco worker wiring (their CDN loader → our explicit Vite workers). +The Monaco choice (self-hosted from npm + raw API, not the CDN loader / `@monaco-editor/react` +route) is a recorded decision — see `docs/architecture/08` § Decision · Monaco integration. + +The highest-value files: `src/utils/monaco.ts` (schema wiring), `src/utils/validate.ts` (ajv), +`src/utils/jsonc-parser.ts`, `src/utils/logger.ts` (LocalLogger), `src/components/renderer/renderer.tsx` +(View lifecycle), `src/components/app.tsx:188-365` (parse→validate→compile→render flow), +`src/constants/default-state.ts` (state shape). Relevant to [[]] M1/M2 of the implementation plan. diff --git a/.claude/skills/alignment/SKILL.md b/.claude/skills/alignment/SKILL.md new file mode 100644 index 0000000..804d01c --- /dev/null +++ b/.claude/skills/alignment/SKILL.md @@ -0,0 +1,185 @@ +--- +name: alignment +description: Review staged or uncommitted code to ensure quality, test coverage, and alignment with project specifications +disable-model-invocation: true +--- + +# Code Alignment + +Review staged or uncommitted code to ensure quality, test coverage, and alignment with the +project's spec (`docs/spec/`) and architecture playbook (`docs/architecture/`). + +## Scope + +Determine the review scope using `git diff` (unstaged) and `git diff --staged` (staged). +Review all changes in scope. If changes span multiple patterns below, apply all relevant sections. + +## General Instructions + +### Process + +1. **Git**: **NEVER** stage (`git add`) or commit (`git commit`) — that is the USER's + responsibility. If the reviewed changes span multiple independent concerns (a feature + an + unrelated fix, a refactor + a new capability), suggest splitting them into separate commits + and mention the logical boundaries. + +2. **Verification**: After changes, run `npm run typecheck` and `npm test`; run `npm run build` + if the change could affect the build. If tests fail, fix the issue if straightforward; ask + the user only if non-trivial or ambiguous. + +3. **Fix directly; don't ask first.** When you find an issue covered by these instructions, + fix it in place rather than reporting it and waiting. Ask the user only when the fix is + genuinely ambiguous or several valid approaches exist with real trade-offs. When guidelines + conflict, prefer in this order: **SOUL.md philosophy > `docs/spec/` behavioral contract > + `docs/architecture/` patterns > local cleanup**. These instructions are not strictly + prohibitive — if a guideline has a valid reason to be bypassed, mention it in the summary. + +### Code Quality + +4. **Code Cleanup**: Remove leftover code, unnecessary defensive programming, and + over-engineering from iterative development — dead code, try/catch around internal calls + that can't throw, abstraction layers wrapping a single implementation. Proceed with caution; + ask if unsure. + +5. **Styles and UI**: When altering CSS or layout, follow or generalize existing patterns + (CSS Modules + design tokens in `styles/tokens.css`) rather than writing from scratch. Don't + fix whitespace/formatting (trailing newlines etc.) — Prettier owns that. + +6. **Code Comments**: Comments should not duplicate what the code already says. Remove + parroting comments. Ensure comments capture non-obvious *why* — design decisions, + constraints, gotchas. Flag missing comments where a reader would reasonably ask "why is this + done this way?" + +7. **Workarounds**: Flag code that works around a problem rather than solving it (`// HACK`, + silent catch-and-ignore, feature detection for internal bugs). A justified workaround + (upstream bug, browser quirk) needs a comment explaining why and a tracking reference; an + unjustified one should be replaced with a proper fix. + +8. **Pre-existing & out-of-scope issues — leave a breadcrumb.** For anything you notice but + don't fix (pre-existing patterns the new code follows; observations the change exposes but + that are out of scope), mark it with a `// TODO:` at the relevant code site explaining + *what* could be improved and *why* (1–3 lines). **If an observation is important enough to + mention in the summary, it is important enough to deserve a `// TODO:` at the code location** + — otherwise the next reader has no way to recover the context. + +### Architecture & Project-Specific Checks + +9. **Portable core boundary**: `src/core/` must stay pure — no browser APIs (`window`, + `document`, `indexedDB`, `localStorage`), no React, no Monaco, no `vega-embed`. Flag any such + import. Pure spec logic (detection, profiling, reference resolution, fit transforms, + validation, import normalization) belongs in `src/core/` and must be unit-tested. See + `docs/architecture/00-overview.md` for the layering. + +10. **Infrastructure-adapter boundary**: Only `src/app/infrastructure/` touches `indexedDB`, + `localStorage`, or `window.location`. Flag direct access elsewhere — route it through an + adapter (`docs/architecture/02-persistence.md`, `04-routing-and-events.md`). + +11. **Rendering safety** (`docs/architecture/05-rendering-theming-preview.md`): the + reference-resolution/fit-mode transform must run on a **copy** of the spec — never mutate the + stored spec; a previous `vega-embed` view must be `.finalize()`d before re-render (no leaks); + user-derived field names must be escaped before going into `field:`; an invalid/unrenderable + spec must fail safe (readable error, no crash), and a blank spec renders nothing. + +12. **Persistence safety**: records that may need migration carry a `version` field; reads + apply migrations; destructive actions (delete, revert, reset) confirm; storage failures + warn rather than silently lose data. + +13. **Self-containment**: documentation and comments must not add pointers that require an + external repository to follow. Knowledge gets captured locally (`docs/spec/`, + `docs/architecture/`), not linked out. + +14. **User-facing copy**: keep user-visible strings centralized and written for users (sentence + case, active voice, no "please", no exclamation marks in errors). If/when an i18n layer + exists, route strings through it instead of hardcoding. + +### Output + +15. **Summary**: respond with a summary of changes — choices made due to these instructions, + choices where multiple approaches existed, and non-obvious architectural assumptions the + user should know but might not spot in the diff. If the summary mentions an observation you + chose not to fix (rule #8), confirm a `// TODO:` breadcrumb was placed at the code site. + +--- + +## Pattern A: New Functionality + +### Testing +- Unit tests for new `src/core/` logic (test the core hardest). +- Lighter component/interaction tests for new UI. +- Tests pass before proceeding. + +### Documentation +Update relevant docs if the feature is significant: +- **`docs/spec/`** — if product behavior changed (this is a contract; change deliberately). +- **`docs/architecture/`** — if a new pattern, navigation map, or decision rule emerged. +- **`docs/IMPLEMENTATION-PLAN.md`** — mark milestone progress. +Use the `/doc-update` skill for session-discovered gaps. The list is not exclusive. + +### Dependencies +If `package.json` changed: +- Flag each new dependency; explain what it does and why it's needed. +- Could a small custom implementation avoid it? Note the trade-off. +- Prefer dependencies that solve genuinely hard problems (parsing, rendering) over those that + save boilerplate. + +### Alignment Check +- **SOUL.md** — philosophy (must not violate without good reason). +- **`docs/spec/`** — behavioral contract. +- **`docs/architecture/`** — the relevant pattern doc. + +--- + +## Pattern B: Bug Fixes + +### Testing +- Add a regression test that reproduces the bug and verifies the fix. +- Interaction test if the bug affected UI behavior. + +### Documentation +Usually not required unless the bug revealed incorrect docs, or the fix changes documented +(spec) behavior. + +### Alignment Check +- **SOUL.md** philosophy; **`docs/spec/`** behavioral contract; **`docs/architecture/`** patterns. + +--- + +## Pattern C: Refactoring + +### Impact Analysis +1. **Search for usages** of modified functions/types across the codebase (Grep). +2. **Identify call sites** (components, stores, services, infrastructure, tests). +3. **Check exports** used by other modules. +4. **Review dependencies** — what the code depends on and what depends on it. + +### Testing +- Update existing tests to the new structure; verify all call sites. +- Run `npm test` and `npm run typecheck`. + +### Documentation +Update `docs/architecture/` if a pattern, module responsibility, or navigation map changed. +Update JSDoc/inline comments if signatures or behavior changed. + +### Alignment Check +- **SOUL.md** (simplicity, no parallel systems); **`docs/architecture/`** (consistent with the + documented patterns); **`docs/spec/`** (behavior unchanged unless intended). + +### Common Refactoring Checks +- Function signatures → all call sites updated. +- Type definitions → search type usages. +- Imports → correct after file moves. +- Stores → all consumers verified. +- Component props → all usages checked. +- Constants/enums → all references updated. + +--- + +## Reference Documents + +| Document | Purpose | +| --- | --- | +| [SOUL.md](../../../SOUL.md) | Project philosophy and core values | +| [AGENTS.md](../../../AGENTS.md) | AI onboarding and project context | +| [docs/spec/](../../../docs/spec/) | Behavioral contract — *what* the app does | +| [docs/architecture/](../../../docs/architecture/00-overview.md) | Architecture playbook — *how* it's built | +| [docs/IMPLEMENTATION-PLAN.md](../../../docs/IMPLEMENTATION-PLAN.md) | Milestone sequence and scope | diff --git a/.claude/skills/doc-update/SKILL.md b/.claude/skills/doc-update/SKILL.md new file mode 100644 index 0000000..54084ce --- /dev/null +++ b/.claude/skills/doc-update/SKILL.md @@ -0,0 +1,111 @@ +--- +name: doc-update +description: Update project documentation based on knowledge gaps discovered during the current session +disable-model-invocation: false +--- + +# Documentation Update from Session Context + +Review the current session to identify knowledge gaps that caused suboptimal codebase +navigation, then update the relevant documentation. + +## The quality bar + +Every addition must pass this test: **"Would this save a future session at least 5 minutes +of exploration?"** + +Documentation serves two purposes — know **where to look** and know **what to do**. Both +are valuable, but at different levels of detail: + +- **Navigation map** (good): "Preview flow: `LivePreview.tsx` → `prepareSpecForRender()` (core) → `vega-embed`" — lists the files and their roles so you don't read a dozen files to find the right four. +- **Decision rule** (good): "The fit-mode/reference-resolution transform runs on a *copy* of the spec — never mutate the stored spec" — captures a non-obvious convention. +- **Code walkthrough** (bad): "SnippetStore.updateDraft sets draftSpec, which a startup subscriber watches, debounces, then calls snippetStore.put… " — restates the code, goes stale on any rename. + +**Navigation maps** use file/module names (stable) to show flow direction. **Decision +rules** capture "when/why" constraints. **Code walkthroughs** restate implementation +details — that's what reading the code is for. + +For documentation organization, see **[CLAUDE.md](../../../CLAUDE.md)** and the doc index in +**[AGENTS.md](../../../AGENTS.md)**. + +## The three documentation layers (know which one a gap belongs to) + +- **`docs/spec/`** — the *what*: behavioral contract (what the app does, acceptance points). + This is a **contract**. Only change it when product behavior genuinely changes, and do so + deliberately — never as a casual "fill a doc gap" edit. A how-detail does NOT belong here. +- **`docs/architecture/`** — the *how*: the patterns behind each layer (state, persistence, + modals, routing, rendering, inference, relationships). Most navigation maps and decision + rules land here. +- **`docs/IMPLEMENTATION-PLAN.md`** — the *when*: milestone sequence and scope. + +## Process + +### 1. Analyze the session + +Look back through the conversation and identify: + +- **Missing navigation maps**: Where did you read many files to discover which 3–4 files a + flow actually involves? A one-line map of file roles would have saved that. +- **Missing rules**: What conventions or constraints were discovered that a new session + would violate or re-discover? +- **Non-obvious "when/why" knowledge**: What decisions require understanding intent, not + just implementation? + +Produce a brief list of gaps before proceeding. For each, state what's needed in one +sentence — a navigation map ("X flow: file → file → file") or a decision rule ("X must/must +not do Y"). If you can't state it concisely, it may be too implementation-specific to document. + +### 2. Categorize and target + +Map each gap to the right document: + +| Gap type | Target document | +| --- | --- | +| Product behavior, capabilities, acceptance points | `docs/spec/` (the relevant 00–10 section) — **contract; change deliberately** | +| State / Zustand stores | `docs/architecture/01-state-and-stores.md` | +| Persistence, IndexedDB, localStorage, migrations | `docs/architecture/02-persistence.md` | +| Modals, dialog lifecycle | `docs/architecture/03-modal-system.md` | +| URL routing, keyboard/events | `docs/architecture/04-routing-and-events.md` | +| Rendering, theming, vega-embed, preview | `docs/architecture/05-rendering-theming-preview.md` | +| Type inference, dataset profiling | `docs/architecture/06-type-inference.md` | +| Names, snippet↔dataset links, rename propagation | `docs/architecture/07-naming-and-relationships.md` | +| Milestone scope, build order | `docs/IMPLEMENTATION-PLAN.md` | +| Project philosophy / identity | `SOUL.md` | +| Onboarding, conventions, stack | `AGENTS.md` / `CLAUDE.md` | + +If a gap fits no existing document, consider a new section in the closest one; prefer +extending over creating. A brand-new architecture topic can become `docs/architecture/08-*.md` +(add it to `docs/architecture/00-overview.md`). + +### 3. Read, locate, and check for bloat + +For each target document: + +- Confirm the gap isn't already covered (if partially covered, extend rather than duplicate). +- Find the right insertion point. +- **Check section length**: if a section is already long (>50 lines), tighten or consolidate + before adding. Documentation that only grows becomes noise. + +### 4. Apply updates + +- **Rules and constraints over descriptions**: "X must do Y because Z" beats "X works by A, B, C". +- **Stability over specifics**: no line numbers, no file counts, no volatile details. +- **Proportional**: a missing sentence doesn't need a new section; a missing concept does. +- **Match existing style**: follow surrounding formatting, heading levels, tone. +- **Self-contained**: never add a pointer that requires an external repository to follow. +- **Consolidate while adding**: net size increase should be minimal. + +### 5. Update index (if needed) + +Only update `CLAUDE.md` (or `docs/architecture/00-overview.md`) if a new document was created +or a major new section was added that should be discoverable. Not for minor additions. + +## What NOT to document + +- **Code walkthroughs**: prose that restates the code; goes stale on any rename. (Navigation + maps that list file roles are fine.) +- **Obvious-from-code patterns**: if reading the file makes it clear, don't add docs. +- **Session-specific context**: current task details, debugging steps taken. +- **Speculative patterns**: only document conventions confirmed across multiple instances. +- **Implementation details that change with refactoring**: if renaming a variable would + invalidate the doc, it's too specific. diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md new file mode 100644 index 0000000..eccd7bf --- /dev/null +++ b/.claude/skills/release/SKILL.md @@ -0,0 +1,94 @@ +--- +name: release +description: Bump the app version, update the changelog, and prepare a git tag for release +disable-model-invocation: false +--- + +# Release + +Bump the app version, update the changelog, and prepare a git tag. + +## Process + +### 1. Determine what changed since the last version + +Run `git log` from the last version tag (or all history if no tags exist) and review the +changes. Categorize: + +- **Features**: new user-facing capabilities +- **Fixes**: bug fixes +- **Improvements**: performance, UX polish, refactoring that affects behavior +- **Internal**: refactoring, docs, tests, build (don't list individually — summarize if substantial) + +### 2. Determine bump type + +Read the current version from `package.json`. The project uses **simplified semver during +pre-1.0**: + +| Bump | When | Example | +| --- | --- | --- | +| **Minor** (`0.x.0`) | New features, UI changes, behavior changes | `0.1.0` → `0.2.0` | +| **Patch** (`0.x.y`) | Bug fixes, polish, performance, internal | `0.1.0` → `0.1.1` | +| **Major** (`1.0.0`) | Only when declaring public stability (user decision) | — | + +Present the categorized changes and your recommended bump type to the user **for confirmation +before proceeding**. + +### 3. Update version + +Update the `version` field in `package.json`. This is the **single source of truth** — Vite +injects it as `__APP_VERSION__` at build time (shown in the header badge, and in Settings / +the export envelope once those exist). + +### 4. Update the changelog + +Maintain `docs/CHANGELOG.md`. If it doesn't exist yet, create it with a top-level `# Changelog` +heading. Add a new entry under a month heading: + +```markdown +## June 2026 + +### v0.2.0 + +- **New feature** — description… + +### v0.1.1 + +- **Bug fix** — description… +``` + +- Group by feature/change, not by commit. +- Lead with the name in bold, then a dash and description. +- Most important changes first; summarize related commits into coherent items. + +### 5. Cross-check user-facing docs + +Scan hand-maintained user-facing surfaces against the changes landing in this release and fix +drift **before** the version-bump commit (the CHANGELOG entry is not the place to quietly slip +in doc fixes): + +- `README.md` — the status line and any feature claims still accurate? +- Any in-app help / about / onboarding content that exists at release time. + +(When narrative content pages are added later, list them here so this net catches accumulated +drift across many changes.) + +### 6. Suggest the git tag + +After the user stages and commits the version bump, suggest: + +```bash +git tag v{version} +git push --tags +``` + +If/when a deploy pipeline is wired, note here what publishing the tag triggers. + +## Rules + +- **Never bump the version without user confirmation** on the bump type. +- **Never stage or commit** — the user handles git operations. +- The export-format `version` (the import/export envelope) is **independent** of the app + version — only bump it when the export schema actually changes. +- Keep commit subjects single-line; no `Co-Authored-By` trailers (the user handles the commit + regardless). diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5110b83 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +node_modules +dist +dist-ssr +dev-dist +*.local +.DS_Store +*.log +coverage +.vite diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..e5ce635 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,6 @@ +{ + "singleQuote": true, + "trailingComma": "all", + "printWidth": 100, + "semi": true +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..8a8059f --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,107 @@ +# Astrolabe Project + +> **Purpose**: Onboarding document for AI agents (and humans) working on Astrolabe. + +--- + +## Project Overview + +**Astrolabe** is a browser-based snippet manager for Vega-Lite visualizations. A user keeps +a local library of **snippets** (saved Vega-Lite specs), edits each as JSON with live +validation and a live chart preview, and reuses **datasets** across many snippets. Fully +local, offline-capable, no account. + +It is a **spec-driven rebuild** on an architecture adapted from its sibling project Syto. +The authoritative behavioral contract is **`docs/spec/`** (sections 00–10). Implement *to +the spec*; do not port legacy code. + +### Technical Stack + +| Layer | Technology | +|-------|------------| +| Build | Vite, TypeScript, Vitest (happy-dom) | +| UI | React, Zustand, CSS Modules | +| Editor | Monaco (JSON + Vega-Lite schema service) | +| Charts | Vega-Lite + vega-embed | +| Storage | IndexedDB (snippets, datasets), localStorage (settings/prefs), URL hash (view state) | +| Offline | `vite-plugin-pwa` (Workbox), `registerType: 'prompt'` | + +--- + +## Architecture (non-negotiable) + +- **`src/core/` is portable** — no browser APIs, no React, no Monaco. All spec operations + live here and are tested hardest. +- **`src/app/`** — React + Zustand UI. State in Zustand **stores**; browser specifics in + **`src/app/infrastructure/`** adapters (IndexedDB / localStorage / URL hash). The rest of + the app never touches `window`/`indexedDB` directly. +- **Modals** go through a registry + coordinator + shell, not ad-hoc rendering. +- **CSS Modules + design tokens** (`styles/tokens.css`); themes flip `[data-theme]`. +- **No shared library with Syto** — patterns are adapted, never imported. + +See [`docs/architecture/`](docs/architecture/00-overview.md) for the patterns behind each +layer (state, persistence, modals, routing, rendering, inference, relationships) and +`docs/IMPLEMENTATION-PLAN.md` for the milestone sequence. Both are **self-contained** — no +external repo is needed to work from them. + +--- + +## Directory Structure + +``` +src/ +├── core/ # Portable spec engine (no browser/React/Monaco) +├── app/ +│ ├── components/ # React UI (CSS Modules co-located) +│ ├── stores/ # Zustand stores +│ ├── services/ # Business logic +│ └── infrastructure/ # IndexedDB, localStorage, URL hash adapters +styles/ # Global CSS (tokens, base) +docs/ +├── spec/ # Authoritative behavioral specification (00–10) — the WHAT +├── architecture/ # Architecture playbook (00–08) — the HOW (self-contained) +└── IMPLEMENTATION-PLAN.md +``` + +--- + +## Development + +```bash +npm run dev # Dev server +npm run build # Typecheck + production build (+ PWA) +npm run typecheck # tsc --noEmit +npm test # Vitest (run once) +npm run test:watch # Vitest watch +npm run format # Prettier +``` + +### AI Developer Protocol + +- **No git on your own initiative** — don't `add`/`commit`/`push` unless explicitly invited. +- **Verify** — run `npm run typecheck` and `npm test` after changes. +- **Spec is the contract** — when in doubt, read `docs/spec/`. If the spec is wrong or + silent, raise it; change the spec deliberately rather than drifting from it. +- **Core-first** — for each feature, build the pure `src/core/` logic with tests before UI. + +### Project skills + +Invoke with `/` (defined in `.claude/skills/`): + +- **`/alignment`** — review uncommitted/staged changes for quality, test coverage, and + alignment with `docs/spec/` + `docs/architecture/`. Fixes issues directly and leaves + `// TODO:` breadcrumbs at code sites for out-of-scope observations. +- **`/doc-update`** — capture session-discovered knowledge gaps into the right doc layer + (`docs/spec/` for behavior, `docs/architecture/` for patterns). +- **`/release`** — bump version, update the changelog, prepare a git tag. + +### Versioning + +Simplified semver `0.x.y` (pre-1.0): minor for features/behavior, patch for fixes. Single +source of truth is `version` in `package.json`, injected as `__APP_VERSION__`. + +### Testing Philosophy + +High coverage on `src/core/` (parsing, detection, profiling, reference resolution, fit +transforms, import normalization). Lighter on components. Extract testable logic out of +components into core/stores where practical. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..5f292e4 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,32 @@ +# Claude Context — Astrolabe + +See @AGENTS.md for project overview, architecture rules, and the AI developer protocol. + +## Documentation Index + +- **[SOUL.md](SOUL.md)** — project philosophy and identity. _Read first._ +- **[docs/spec/](docs/spec/)** — authoritative behavioral specification (sections 00–10): + the **what**. This is the contract; implement to it. +- **[docs/architecture/](docs/architecture/00-overview.md)** — architecture playbook + (00–08): the **how** (state, persistence, modals, routing, rendering, inference, + relationships, vega-editor techniques). Self-contained — no external repo needed. +- **[docs/IMPLEMENTATION-PLAN.md](docs/IMPLEMENTATION-PLAN.md)** — incremental milestone + plan (M0–M6), MVP boundary, per-milestone tests + manual checks, and an architecture + reference index. +- **[AGENTS.md](AGENTS.md)** — onboarding, stack, directory map, scripts, conventions. + +## Quick Orientation + +- Astrolabe is a **spec-driven rebuild** — the behavior is fixed in `docs/spec/`; the + architecture is adapted from Syto. Implement to the spec; don't port legacy code. +- **`src/core/` is portable and tested hardest.** Browser specifics live in + `src/app/infrastructure/`. UI is React + Zustand. +- **Editor is Monaco**, charts render via **vega-embed**, storage is **IndexedDB**. +- Work milestone by milestone (see the plan): core-first, then UI, then tests, then a + manual smoke check against the spec's acceptance points. + +## Conventions + +- No git actions unless explicitly invited. +- Run `npm run typecheck` and `npm test` after changes. +- Single-line commit subjects; no Co-Authored-By trailers. diff --git a/README.md b/README.md new file mode 100644 index 0000000..b168a69 --- /dev/null +++ b/README.md @@ -0,0 +1,33 @@ +# Astrolabe + +A browser-based **snippet manager for [Vega-Lite](https://vega.github.io/vega-lite/) +visualizations**. Author chart specs as JSON, watch them render live, and keep a personal, +searchable library — fully local, offline-capable, no account. + +> Astrolabe is a **spec-driven rebuild** on an architecture adapted from its sibling +> project Syto. The authoritative behavioral contract lives in [`docs/spec/`](docs/spec/). +> See [SOUL.md](SOUL.md) for the philosophy and [docs/IMPLEMENTATION-PLAN.md](docs/IMPLEMENTATION-PLAN.md) +> for the build sequence. + +## Stack + +Vite · TypeScript · React + Zustand · Monaco · Vega-Lite + vega-embed · IndexedDB · PWA (offline + installable). + +## Develop + +```bash +npm install +npm run dev # dev server +npm run build # typecheck + production build (+ service worker) +npm run typecheck +npm test # Vitest +``` + +## Status + +**M0 — Skeleton.** Toolchain green (typecheck, tests, build, PWA). The three-pane shell +renders; features land milestone by milestone per the implementation plan (MVP at end of M1). + +## License + +TBD. diff --git a/SOUL.md b/SOUL.md new file mode 100644 index 0000000..df735c8 --- /dev/null +++ b/SOUL.md @@ -0,0 +1,102 @@ +# Astrolabe — What This Project Is About + +## The Problem + +People who work with [Vega-Lite](https://vega.github.io/vega-lite/) directly — analysts, +educators, chart authors — don't have a fast, private place to *keep* their charts. The +official Vega-Lite editor is great for a single spec in a tab, but it forgets everything +when you close it. Notebooks bury charts in code. BI tools hide the spec behind a GUI and +lock you into an account. + +Astrolabe fills this gap: **a local-first workspace where you author Vega-Lite specs as +JSON, see them render live, and keep a personal, searchable library of them — with no +account, no server, and full offline use.** + +## The Core Idea + +The central artifact is the **snippet**: a saved Vega-Lite specification plus metadata. +Everything else — the editor, the live preview, the dataset library, the chart builder — +exists to author, organize, and reuse snippets. Astrolabe does not abstract Vega-Lite +away; a snippet *is* a Vega-Lite spec. The chart builder offers a no-JSON on-ramp, but the +JSON is always the source of truth and always editable. + +Reusable **datasets** are stored once and referenced by name from many snippets, so the +data lives in one place and the specs stay lean. + +## Core Values + +### 1. Local-Only by Default +Everything runs in the browser. Snippets, datasets, and settings never leave the machine. +No accounts, no uploads, no tracking. The only outbound requests are user-created +URL-dataset fetches. + +### 2. Vega-Lite Native, Not Vega-Lite Hidden +The product domain *is* Vega-Lite. We validate, render, and reason about specs as +Vega-Lite, and we surface its real vocabulary (marks, encodings, field types). We don't +invent a parallel chart abstraction. The chart builder is an on-ramp, not a replacement +for the spec. + +### 3. Experiment Safely +A snippet carries a stable **published** spec and an editable **draft**. You can tinker +freely without losing a known-good version. Auto-save protects in-progress work; publish +promotes it deliberately. + +### 4. Beginner On-Ramp, Power-User Ceiling +The chart builder lets someone produce a chart without writing JSON. The editor — with +schema-aware autocomplete and live validation — lets a power user do anything Vega-Lite +can. Neither caps the other. + +### 5. Own Your Data +Fully local and offline-capable, with import/export for backup and transfer. Your library +is a file you control, not a row in someone's database. + +### 6. Predictable, Not Clever +When a behavior could go several ways, pick the one closest to the user's existing mental +model (the Vega-Lite editor, JSON tooling, file-based apps). Least surprise beats most +clever. + +## What We're Not + +- **Not a BI/dashboarding tool.** A snippet is *one* visualization, not a composed report + with cross-filters and layout. Dashboards are a different product. +- **Not a data-wrangling tool.** Datasets are stored and referenced, not cleaned or + transformed. (That's [Syto](https://github.com/) territory — Astrolabe's sibling in + architecture and quality bar, but a separate product with separate goals.) +- **Not a collaboration platform.** No multi-user, no sync, no comments. Import/export + moves data between machines. +- **Not a server app.** No backend, no rendering service, no account system. + +## Technical Philosophy + +### Spec-Driven, Clean Implementation +The behavioral contract lives in `docs/spec/`. Astrolabe is a deliberate rebuild on a +robust architecture (adapted from Syto): we implement *to the spec*, not by porting old +code. When the spec and convenience conflict, the spec wins or the spec changes — never +silent drift. + +### Portable Core, Thin Browser Shell +`src/core/` is pure and portable — no browser APIs, no UI framework. Spec operations +(detection, profiling, reference resolution, fit transforms, validation, import +normalization) live there and are tested hardest. The UI is a thin, replaceable shell over +that core. + +### Leverage Existing Libraries +Vega-Lite renders. Monaco edits. vega-embed mounts charts. React + Zustand drive the UI. +We wrap these with thin integration layers rather than reinventing them. Custom code +focuses on what's unique to Astrolabe: the snippet/dataset model, the rendering contract, +and the workspace that ties it together. + +### No Parallel Systems +Each fact lives in one place. A snippet↔dataset link, a setting, a schema — one source of +truth, others derived. If you're writing the same logic twice, one should import or be +generated from the other. + +### Test the Core, Trust the UI +High coverage on the portable engine (where a bug corrupts data or breaks rendering); +lighter coverage on components (where a bug is a cosmetic annoyance). + +## The Name + +An **astrolabe** is an ancient instrument for locating and predicting the positions of +stars — a tool for finding your way by the sky. The app helps you find your way through a +library of visualizations: keep them, locate them, and see where each one points. diff --git a/docs/IMPLEMENTATION-PLAN.md b/docs/IMPLEMENTATION-PLAN.md new file mode 100644 index 0000000..d2e4b22 --- /dev/null +++ b/docs/IMPLEMENTATION-PLAN.md @@ -0,0 +1,288 @@ +# Astrolabe — Incremental Implementation Plan + +> A spec-driven rebuild of Astrolabe on Syto's architecture. The authoritative +> behavioral contract is `docs/spec/` (sections 00–10). This document sequences +> the build into the **quickest path to a usable MVP**, then layers the rest. +> +> **Method per milestone:** build core-first (portable, pure, tested) → wire UI → +> cover with tests → manual smoke check against the spec's acceptance points. +> "Test the Core, Trust the UI": high coverage on `src/core/`, lighter on components. + +--- + +## Architectural ground rules + +These are decided and apply to every milestone. The **how** behind each is written up +self-containedly in [`docs/architecture/`](architecture/00-overview.md) — read the matching +doc before implementing. + +- **`src/core/` is portable** — no browser APIs, no React, no Monaco. Pure spec + operations (detection, profiling, reference resolution, fit transforms, + validation, import normalization). This is what we test hardest and what could + power a future headless renderer/CLI. +- **`src/app/`** holds React + Zustand UI. State lives in Zustand **stores** + (`useAppStore`, plus per-feature stores); browser specifics live in + **`src/app/infrastructure/`** adapters (IndexedDB, localStorage, URL hash) so + the rest of the app never touches `window`/`indexedDB` directly. +- **Modals via a registry + coordinator + shell** (see [Architecture 03](architecture/03-modal-system.md)), + not ad-hoc conditional rendering. +- **CSS Modules + design tokens** (`styles/tokens.css`); themes flip + `[data-theme]`. Vega theme follows the UI theme. +- **Editor: Monaco**, **self-hosted from npm + raw `monaco-editor` API** (not the + CDN loader / `@monaco-editor/react` wrapper — decided; rationale in + [Architecture 08](architecture/08-vega-editor-techniques.md#decision--monaco-integration-self-hosted-raw-api)). + Workers are wired explicitly via Vite `?worker`. The Vega-Lite JSON-schema + service is what gives autocomplete + validation; mine vega-editor for how it + wires the schema. +- **No shared library with Syto.** Patterns are copied/adapted, never imported. + +--- + +## Milestone map + +| # | Milestone | Outcome | Spec | +|---|-----------|---------|------| +| **M0** | Skeleton ✅ | Repo builds, tests run, empty shell renders | — | +| **M1** | **MVP core loop** | Author a Vega-Lite snippet, see it render live, it persists | §02, §03A–C, §04, §09A | +| **M2** | Editor robustness | Draft/Published, validation, schema autocomplete, fit modes | §03D–E, §04, §07(editor) | +| **M3** | Datasets | Named reusable data + reference resolution in preview | §05, §03F, §09B | +| **M4** | Chart Builder | No-JSON chart composition from a dataset | §06 | +| **M5** | Settings + Import/Export | Preferences + workspace backup/transfer | §07, §08, §09C | +| **M6** | Shell polish | Resize/toggle panes, routing, shortcuts, toasts, a11y, offline | §01, §10 | + +**MVP boundary = end of M1** (a genuinely usable single-user chart authoring loop). +M2 makes it *robust*; M3–M6 make it *complete*. Ship/dogfood after M1, iterate. + +--- + +## M0 · Skeleton ✅ (done) + +Vite + React + Zustand + TypeScript + Vitest (happy-dom) + vite-plugin-pwa. +`src/core` ↔ `src/app` split, `useAppStore`, design tokens, three-pane placeholder +shell, first core module (`format-detection`) with tests. + +**Verified:** `npm run typecheck`, `npm test` (14 passing), `npm run build` (PWA SW generated). + +--- + +## M1 · MVP core loop → *the quickest usable Astrolabe* + +**Goal:** select/create a snippet, edit its spec JSON, watch a live Vega-Lite +preview, and have it survive reload. Single source kind: inline-data specs only +(datasets come in M3). No draft/published yet — edits save directly. + +**Core (`src/core/`)** +- `snippet.ts` — the Snippet type (spec §09A) + factory (`createSnippet`, + default sample bar-chart template, auto-generated date/time name). +- `rendering.ts` — `prepareSpecForRender(spec, { fitMode })` skeleton; in M1 it's + near pass-through (reference resolution is a no-op until M3, fit-mode is M2). + Establish the "transform a copy, never mutate stored spec" contract now. + +**Infrastructure (`src/app/infrastructure/`)** +- `idb.ts` — thin IndexedDB wrapper (open, get/put/delete/getAll by store). + *(see [Architecture 02 · Persistence](architecture/02-persistence.md))* +- `snippet-store.ts` — persist snippets (object store `snippets`). + +**App** +- `stores/SnippetStore.ts` — `useSnippetStore` with `snippets`, `activeSnippetId`, + selector-derived `activeSnippet`; load-on-startup; create/select/delete/update actions + (debounced auto-save of edits, spec §03B). Seed one sample snippet on first run. +- `components/SnippetLibrary.tsx` — list + "Create New" pinned item + select/delete. +- `components/SpecEditor.tsx` — Monaco JSON editor bound to active snippet's spec; + debounced write-back to the store. (Worker wiring via Vite `?worker` imports — + mine vega-editor's Monaco setup.) +- `components/LivePreview.tsx` — render current spec via `vega-embed` (actions: + false), debounced; clean empty pane when no/blank spec; basic error text. +- Fill the three panes in `App.tsx` with these. + +**Tests (core-first)** +- `snippet.test.ts` — factory defaults, sample template validity, unique naming. +- `rendering.test.ts` — copy-not-mutate invariant; pass-through shape. +- A store test for create/select/delete/auto-save reducer logic (logic extracted + from the component so it's testable without DOM). + +**Manual checks** +- Fresh load seeds a sample snippet that renders a bar chart. +- Type in the editor → preview updates after the debounce; bad JSON → editor keeps + working, preview shows an error, recovers when fixed. +- Reload → snippets and selection persist. + +--- + +## M2 · Editor robustness + +**Goal:** the editor becomes trustworthy — draft vs published, schema-aware +assistance, and the fit-mode rendering contract. + +**Core** +- `rendering.ts` — implement **fit-mode** transform (Original/Width/Height/Full → + Vega-Lite `"container"`), recursing into layered/concat/child specs (spec §04 + Rendering Contract, step 2). +- `vega-lite-schema.ts` — provide the Vega-Lite JSON schema for Monaco's + validation/autocomplete (mine vega-editor for sourcing/versioning the schema). + +**App** +- Snippet gains `spec` (published) + `draftSpec` (working) per §09A; editing + touches `draftSpec` only. +- `SpecEditor` header: Draft/Published toggle; **Publish** (promotes draft, recomputes + dataset refs — refs land in M3) + **Revert** (confirm dialog). +- Library list item: draft-vs-published **status indicator**. +- Monaco wired with the Vega-Lite schema → squiggles + autocomplete; inline error + surface in the editor pane (§03E). +- Preview **Fit control** (4 modes), persisted (`previewFitMode`). + +**Tests** +- Fit-mode transforms for each mode incl. nested specs; copy-not-mutate. +- Draft/publish/revert reducer logic; "has unpublished changes" derivation. + +**Manual checks** +- Edit draft, see status flip to "draft"; Publish → status clears; Revert → + draft restored with confirmation. +- Invalid spec shows inline error; autocomplete suggests Vega-Lite properties. +- Each fit mode resizes the chart as specified; choice survives reload. + +--- + +## M3 · Datasets + +**Goal:** named, reusable data that snippets reference by name; preview resolves +the reference. + +**Core** +- `profiling.ts` — row/column counts, column names, **per-column type inference** + (number/text/date/boolean). *(see [Architecture 06 · Type Inference](architecture/06-type-inference.md))* +- `rendering.ts` — implement **dataset reference resolution** (§04 Rendering + Contract, step 1): `{data:{name}}` → inline values / raw text+format / URL+format, + recursing into sub-specs; "dataset not found" error. +- `dataset.ts` — Dataset type (§09B); name uniqueness helpers; rename-propagation + into referencing specs. *(see [Architecture 07 · Naming & Relationships](architecture/07-naming-and-relationships.md))* + +**Infrastructure** +- `dataset-store.ts` — separate high-capacity IndexedDB store (§09E). + +**App** +- `stores/DatasetStore.ts` + Datasets **modal** (list/detail panes, create form, + edit, delete, copy-reference) via the modal registry/coordinator. +- Snippet `datasetRefs` maintained on publish; library shows dataset icon + + Linked Datasets; dataset detail shows Linked Snippets (bidirectional name link, §09F). +- **Extract-to-Dataset** flow from the editor (§03F). +- URL-sourced datasets fetched at render time. + +**Tests** +- Reference resolution per source/format incl. nested; not-found error. +- Profiling/type inference across mixed columns, nulls, booleans. +- Rename propagation; name-uniqueness + import-style auto-suffix. + +**Manual checks** +- Create a dataset, reference it by name in a snippet → preview renders. +- Extract inline data → spec rewritten to a reference, dataset appears, links show both ways. +- Delete/rename a referenced dataset behaves per spec. + +--- + +## M4 · Chart Builder + +**Goal:** no-JSON chart composition from a dataset → a new snippet. + +**Core** +- `chart-builder.ts` — pure spec assembler: (mark ∈ Bar/Line/Point/Area/Circle) + + channels (X/Y/Color/Size) with field types (Quantitative/Nominal/Ordinal/Temporal) + + optional width/height → complete Vega-Lite spec with tooltips + named data ref + (§06 Output). Field-type defaults from inferred column type. + +**App** +- Chart Builder **modal** (config pane + live preview pane), launched from a + selected dataset; default pre-population (first col→X, second→Y); validation + (≥1 channel); Create Snippet → new linked snippet becomes active. + +**Tests** +- Spec assembly: mark/channel/type permutations, unmapped channels omitted, + width/height inclusion, field-type derivation, validation gate. + +**Manual checks** +- Build a bar chart from a dataset in a few clicks; preview live-updates; + Create → new snippet opens and renders. + +--- + +## M5 · Settings + Import/Export + +**Goal:** preferences and whole-workspace backup/transfer. + +**Core** +- `settings.ts` — UserSettings shape + defaults + load-with-fallback (§07, §09C); + unknown/missing values fall back silently. +- `import-normalize.ts` — accept envelope / bare array / single snippet / foreign + shapes; field mapping (`content`→spec, `draft`→draftSpec, `createdAt`→created); + tag `"imported"`; merge rules (append, id-collision reassign, dataset-name + auto-suffix, datasets-before-snippets) (§08). +- `export-envelope.ts` — build the `{version, exportedAt, exportedBy, snippets, datasets}` envelope. + +**Infrastructure** +- `settings-store.ts` (localStorage); `ux-prefs` for sort + panel layout (§09D). + +**App** +- Settings **modal** (Appearance/Editor/Performance/Formatting), Apply/Cancel/Reset, + dirty indicator; wire render-debounce + theme + date-format through to the app. +- Header **Import**/**Export** (direct file dialog / download, no modal). +- Date formatting util (smart/iso/custom) used by the library list. + +**Tests** +- Import normalization across all accepted shapes; merge/collision/rename logic; + quota-overage messaging path. Envelope round-trip (export→import idempotence). +- Settings load-with-fallback for partial/unknown records. + +**Manual checks** +- Change theme/debounce/date-format → takes effect; Cancel reverts; Reset confirms. +- Export → reimport into a populated workspace merges without overwrite; renames reported. + +--- + +## M6 · Shell polish & non-functional + +**Goal:** the workspace feels finished and meets §10. + +- **Panes:** drag-resize handles with min widths; per-pane show/hide toggle strip; + widths + visibility persist (§01A, §09D). +- **Routing:** URL hash view-state (`#snippet-`, `#datasets/...`) with Back/Forward; + restore on load (§01E). *(see [Architecture 04 · Routing & Events](architecture/04-routing-and-events.md))* +- **Shortcuts:** Cmd/Ctrl+Shift+N / +K / +S / +, / Esc via a single key router + (§01D). *(see [Architecture 04 · Routing & Events](architecture/04-routing-and-events.md))* +- **Toasts:** success/error/warning/info, stacking, auto-dismiss, reduced-motion (§01F). +- **Storage monitor** for the snippet tier (§02). +- **A11y:** modal focus trap + return, labelled icon buttons, contrast in both themes (§10). +- **Offline/installable:** verify the SW + manifest give a working offline + installed app. +- **About & Privacy** and **Donate** modals. + +**Manual checks:** keyboard-only run-through; reload restores view from URL; +offline reload works; install as standalone; reduced-motion honored. + +--- + +## Cross-cutting, do-as-you-go + +- **i18n** (optional, deferred): if translation is wanted, split a portable i18n + registry (no React) from the app-layer bindings, mirroring the `core` ↔ `app` + boundary. M1–M6 can ship English-only with date formatting locale-aware (§10). + Don't retrofit later if avoidable — keep user-facing strings centralized from M1. +- **Versioning:** simplified semver `0.x.y`, `package.json` → `__APP_VERSION__` + (already wired). Bump per shippable milestone. +- **Docs trio:** keep `SOUL.md` / `AGENTS.md` / `CLAUDE.md` current as the app grows. + +--- + +## Architecture reference + +The **how** behind each milestone is documented self-containedly in +[`docs/architecture/`](architecture/00-overview.md) — no external repo needed: + +| Need | Doc | +|------|-----| +| Zustand stores, selector derivations, debounced auto-save | [01 · State & Stores](architecture/01-state-and-stores.md) | +| IndexedDB wrapper, lazy loading, migrations, localStorage prefs, storage tiers | [02 · Persistence](architecture/02-persistence.md) | +| Modal registry + coordinator + shell, unsaved-change detection, focus trap | [03 · Modal System](architecture/03-modal-system.md) | +| URL hash view-state, keyboard routing, interactive-context detection | [04 · Routing & Events](architecture/04-routing-and-events.md) | +| vega-embed integration, theming, debounced preview, error display | [05 · Rendering, Theming & Preview](architecture/05-rendering-theming-preview.md) | +| Column type inference + dataset profiling | [06 · Type Inference & Profiling](architecture/06-type-inference.md) | +| Unique names + import auto-suffix, snippet↔dataset links, rename propagation | [07 · Naming & Relationships](architecture/07-naming-and-relationships.md) | +| Monaco setup, Vega-Lite schema service, editor patterns mined from vega/editor | [08 · Vega Editor Techniques](architecture/08-vega-editor-techniques.md) | diff --git a/docs/WHY-A-SEPARATE-REBUILD.md b/docs/WHY-A-SEPARATE-REBUILD.md new file mode 100644 index 0000000..8ef3ecc --- /dev/null +++ b/docs/WHY-A-SEPARATE-REBUILD.md @@ -0,0 +1,204 @@ +# Astrolabe → Syto Integration Analysis + +> **Question:** Can Astrolabe (a browser-based Vega-Lite snippet manager) be integrated into +> Syto's functionality? This document compares the Astrolabe specification (`docs/spec/`) +> against Syto in its current state, and recommends an integration path. +> +> **Short answer:** Not as a wholesale port, and not as the "snippet manager" it is today — +> that framing collides with Syto's stated non-goals. But the *valuable parts* of Astrolabe +> (the Chart Builder, the generic spec→render pipeline, the schema-assisted JSON editor) map +> cleanly onto a Syto-native **"chart a model"** feature, and most of the supporting tech already +> exists in the codebase. The recommendation is **harvest, don't port** — and the framing decision +> needs a `SOUL.md` ruling first. + +--- + +## 1. Executive Summary + +| | | +|---|---| +| **Conceptual fit** | Partial. Astrolabe and Syto are both local-first, browser-only, Vega-Lite-using, three-pane-ish workspaces. But Astrolabe's *primary entity* (a saved chart spec) is a thing Syto deliberately does not have. | +| **Strategic fit** | **Conflicted.** `SOUL.md` explicitly lists "Not a BI/visualization platform — charts are for exploration during wrangling, not final output" as a non-goal, and "Do One Thing Well." A *snippet library* is chart-authoring-as-product. This is the central tension and must be resolved before any code. | +| **Technical fit** | **Good for the rendering/editing layer, poor for the data-model and shell layers.** Syto already ships Vega-Lite, vega-embed, CodeMirror 6, IndexedDB persistence, a settings system, URL-hash routing, and a far stronger type/schema engine than Astrolabe's profiler. The friction is in the *parallel systems* a verbatim port would introduce. | +| **Recommended path** | **Option B (harvest into a native "Visualize" feature).** Reuse Astrolabe's Chart Builder and rendering contract; bind them to Syto **Models** instead of a new "dataset" entity; drop the snippet-as-primary-entity, the draft/published workflow, the separate dataset library, and the separate import/export envelope. | + +--- + +## 2. The Two Products Side by Side + +| Dimension | **Astrolabe** | **Syto** | +|---|---|---| +| Core artifact | A **snippet** = a saved Vega-Lite spec + metadata | A **workflow** = a declarative transform pipeline over a Source | +| Primary verb | *Author & organize charts* | *Clean & reshape tabular data* | +| Data unit | **Dataset** (named blob: JSON/CSV/TSV/TopoJSON, inline or URL) | **Source** (immutable imported table) → **Model** (derived table) | +| Persistence | Snippets (~5 MB tier) + Datasets (high-capacity tier), both local | Sources + Models in IndexedDB (lazy row data), prefs in localStorage | +| Editor | JSON editor w/ Vega-Lite schema autocomplete + live validation | CodeMirror 6 — used for transform JSON + the expression language | +| Rendering | Renders *arbitrary user specs* via reference-resolution + fit-mode transforms | Renders *programmatically generated* EDA specs (`charts.ts`) | +| Shell | 3 panes: library · editor · preview, + modals | Ribbon + sidebar + data table + slide-panel/modal dialogs | +| Routing | URL hash: `#snippet-`, `#datasets/...` | URL hash: active source/model/dialog | +| Export | One JSON envelope of all snippets + datasets | Workflow v2 JSON (transforms, topo-sorted) | +| Stack stance | Implementation-agnostic spec | Preact + Signals + Arquero + CSS Modules, fixed | + +**The key observation:** Astrolabe's "dataset" is conceptually Syto's "Source," and the thing you +most want to chart in Syto — a cleaned, transformed **Model** — has *no equivalent in Astrolabe at +all*. Astrolabe charts static blobs; Syto produces living, recomputed tables. A naive port would +bolt a second, weaker data-library (Astrolabe datasets) next to Syto's existing one (Sources/Models), +which directly violates SOUL's **"No Parallel Systems"** value. + +--- + +## 3. The Strategic Tension (resolve this first) + +This is not a technical blocker; it is a product-identity decision, and per project convention +(`SOUL.md` is the arbiter for contract/design decisions) it must be settled before implementation. + +**What `SOUL.md` currently says:** + +- *"Do One Thing Well… It's not trying to become a spreadsheet, a statistical package, a visualization tool, or a database. The EDA features… exist to help users understand their data before transforming it — not to replace dedicated analysis tools."* +- *"Not a BI/visualization platform: Charts are for exploration during wrangling, not final output. Dashboards and reporting are a separate concern."* + +A **snippet manager** — a personal, searchable, import/exportable *library of saved charts* — is +squarely "charts as final output" and "a visualization tool." Porting Astrolabe as-is would +contradict two written non-goals. + +**However**, there is a reading that is fully *aligned* with the rest of SOUL: + +- *"Beginner-Friendly, Not Beginner-Limited"* and *"Progressive Complexity"* — today a user can clean data but has **no way to turn the result into a shareable picture.** A chart is the natural last step of a wrangling session. +- *"Leverage Existing Libraries — Vega-Lite handles charts."* The infrastructure is already paid for. +- Astrolabe's **Chart Builder** (pick a mark, map columns → spec) is the *exact* beginner-friendly, no-JSON affordance Syto favors, and the live JSON editor is the power-user escape hatch. + +**The decision to make:** Is "produce a chart as the output of a workflow" *part of* doing the one +thing well (wrangling ends in a usable artifact), or is it the BI/viz scope SOUL rejects? + +Two coherent resolutions: + +- **(A) Amend SOUL** to permit *single-chart output of a model* (not dashboards, not a chart library-as-product) — and integrate as a native feature (§6, Option B). +- **(B) Keep it separate** — Astrolabe stays its own thing, or lives as a sibling `/tools/` mini-app that merely *shares code* with Syto (§6, Option C). The main app's non-goals stay intact. + +I recommend (A) with a tightly-scoped amendment, because the value lands precisely where Syto is +currently weakest (no output artifact), and because doing it natively avoids the parallel-systems +trap. But this is the user's call to make against SOUL. + +--- + +## 4. Feature-by-Feature Reuse Map + +Legend: 🟢 already exists / strong reuse · 🟡 partial, needs adaptation · 🔴 net-new build + +| Astrolabe feature | Syto today | Verdict | Notes | +|---|---|---|---| +| **Vega-Lite rendering** | `charts.ts` + `vega-embed@7` render programmatic specs into DOM refs | 🟡 | Engine present; needs a *generic* "render this arbitrary spec" path + error surface. The hardcoded EDA specs don't help directly, but the rendering primitive does. | +| **Dataset-reference resolution** (`{data:{name}}` → contents, recursing into layers) | none | 🔴 | New, but small and pure — and in Syto it resolves to a **Model's data**, not a separate dataset store. | +| **Fit-mode transforms** (Original/Width/Height/Full via `"container"`) | none | 🔴 | Small, pure, copy-on-render spec rewrite. Directly portable. | +| **JSON spec editor** | CodeMirror 6 (`CodeMirrorEditor.tsx`, `JsonEditorModal.tsx`) + lint infra (`linters/`) | 🟡 | Editor & lint plumbing exist. Missing: a **Vega-Lite schema service** for autocomplete + validation. (Note: Astrolabe's "minimap" and "VS Light/Dark/High-Contrast" editor themes are Monaco-isms; Syto is on CodeMirror — those exact settings don't carry over.) | +| **Chart Builder** (mark + X/Y/Color/Size + field types → spec) | none | 🟡→🔴 | The single most valuable, most SOUL-aligned piece. Build it against a **Model's columns** using Syto's existing schema types. High reuse of the *dialog* pattern (registry + slide-panel/modal + debounced preview). | +| **Column profiling / type inference** | `schema-engine.ts` (integer/float/date/datetime/boolean/json) | 🟢 | Syto's engine **supersedes** Astrolabe's (number/string/date/boolean). Astrolabe→Vega field-type mapping (numeric→Quantitative, date→Temporal, else Nominal) layers on top trivially. | +| **Datasets library + manager modal** | Sources/Models already *are* the data library | 🔴 *(avoid)* | Do **not** build. Reuse Sources/Models. Building it = parallel systems. | +| **Snippet library** (search/sort/CRUD, draft vs published, status, tags, storage monitor) | none | 🔴 | The genuinely new persistent entity. Only needed if going full snippet-manager (not recommended). Draft/Published has no analog in Syto's undo/redo model. | +| **Settings** (editor/performance/formatting) | `ux-settings.ts` + settings dialog | 🟡 | System exists; add render-debounce + a couple of fields. Editor-theme/minimap fields are Monaco-shaped and mostly drop. | +| **Import/Export envelope** (snippets+datasets JSON) | Workflow v2 export/import | 🔴 *(avoid)* | A second export format competing with workflow v2. If charts are part of a workflow, they belong *in* the workflow spec or alongside it — not in a rival envelope. | +| **App shell / 3-pane layout** | Ribbon + sidebar + table + slide-panel | 🔴 *(avoid)* | Don't graft Astrolabe's shell. A chart view is a *mode/panel within* Syto's shell. | +| **URL-hash routing** | Hash routing for source/model/dialog | 🟡 | Reusable, but Astrolabe's `#snippet-…`/`#datasets/…` scheme would **collide**; must namespace under Syto's existing scheme. | +| **Keyboard shortcuts** | `EventRouter` owns Ctrl+S (save), Escape priority chain, etc. | 🟡 | **Collisions:** Astrolabe binds Ctrl+S (Publish) and Ctrl+K (Datasets). Syto already owns Ctrl+S. Must reconcile, not adopt verbatim. | +| **Offline / PWA / installable** | `vite-plugin-pwa` already configured | 🟢 | Free. | +| **i18n** | i18next, en/uk, namespaced | 🟢 | New strings go in a namespace; framework is there. | +| **Toasts** | Notification system exists | 🟢 | Reuse. | + +**Reuse tally:** the *rendering, editing, persistence, settings, schema, i18n, PWA, and toast* +substrate is largely present. The *data-model, shell, routing-scheme, and lifecycle* layers of +Astrolabe are either redundant with Syto or actively conflicting and should be dropped. + +--- + +## 5. Technical Friction Points (if ported verbatim) + +1. **Parallel data library.** Astrolabe datasets vs Syto Sources/Models — two stores, two + profilers, two "named data" concepts. Violates *No Parallel Systems*. (The fix: charts reference + Models.) +2. **Parallel persistence + export.** A second IndexedDB store layout and a second JSON envelope + alongside workflow v2. Two backup formats for users to confuse. +3. **Draft/Published has no home.** Syto's non-destructive model is *pipeline steps + undo/redo*, + not a per-document draft/published toggle. Astrolabe's central editing model would be a third, + unrelated state concept. +4. **Shell mismatch.** Astrolabe's library·editor·preview triptych is a *whole app*. Syto's shell is + ribbon-driven with slide-panel dialogs. They don't compose; one must yield. +5. **Routing & shortcut collisions.** Hash schemes overlap; Ctrl+S/Ctrl+K already bound. +6. **Editor-feature gap.** Syto is on CodeMirror (no minimap, different theme model); Astrolabe's + settings assume Monaco. And neither today has a **Vega-Lite schema service** — that autocomplete/ + validation is net-new work on either stack. +7. **TopoJSON / arbitrary-JSON data.** Syto Sources are *tabular*. Astrolabe datasets include + TopoJSON and arbitrary JSON. Charting a Model covers the tabular case; map/topology charts would + be out of scope unless Sources grow a non-tabular kind. + +None of these are unsolvable — but every one of them is *work created by the port itself*, not by +the user value. That's the signature of "harvest, don't port." + +--- + +## 6. Integration Options + +### Option A — Full port (snippet manager inside Syto) +Bring Astrolabe over more-or-less intact: snippet library, dataset manager, draft/published, its +shell, its export. +- **Pros:** Fastest way to "have Astrolabe." Feature-complete chart authoring. +- **Cons:** Maximal parallel-systems debt (§5). Directly contradicts SOUL non-goals. Two data + libraries, two export formats, shell/routing/shortcut conflicts. **Not recommended.** + +### Option B — Harvest into a native "Visualize" feature ✅ *recommended* +Add charting as the natural *output* step of a workflow, reusing Syto's own primitives: +- A **"Chart" / "Visualize"** action on a **Model** opens a **Chart Builder** (Astrolabe's mark + + X/Y/Color/Size + field-type controls), populated from the Model's columns and `schema-engine` + types. +- It produces a Vega-Lite spec rendered live via the existing `vega-embed`, using a ported + **reference-resolution + fit-mode** rendering contract where the named data resolves to the + **Model's rows**. +- Power users get the **JSON spec editor** (CodeMirror, with a Vega-Lite schema service added) as the + escape hatch — consistent with *Beginner-Friendly, Not Beginner-Limited*. +- The chart (its spec) is persisted **attached to the Model** (or to the workflow), not as a separate + snippet entity. Export rides along with workflow v2 (or a sibling field), not a rival envelope. +- **Dropped from Astrolabe:** separate dataset library, draft/published, snippet search/sort/tags, + storage monitor, its shell, its import/export, its routing scheme. +- **Pros:** No parallel systems. Lands value exactly where Syto is weak (no output artifact). Maximal + reuse of existing infra. Defensible against SOUL with a *narrow* amendment ("single-chart output of + a model," not dashboards/library). +- **Cons:** Requires the SOUL decision (§3). Loses Astrolabe features that depend on the + snippet/dataset model (TopoJSON/URL datasets, multi-snippet library). Net-new: schema service, + builder dialog, render contract. + +### Option C — Sibling `/tools/` mini-app +Port Astrolabe as a self-contained app under `/tools/astrolabe/`, sharing only *code* (vega render +helpers, CodeMirror wrapper, i18n) with the main app — no AppStore/DialogStore coupling. +- **Pros:** Keeps the main app's non-goals pristine (it's a separate utility, like other tools). + Lower conceptual conflict. Astrolabe keeps its own model. +- **Cons:** Syto's `/tools/` layer is designed for *small, single-purpose* utilities; Astrolabe is a + full application — a stretch for that slot. Still carries Astrolabe's whole parallel data model, + just quarantined. "Integration" here means "co-located," not "unified" — limited synergy. + +--- + +## 7. Recommendation + +1. **Make the SOUL call first (§3).** Decide whether single-chart *output of a model* is in scope. + If **no**, stop here or pursue Option C as a quarantined sibling. If **yes**, amend SOUL with a + tight scope statement and proceed to Option B. +2. **Pursue Option B.** Harvest the three high-value, well-aligned pieces: + - the **Chart Builder** (bound to a Model, driven by `schema-engine` types), + - the **rendering contract** (reference-resolution + fit modes, resolving to Model data), + - the **schema-assisted JSON editor** (CodeMirror + a new Vega-Lite schema service). +3. **Drop the parallel-systems pieces:** separate dataset library, draft/published, snippet + library + storage monitor, separate import/export envelope, Astrolabe's shell and routing scheme. +4. **Reconcile, don't adopt,** the cross-cutting surfaces: fold settings into `ux-settings`, + namespace any new hash state under Syto's scheme, resolve the Ctrl+S/Ctrl+K shortcut collisions. + +This delivers the genuinely useful core of Astrolabe — turning cleaned data into a chart, with a +beginner path and a power-user path — while staying true to *Do One Thing Well* and *No Parallel +Systems*, and reusing the infrastructure Syto has already built. + +--- + +## 8. Open Questions for the User + +- **SOUL scope:** Is "a chart as the output of a workflow" inside Syto's mission, or out? (Blocks everything.) +- **Persistence model:** Should a chart spec live *on a Model*, *in the workflow v2 export*, or as a new top-level entity? +- **Non-tabular data:** Do we ever need TopoJSON / arbitrary-JSON charts (maps), which Syto Sources can't currently hold? If not, that simplifies scope considerably. +- **Editor depth:** Is full Vega-Lite schema autocomplete/validation in scope, or is a plain JSON editor + live error surface enough for v1? diff --git a/docs/architecture/00-overview.md b/docs/architecture/00-overview.md new file mode 100644 index 0000000..8f4411a --- /dev/null +++ b/docs/architecture/00-overview.md @@ -0,0 +1,50 @@ +# Astrolabe — Architecture Playbook + +> These documents capture the **architectural patterns** Astrolabe is built on. They are +> self-contained: everything needed to implement a pattern lives here, in Astrolabe's own +> domain terms (snippets, datasets, settings, Vega-Lite specs). You do not need any other +> repository to work from them. +> +> They are the architectural counterpart to [`docs/spec/`](../spec/): the **spec** says +> *what the app does* (behavior, acceptance points); this **playbook** says *how we build +> it* (state, persistence, modals, routing, rendering, inference, relationships). + +## How to use this playbook + +- Building a feature? Read the relevant spec section first (the *what*), then the matching + playbook doc (the *how*), then implement core-first per [`../IMPLEMENTATION-PLAN.md`](../IMPLEMENTATION-PLAN.md). +- Each doc states the pattern, the **rationale** (what problem it solves, what it prevents), + TypeScript sketches in Astrolabe terms, and Do/Don't rules. +- The sketches are *illustrative*, not finished code. Adapt them; keep the principles. + +## The documents + +| # | Doc | Covers | +|---|-----|--------| +| 01 | [State & Stores](01-state-and-stores.md) | Zustand stores; one source of truth; selector derivations; central `useAppStore` vs per-feature stores; testable action functions; debounced auto-save. | +| 02 | [Persistence](02-persistence.md) | The infrastructure-adapter boundary; promise-wrapped IndexedDB wrapper; lazy data loading; per-record schema versioning + migration; localStorage prefs with fallback; storage tiers + quota monitoring. | +| 03 | [Modal System](03-modal-system.md) | Registry + coordinator + shell; one modal at a time; unsaved-change detection via snapshot; focus trap; backdrop/Escape/close dismissal. | +| 04 | [Routing & Events](04-routing-and-events.md) | URL hash as view-state (restore/sync, Back/Forward); global keyboard routing; Escape priority chain; the single-source `isInInteractiveContext()` helper (Monaco-aware). | +| 05 | [Rendering, Theming & Preview](05-rendering-theming-preview.md) | vega-embed integration (`actions:false`, `view.finalize()`); field-name escaping; theme→config mapping; debounced non-blocking renderer; resilient error display. | +| 06 | [Type Inference & Profiling](06-type-inference.md) | Pure, portable column-type inference (number/text/date/boolean) and the dataset profile shape. | +| 07 | [Naming & Relationships](07-naming-and-relationships.md) | Unique-name enforcement + import auto-suffix; the bidirectional snippet↔dataset name link; rename propagation into specs. | +| 08 | [vega/editor Techniques](08-vega-editor-techniques.md) | Reference brief: borrowable Monaco-schema wiring, vega-embed lifecycle, two-tier validation, and data-flow/debounce techniques distilled from the official Vega-Lite editor — plus where we do better. | + +## The non-negotiable layering (every doc assumes this) + +- **`src/core/`** — portable, pure logic. No browser APIs, no React, no Monaco. Spec + operations live here and are unit-tested hardest. (Docs 06, 07, parts of 05 land here.) +- **`src/app/stores/`** — Zustand stores. (Doc 01.) +- **`src/app/infrastructure/`** — the *only* place that touches `indexedDB`, `localStorage`, + or `window.location`. Everything else goes through these typed adapters. (Docs 02, 04.) +- **`src/app/services/` & `orchestration/`** — coordination that composes stores + + infrastructure + core (lifecycle, routing sync, dependency upkeep). (Docs 03, 04, 07.) +- **`src/app/components/`** — React + CSS Modules. Thin; pushes logic down into stores/core + so it stays testable. (Docs 03, 05.) + +## Why a playbook at all + +Patterns written down once, in one place, stop two classes of problem: drift (the same +decision re-litigated inconsistently across features) and rediscovery (re-deriving why +something is the way it is). When a pattern here proves wrong, change the doc — don't fork +the convention silently. This is the same discipline `docs/spec/` applies to behavior. diff --git a/docs/architecture/01-state-and-stores.md b/docs/architecture/01-state-and-stores.md new file mode 100644 index 0000000..0890607 --- /dev/null +++ b/docs/architecture/01-state-and-stores.md @@ -0,0 +1,432 @@ +# State Management & Stores + +How Astrolabe holds and shares application state. The whole app is built on +**Zustand**: small, standalone stores created with `create()`, each exposing +state fields and the actions that mutate them. Components subscribe to the exact +slices they read; non-component code (services, infrastructure, orchestration) +reads and writes the same stores directly. This document defines how we use +Zustand, where state lives, and the rules that keep state predictable as the app +grows. + +Why Zustand: it is idiomatic React (just a hook), it has a first-class **outside-React** +API (`getState`/`setState`/`subscribe`) that fits our "logic lives in core/services, +not components" architecture, and it carries no build-time magic. The principles below +(one source of truth, derive-don't-duplicate, actions outside components, thin components) +are the durable part — they would survive a change of library. + +--- + +## 1. The Primitives + +A store is a module that calls `create()` once and exports the resulting +hook. The state object holds both **data fields** and **action functions**. + +```ts +// src/app/stores/AppStore.ts +import { create } from 'zustand'; +import type { UiTheme } from '@core/theme'; // defined in core; charts key off it too + +export type ModalName = 'datasets' | 'settings' | 'about' | 'donate' | 'chartBuilder' | 'extract'; + +export interface AppState { + uiTheme: UiTheme; + activeModal: ModalName | null; + setTheme: (theme: UiTheme) => void; + // Low-level primitive. High-level open/close (snapshot, URL sync, discard + // prompt) is the modal coordinator's job — see docs/architecture/03. + setActiveModal: (modal: ModalName | null) => void; +} + +export const useAppStore = create((set) => ({ + uiTheme: 'light', + activeModal: null, + setTheme: (uiTheme) => set({ uiTheme }), + setActiveModal: (activeModal) => set({ activeModal }), +})); +``` + +Three ways to touch a store: + +- **`set(partial)`** — update state (shallow-merges). Inside actions, the only place + that mutates state. +- **`get()`** — read current state inside actions without subscribing. +- **the hook `useAppStore(selector)`** — read state *in a React component*, subscribing + to exactly what the selector returns. + +### Reading in components — always select narrowly + +Call the hook with a **selector** that returns the smallest thing you need. The +component re-renders only when that selected value changes (default `Object.is` +comparison). + +```tsx +import { useAppStore } from '../stores/AppStore'; + +export function ThemeBadge() { + const theme = useAppStore((s) => s.uiTheme); // re-renders only when uiTheme changes + return {theme}; +} +``` + +When you select **multiple fields or a fresh object/array**, wrap the selector in +`useShallow` so a new-but-equal result doesn't cause an extra render: + +```tsx +import { useShallow } from 'zustand/react/shallow'; + +const { activeModal, uiTheme } = useAppStore( + useShallow((s) => ({ activeModal: s.activeModal, uiTheme: s.uiTheme })), +); +``` + +### Reading/writing outside components + +Services, orchestration, infrastructure, and tests use the store object directly — +no React involved. This is the property that lets our logic live outside components: + +```ts +openModal('settings'); // via the modal coordinator (doc 03) +const theme = useAppStore.getState().uiTheme; // snapshot read +const unsub = useAppStore.subscribe((s, prev) => { /* react to changes */ }); +``` + +> Rule: in components, **select narrowly** (and `useShallow` for object/array +> selections). Outside components, use `getState()` for a snapshot, `subscribe()` +> to react. + +--- + +## 2. One Source of Truth per Fact — Derive, Don't Duplicate + +Every fact lives in exactly one state field. Anything that can be *calculated* +from other state is computed **in a selector at read time**, never stored as a +second field you keep in sync by hand. + +The failure mode this avoids: two fields that must agree (`snippets` and +`snippetCount`, or `activeSnippetId` and `activeSnippet`) drift apart because one +update path forgets the other. If the derived value is computed from the source on +read, drift is structurally impossible. + +```ts +// State holds only the sources: +// snippets: Snippet[] +// activeSnippetId: string | null + +// Derive in the component's selector — not a stored field: +const activeSnippet = useSnippetStore((s) => + s.snippets.find((x) => x.id === s.activeSnippetId) ?? null, +); +const snippetCount = useSnippetStore((s) => s.snippets.length); +``` + +For a derivation that is **expensive** or reused in many places, expose it as a +selector function (memoize if profiling shows it matters) rather than caching it +into state: + +```ts +// src/app/stores/snippet-selectors.ts +export const selectActiveSnippet = (s: SnippetState) => + s.snippets.find((x) => x.id === s.activeSnippetId) ?? null; + +// in a component: +const active = useSnippetStore(selectActiveSnippet); +``` + +> Rule: if you can compute it, do not store it. Add a new state field only for a +> value that is *input* the app receives, not output it derives. + +--- + +## 3. Where State Lives: Central vs. Per-Feature Stores + +Each store is its own `create()` module. We split by *concern*, not by component +tree. + +### Per-feature stores + +Each cohesive feature owns a store holding its durable domain state. + +- **`useSnippetStore`** — the snippet library: `snippets`, `activeSnippetId`, the + working `draftSpec`, and its actions. +- **`useDatasetStore`** — loaded datasets, the active dataset, inferred fields. +- **`useSettingsStore`** — user preferences (editor options, render debounce, + date format, theme); mirrors what gets persisted to `localStorage`. + +### The central `useAppStore` + +`useAppStore` holds only *cross-cutting, ephemeral UI state* that no single +feature owns — which modal is open, the runtime theme, transient render flags. + +### How to decide + +| Put it in a **feature store** when… | Put it in **`useAppStore`** when… | +| --------------------------------------------- | --------------------------------------------- | +| It's domain data (snippets, datasets, specs) | It's transient UI chrome (open modal, theme) | +| It outlives a single interaction | It belongs to no single feature | +| It gets persisted | Multiple unrelated features read/write it | + +> Rule: keep `useAppStore` small. When a chunk of it only ever serves one feature, +> that's the signal to extract a feature store. A bloated central store is the +> thing this split exists to prevent. + +--- + +## 4. Actions: Mutations Live in the Store, Not Components + +Components **render** and **dispatch**; they do not contain mutation logic. Every +state change goes through a named action defined on the store (via `set`/`get`). +Multi-step logic that coordinates several stores or touches infrastructure can +live in a `src/app/services/*` module that calls store actions. + +```ts +// src/app/stores/SnippetStore.ts +import { create } from 'zustand'; +import type { Snippet } from '@core/snippet'; + +interface SnippetState { + snippets: Snippet[]; + activeSnippetId: string | null; + draftSpec: string; // Monaco editor buffer (Vega-Lite JSON) + + create: (name: string) => string; + select: (id: string) => void; + remove: (id: string) => void; + updateDraft: (spec: string) => void; + reset: () => void; +} + +export const useSnippetStore = create((set, get) => ({ + snippets: [], + activeSnippetId: null, + draftSpec: '', + + create: (name) => { + const snippet: Snippet = { id: crypto.randomUUID(), name, spec: '{}' }; + set((s) => ({ snippets: [...s.snippets, snippet] })); + get().select(snippet.id); + return snippet.id; + }, + + select: (id) => + set((s) => ({ + activeSnippetId: id, + draftSpec: s.snippets.find((x) => x.id === id)?.spec ?? '{}', + })), + + remove: (id) => + set((s) => { + const snippets = s.snippets.filter((x) => x.id !== id); + const activeSnippetId = + s.activeSnippetId === id ? (snippets[0]?.id ?? null) : s.activeSnippetId; + return { snippets, activeSnippetId }; + }), + + updateDraft: (draftSpec) => set({ draftSpec }), + + reset: () => set({ snippets: [], activeSnippetId: null, draftSpec: '' }), +})); +``` + +The component is thin — it selects state and calls actions: + +```tsx +import { useShallow } from 'zustand/react/shallow'; +import { useSnippetStore } from '../stores/SnippetStore'; + +export function SnippetList() { + const { snippets, activeSnippetId } = useSnippetStore( + useShallow((s) => ({ snippets: s.snippets, activeSnippetId: s.activeSnippetId })), + ); + const select = useSnippetStore((s) => s.select); + const remove = useSnippetStore((s) => s.remove); + + return ( +
    + {snippets.map((s) => ( +
  • select(s.id)}> + {s.name} + +
  • + ))} +
+ ); +} +``` + +> Note: action identities are stable, so selecting them (`s.select`) never causes +> re-renders — select actions individually rather than bundling them into a +> `useShallow` object. + +### Why mutations live in the store + +- **Testable without a DOM.** Actions are plain functions over state. A Vitest test + calls `useStore.getState().create('x')` and asserts on `getState()` — no + rendering, no React. +- **One place to change behavior.** "Deleting the active snippet falls back to the + first remaining one" is a rule that lives in `remove`, not scattered across every + delete button. +- **Readable components.** A component that only wires events to named actions reads + like a description of the UI, not a tangle of state juggling. + +```ts +// SnippetStore.test.ts — no browser needed +import { useSnippetStore } from './SnippetStore'; + +beforeEach(() => useSnippetStore.getState().reset()); + +test('deleting the active snippet selects the next one', () => { + const store = useSnippetStore.getState(); + const a = store.create('A'); + const b = store.create('B'); + store.select(a); + store.remove(a); + expect(useSnippetStore.getState().activeSnippetId).toBe(b); +}); +``` + +> Rule: no `setState` calls inside component bodies for shared state — call an +> action. Local, throwaway UI state (a dropdown's open flag) may stay in component +> `useState`; anything another component reads belongs in a store behind an action. + +--- + +## 5. Effects: Persistence and External Sync + +Cross-cutting reactions — persisting state, mirroring the theme onto the document, +pushing the draft into Vega for rendering — are wired once at app startup with +`store.subscribe(...)`, in the orchestration/startup layer, not in components. +Subscribers read state and write to `src/app/infrastructure/` adapters (IndexedDB, +`localStorage`, URL hash). + +### Theme → document (the minimal example, already wired) + +```ts +// src/main.tsx +const applyTheme = (t: string) => { document.documentElement.dataset.theme = t; }; +applyTheme(useAppStore.getState().uiTheme); +useAppStore.subscribe((s, prev) => { + if (s.uiTheme !== prev.uiTheme) applyTheme(s.uiTheme); +}); +``` + +The store stays DOM-free; the adapter (the `applyTheme` subscriber) lives at the edge. + +### Debounced auto-save of the draft spec + +The Monaco editor writes every keystroke into `draftSpec`. We do **not** persist on +every keystroke. A startup subscriber observes the draft and debounces the expensive +work: + +```ts +// src/app/orchestration/persistence.ts +import { useSnippetStore } from '../stores/SnippetStore'; +import { saveSnippet } from '../infrastructure/snippet-store'; // IndexedDB adapter + +export function wireDraftAutoSave(): void { + let timer: ReturnType | undefined; + + useSnippetStore.subscribe((s, prev) => { + if (s.draftSpec === prev.draftSpec) return; // only react to draft edits + const id = s.activeSnippetId; + if (!id) return; + + clearTimeout(timer); + const spec = s.draftSpec; + timer = setTimeout(() => { + useSnippetStore.setState((cur) => ({ + snippets: cur.snippets.map((x) => (x.id === id ? { ...x, spec } : x)), + })); + void saveSnippet(id, spec); + }, 400); + }); +} +``` + +> For selector-based subscriptions (`subscribe(selector, listener)` with an equality +> function) add the `subscribeWithSelector` middleware to the store. Plain +> `subscribe((state, prev) => …)` as above is enough for most wiring. + +> Rule: components never touch infrastructure adapters directly. Reads/writes to +> IndexedDB, `localStorage`, and the URL hash happen in startup subscribers or +> actions, so the persistence story is in one place and the UI stays pure. + +--- + +## 6. Reading State: Import the Store, Don't Thread It + +Because stores are singletons importable anywhere, a deep leaf component reads the +state it needs directly instead of receiving it through five layers of props. + +```tsx +// Good: a deeply nested toggle reads + flips the theme itself. +import { useAppStore } from '../stores/AppStore'; + +export function ThemeToggle() { + const theme = useAppStore((s) => s.uiTheme); + const setTheme = useAppStore((s) => s.setTheme); + return ( + + ); +} +``` + +This is the right default for **global/shared** state. Threading `theme` and +`onThemeChange` through `Layout → Header → Toolbar → ThemeToggle` adds noise and +couples every intermediate component to data it doesn't use. + +### When to thread props instead + +- The value is **presentational input**, not shared app state. ` + + +
+ {/* The ONE place the active modal is mapped to a component. */} + +
+ + {/* Optional generic action row for editing modals. A modal with no + primary action (about, donate) can render its own footer/none. */} + {config.getState && ( +
+ + +
+ )} + + + )} + + ); +} +``` + +Rendering `` from the registry is the only modal-name→view +mapping in the app. There is no `name === 'datasets' && ` 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(active: boolean) { + const ref = useRef(null); + const returnTo = useRef(null); + + useEffect(() => { + const el = ref.current; + if (!active || !el) return; + + returnTo.current = document.activeElement; + el.querySelector(FOCUSABLE)?.focus(); + + const onKey = (e: KeyboardEvent) => { + if (e.key !== 'Tab') return; + const f = el.querySelectorAll(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 `` — 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-`, `#datasets/dataset-/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. diff --git a/docs/architecture/04-routing-and-events.md b/docs/architecture/04-routing-and-events.md new file mode 100644 index 0000000..a230b25 --- /dev/null +++ b/docs/architecture/04-routing-and-events.md @@ -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-` | +| Datasets manager (list) | `#datasets` | +| A specific dataset | `#datasets/dataset-` | +| New-dataset form | `#datasets/new` | +| Chart Builder for a dataset | `#datasets/dataset-/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- + | { kind: 'datasets' } // #datasets + | { kind: 'dataset'; datasetId: number } // #datasets/dataset- + | { 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 `