From ca54bb66b1865d1e0c81401c828b9656134febc7 Mon Sep 17 00:00:00 2001 From: Oleh Omelchenko Date: Fri, 5 Jun 2026 00:16:24 +0300 Subject: [PATCH] Implement M1 authoring loop: library, editor, live preview, persistence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M1 MVP from docs/IMPLEMENTATION-PLAN.md, with the /alignment pass applied. - core: Snippet model + factory; prepareSpecForRender (copy-not-mutate); per-theme Vega chart config - state/orchestration: SnippetStore with debounced auto-save; IndexedDB adapter + read-time migration; startup hydration + write-through persistence - ui: SnippetLibrary, SpecEditor (Monaco edcore.main — full editor features, JSON-only languages), LivePreview - build: Monaco/Vega manual chunks; raised PWA precache ceiling - alignment: flush a valid draft on snippet switch (+regression tests); TODO breadcrumbs for the preview render race and the window.confirm delete - housekeeping: gitignore .claude/projects/ --- .../memory/MEMORY.md | 2 - .../memory/stack-moving-to-react.md | 29 ---- .../memory/vega-editor-reference-clone.md | 27 ---- .gitignore | 4 + AGENTS.md | 11 +- docs/architecture/01-state-and-stores.md | 8 + .../architecture/08-vega-editor-techniques.md | 23 +++ src/app/App.module.css | 22 ++- src/app/App.tsx | 17 ++- src/app/components/LivePreview.module.css | 24 +++ src/app/components/LivePreview.tsx | 103 +++++++++++++ src/app/components/SnippetLibrary.module.css | 102 +++++++++++++ src/app/components/SnippetLibrary.tsx | 78 ++++++++++ src/app/components/SpecEditor.module.css | 22 +++ src/app/components/SpecEditor.tsx | 90 +++++++++++ src/app/infrastructure/db.ts | 80 ++++++++++ src/app/infrastructure/monaco-env.ts | 18 +++ src/app/infrastructure/monaco-schema.ts | 63 ++++++++ .../infrastructure/snippet-migrations.test.ts | 34 +++++ src/app/infrastructure/snippet-migrations.ts | 47 ++++++ src/app/infrastructure/snippet-store.ts | 42 ++++++ src/app/orchestration/persistence.ts | 57 +++++++ src/app/orchestration/startup.ts | 33 ++++ src/app/services/chart-renderer.ts | 37 +++++ src/app/stores/SnippetStore.test.ts | 141 ++++++++++++++++++ src/app/stores/SnippetStore.ts | 133 +++++++++++++++++ src/core/rendering.test.ts | 42 ++++++ src/core/rendering.ts | 47 ++++++ src/core/snippet.test.ts | 74 +++++++++ src/core/snippet.ts | 123 +++++++++++++++ src/core/vega-themes.ts | 60 ++++++++ src/main.tsx | 6 + src/vite-env.d.ts | 14 ++ vite.config.ts | 18 +++ 34 files changed, 1557 insertions(+), 74 deletions(-) delete mode 100644 .claude/projects/-Users-oleh-code-astrolabe/memory/MEMORY.md delete mode 100644 .claude/projects/-Users-oleh-code-astrolabe/memory/stack-moving-to-react.md delete mode 100644 .claude/projects/-Users-oleh-code-astrolabe/memory/vega-editor-reference-clone.md create mode 100644 src/app/components/LivePreview.module.css create mode 100644 src/app/components/LivePreview.tsx create mode 100644 src/app/components/SnippetLibrary.module.css create mode 100644 src/app/components/SnippetLibrary.tsx create mode 100644 src/app/components/SpecEditor.module.css create mode 100644 src/app/components/SpecEditor.tsx create mode 100644 src/app/infrastructure/db.ts create mode 100644 src/app/infrastructure/monaco-env.ts create mode 100644 src/app/infrastructure/monaco-schema.ts create mode 100644 src/app/infrastructure/snippet-migrations.test.ts create mode 100644 src/app/infrastructure/snippet-migrations.ts create mode 100644 src/app/infrastructure/snippet-store.ts create mode 100644 src/app/orchestration/persistence.ts create mode 100644 src/app/orchestration/startup.ts create mode 100644 src/app/services/chart-renderer.ts create mode 100644 src/app/stores/SnippetStore.test.ts create mode 100644 src/app/stores/SnippetStore.ts create mode 100644 src/core/rendering.test.ts create mode 100644 src/core/rendering.ts create mode 100644 src/core/snippet.test.ts create mode 100644 src/core/snippet.ts create mode 100644 src/core/vega-themes.ts diff --git a/.claude/projects/-Users-oleh-code-astrolabe/memory/MEMORY.md b/.claude/projects/-Users-oleh-code-astrolabe/memory/MEMORY.md deleted file mode 100644 index afefcd7..0000000 --- a/.claude/projects/-Users-oleh-code-astrolabe/memory/MEMORY.md +++ /dev/null @@ -1,2 +0,0 @@ -- [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 deleted file mode 100644 index 217a1f2..0000000 --- a/.claude/projects/-Users-oleh-code-astrolabe/memory/stack-moving-to-react.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -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 deleted file mode 100644 index 3025403..0000000 --- a/.claude/projects/-Users-oleh-code-astrolabe/memory/vega-editor-reference-clone.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -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/.gitignore b/.gitignore index 5110b83..4a3ac13 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,7 @@ dev-dist *.log coverage .vite + +# Claude Code per-project session/memory data — machine-local, not part of the +# codebase. Shared project config (.claude/skills/, .claude/settings.json) stays tracked. +.claude/projects/ diff --git a/AGENTS.md b/AGENTS.md index 8a8059f..31b9721 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -79,7 +79,16 @@ 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. +- **Verify** — run `npm run typecheck` and `npm test` after changes. A green build and a + smaller bundle prove nothing about behavior: when a change touches what the user sees or + does, confirm it by exercising the feature, not by the compiler alone. +- **Trim content, not capability** — when narrowing a third-party import or build to cut + size, remove optional _content_ (languages, locales, themes, icon sets), never the + library's _features_. The smallest/lowest-level entry point is seldom the right one — it + often drops capabilities you meant to keep. Prefer the entry that excludes the unwanted + content while retaining behavior, and validate the behavior survived. (This bit us once: + importing Monaco's `editor.api` to drop unused languages also stripped every editor + feature — see `docs/architecture/08`.) - **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. diff --git a/docs/architecture/01-state-and-stores.md b/docs/architecture/01-state-and-stores.md index 0890607..ff8afdb 100644 --- a/docs/architecture/01-state-and-stores.md +++ b/docs/architecture/01-state-and-stores.md @@ -14,6 +14,14 @@ not components" architecture, and it carries no build-time magic. The principles (one source of truth, derive-don't-duplicate, actions outside components, thin components) are the durable part — they would survive a change of library. +**Lineage (why React + Zustand).** The UI began on Preact + `@preact/signals` and migrated to +**React + Zustand** at M0, before feature work. The driver was *React-ecosystem friction* — +real-React-only libraries not cooperating with `preact/compat` — **not** the signals model. +Switching framework while nothing was implemented yet was also the one cheap moment to pick +the lowest-migration-risk state library, so signals gave way to Zustand. A bonus: borrowing +from the React-based vega/editor reference (see [08](08-vega-editor-techniques.md)) then ports +directly rather than through `preact/compat`. + --- ## 1. The Primitives diff --git a/docs/architecture/08-vega-editor-techniques.md b/docs/architecture/08-vega-editor-techniques.md index bcbe19f..8f67219 100644 --- a/docs/architecture/08-vega-editor-techniques.md +++ b/docs/architecture/08-vega-editor-techniques.md @@ -73,6 +73,29 @@ self-hosting. **Accepted cost:** the explicit worker wiring (§1) is inherent to self-hosting — it is the price of offline, paid in any non-CDN setup, and the wrapper would not remove it. +### Entry point: `edcore.main`, never `editor.api` (trim languages, not features) + +Self-hosting raw Monaco forces a choice of ESM entry point, and the granularity matters: + +| Import | What you get | Use? | +|---|---|---| +| `monaco-editor` (barrel) | All features **+ every basic language** (sql, abap, solidity, …) | ❌ language bloat (~20 dead chunks) | +| `esm/vs/editor/editor.api` | The API surface only — **zero feature contributions** | ❌ a text box: no folding, suggest widget, `Cmd+Backspace`, find, bracket colorization | +| `esm/vs/editor/edcore.main` | `editor.all` (all 59 feature contributions) + API, **no languages** | ✅ full editor UX, JSON-only weight | + +Import **`edcore.main`** and add only the JSON language service +(`esm/vs/language/json/monaco.contribution`). `edcore.main` ships no `.d.ts` of its own — +add an ambient `declare module … { export * from '…/editor.api'; }` so types (and Monaco's +global `MonacoEnvironment` augmentation) resolve. Two editor options worth setting because +they bite Vega-Lite specs specifically: `showFoldingControls: 'always'` (fold arrows always +visible), and `quickSuggestions: { strings: true }` (VL enum values like `"bar"` live inside +JSON strings, where Monaco disables auto-suggest by default). + +> Reaching for `editor.api` to "drop unused languages" silently strips every editor feature — +> the languages live elsewhere. This is the concrete case behind AGENTS.md's **"trim content, +> not capability"** rule: cut the unwanted _content_, keep the _behavior_, and verify the +> behavior survived by exercising the editor, not by a green build. + --- > **The single biggest surprise:** vega/editor does **not** use `vega-embed` for its live diff --git a/src/app/App.module.css b/src/app/App.module.css index d235003..b0b74c0 100644 --- a/src/app/App.module.css +++ b/src/app/App.module.css @@ -39,20 +39,26 @@ } .pane { - flex: 1; + flex: 1 1 0; min-width: 0; padding: var(--space-4); overflow: auto; border-right: 1px solid var(--color-border); } +/* Library is a fixed-ish sidebar; editor + preview share the rest. */ +.panes > .pane:first-child { + flex: 0 0 260px; +} + +/* Editor pane: Monaco manages its own scroll/layout, so no padding. */ +.paneEditor { + flex: 1 1 0; + min-width: 0; + overflow: hidden; + border-right: 1px solid var(--color-border); +} + .pane:last-child { border-right: none; } - -.paneLabel { - font-size: 11px; - text-transform: uppercase; - letter-spacing: 0.05em; - color: var(--color-text-muted); -} diff --git a/src/app/App.tsx b/src/app/App.tsx index c7c9b85..85891a8 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -1,12 +1,15 @@ +import { LivePreview } from './components/LivePreview'; +import { SnippetLibrary } from './components/SnippetLibrary'; +import { SpecEditor } from './components/SpecEditor'; import styles from './App.module.css'; /** * Application shell — the three-pane workspace from spec §01A * (library · editor · preview) under a fixed header. * - * This is the skeleton: panes are placeholders. Each milestone fills one in - * (see docs/IMPLEMENTATION-PLAN.md). Resizing, toggling, modals, routing, and - * shortcuts arrive in later milestones. + * M1 fills the panes with the MVP authoring loop. Pane resizing/toggling, + * modals, routing, and shortcuts arrive in later milestones (see + * docs/IMPLEMENTATION-PLAN.md). */ export function App() { return ( @@ -19,13 +22,13 @@ export function App() {
-
Library
+
-
-
Editor
+
+
-
Preview
+
diff --git a/src/app/components/LivePreview.module.css b/src/app/components/LivePreview.module.css new file mode 100644 index 0000000..7887ab7 --- /dev/null +++ b/src/app/components/LivePreview.module.css @@ -0,0 +1,24 @@ +.preview { + height: 100%; + width: 100%; + overflow: auto; +} + +.chart { + display: flex; + align-items: flex-start; + justify-content: center; + min-height: 100%; + padding: var(--space-2); +} + +.error { + margin: 0; + padding: var(--space-3); + font-family: var(--font-mono); + font-size: 12px; + line-height: 1.5; + color: var(--color-error); + white-space: pre-wrap; + word-break: break-word; +} diff --git a/src/app/components/LivePreview.tsx b/src/app/components/LivePreview.tsx new file mode 100644 index 0000000..84faed4 --- /dev/null +++ b/src/app/components/LivePreview.tsx @@ -0,0 +1,103 @@ +/** + * Live Preview — the right pane (spec §04). + * + * Renders the active snippet's current buffer as a Vega-Lite chart, debounced so + * typing stays smooth. The pipeline is: buffer text → JSON.parse → + * prepareSpecForRender (copy, pure) → renderSpec (vega-embed). A render- + * generation token guards against a slow render resolving after a newer one. + * + * M1 scope: inline-data specs, Original sizing, basic error text. Fit modes (M2) + * and dataset reference resolution (M3) plug into prepareSpecForRender without + * changing this component. + */ + +import { useEffect, useRef, useState } from 'react'; +import type { VisualizationSpec } from 'vega-embed'; +import { prepareSpecForRender } from '@core/rendering'; +import { chartConfigFor } from '@core/vega-themes'; +import { renderSpec, type RenderHandle } from '../services/chart-renderer'; +import { useAppStore } from '../stores/AppStore'; +import { useSnippetStore } from '../stores/SnippetStore'; +import styles from './LivePreview.module.css'; + +/** Render debounce (ms). Becomes the configurable `renderDebounce` setting in M5. */ +const RENDER_DEBOUNCE_MS = 300; + +export function LivePreview() { + const hostRef = useRef(null); + const handleRef = useRef(null); + const generationRef = useRef(0); + const draftText = useSnippetStore((s) => s.draftText); + const uiTheme = useAppStore((s) => s.uiTheme); + const [error, setError] = useState(null); + + useEffect(() => { + const node = hostRef.current; + if (!node) return; + const text = draftText.trim(); + + const timer = setTimeout(async () => { + // Empty/blank is not an error — clean, empty pane (spec §04). + if (text === '') { + handleRef.current?.destroy(); + handleRef.current = null; + setError(null); + return; + } + + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch (e) { + setError(`Invalid JSON: ${(e as Error).message}`); + return; + } + + const mine = ++generationRef.current; + try { + const prepared = prepareSpecForRender(parsed, { fitMode: 'default' }); + const config = chartConfigFor(uiTheme); + handleRef.current?.destroy(); + handleRef.current = null; + const handle = await renderSpec(node, prepared as VisualizationSpec, config); + if (mine !== generationRef.current) { + // TODO: a superseded render's destroy() calls node.replaceChildren(), + // which can blank the live chart if two embeds on the same node are + // ever in flight at once (heavy spec whose embed outlasts the 300ms + // debounce). The debounce makes this rare in M1; when fit-mode/dataset + // work (M2/M3) lands, serialize renders or finalize the stale view + // without clearing the shared node. + handle.destroy(); // a newer render superseded this one + return; + } + handleRef.current = handle; + setError(null); + } catch (e) { + if (mine === generationRef.current) { + setError( + `Rendering error: ${(e as Error).message}. ` + + `Check your JSON syntax and that the spec is valid Vega-Lite.`, + ); + } + } + }, RENDER_DEBOUNCE_MS); + + return () => clearTimeout(timer); + }, [draftText, uiTheme]); + + // Finalize the live view on unmount. + useEffect( + () => () => { + handleRef.current?.destroy(); + handleRef.current = null; + }, + [], + ); + + return ( +
+ + ); +} diff --git a/src/app/components/SnippetLibrary.module.css b/src/app/components/SnippetLibrary.module.css new file mode 100644 index 0000000..49a75c3 --- /dev/null +++ b/src/app/components/SnippetLibrary.module.css @@ -0,0 +1,102 @@ +.library { + display: flex; + flex-direction: column; + height: 100%; + gap: var(--space-3); +} + +.createNew { + flex: 0 0 auto; + padding: var(--space-2) var(--space-3); + border: 1px solid var(--color-border); + border-radius: var(--radius); + background: var(--color-accent); + color: var(--color-accent-contrast); + font: inherit; + font-weight: 600; + cursor: pointer; + text-align: left; +} + +.createNew:hover { + filter: brightness(1.05); +} + +.list { + list-style: none; + margin: 0; + padding: 0; + flex: 1 1 auto; + min-height: 0; + overflow: auto; + display: flex; + flex-direction: column; + gap: var(--space-1); +} + +.empty { + color: var(--color-text-muted); + font-size: 13px; + padding: var(--space-2); +} + +.item { + display: flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-2) var(--space-3); + border: 1px solid transparent; + border-radius: var(--radius); + cursor: pointer; +} + +.item:hover { + background: var(--color-surface); +} + +.active { + background: var(--color-surface); + border-color: var(--color-accent); +} + +.itemMain { + flex: 1 1 auto; + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; +} + +.name { + font-size: 13px; + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.date { + font-size: 11px; + color: var(--color-text-muted); +} + +.delete { + flex: 0 0 auto; + border: none; + background: none; + color: var(--color-text-muted); + cursor: pointer; + font-size: 12px; + padding: var(--space-1); + border-radius: var(--radius); + opacity: 0; +} + +.item:hover .delete { + opacity: 1; +} + +.delete:hover { + color: var(--color-error); + background: var(--color-bg); +} diff --git a/src/app/components/SnippetLibrary.tsx b/src/app/components/SnippetLibrary.tsx new file mode 100644 index 0000000..bcaa1b3 --- /dev/null +++ b/src/app/components/SnippetLibrary.tsx @@ -0,0 +1,78 @@ +/** + * Snippet Library — the left pane (spec §02). + * + * M1 scope: the always-visible list with a pinned "Create New Snippet" item, + * selection/highlight, and delete. Search, sort controls, the metadata panel, + * status/dataset indicators, and the storage monitor arrive in later milestones. + */ + +import { useShallow } from 'zustand/react/shallow'; +import { useSnippetStore } from '../stores/SnippetStore'; +import styles from './SnippetLibrary.module.css'; + +/** Compact relative date for the list (full date formatting lands in M5). */ +function relativeDate(iso: string): string { + const then = new Date(iso); + const now = new Date(); + const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const dayMs = 24 * 60 * 60 * 1000; + const days = Math.floor((startOfToday.getTime() - new Date(then.getFullYear(), then.getMonth(), then.getDate()).getTime()) / dayMs); + if (days <= 0) return 'Today'; + if (days === 1) return 'Yesterday'; + if (days < 7) return `${days}d ago`; + return then.toLocaleDateString(); +} + +export function SnippetLibrary() { + const snippets = useSnippetStore(useShallow((s) => s.snippets)); + const activeId = useSnippetStore((s) => s.activeSnippetId); + const createSnippet = useSnippetStore((s) => s.createSnippet); + const selectSnippet = useSnippetStore((s) => s.selectSnippet); + const removeSnippet = useSnippetStore((s) => s.removeSnippet); + + // Default ordering: newest-modified first (spec §02 → Sort). + const ordered = [...snippets].sort((a, b) => b.modified.localeCompare(a.modified)); + + const handleDelete = (id: string, name: string) => { + // TODO: M1 stopgap — route destructive confirms through the modal coordinator + // (docs/architecture/03) when the modal system lands, and surface a deletion + // toast (spec §02), instead of the native window.confirm. + if (window.confirm(`Delete "${name}"? This cannot be undone.`)) removeSnippet(id); + }; + + return ( +
+ + +
    + {ordered.length === 0 &&
  • No snippets found
  • } + {ordered.map((s) => ( +
  • selectSnippet(s.id)} + > +
    + {s.name} + {relativeDate(s.modified)} +
    + +
  • + ))} +
+
+ ); +} diff --git a/src/app/components/SpecEditor.module.css b/src/app/components/SpecEditor.module.css new file mode 100644 index 0000000..6268d8c --- /dev/null +++ b/src/app/components/SpecEditor.module.css @@ -0,0 +1,22 @@ +.editorPane { + position: relative; + height: 100%; +} + +.editor { + height: 100%; + width: 100%; +} + +.placeholder { + position: absolute; + inset: 0; + z-index: 1; + display: flex; + align-items: center; + justify-content: center; + color: var(--color-text-muted); + font-size: 13px; + background: var(--color-bg); + pointer-events: none; +} diff --git a/src/app/components/SpecEditor.tsx b/src/app/components/SpecEditor.tsx new file mode 100644 index 0000000..99a5581 --- /dev/null +++ b/src/app/components/SpecEditor.tsx @@ -0,0 +1,90 @@ +/** + * Spec Editor — the center pane (spec §03). + * + * M1 scope: a Monaco JSON editor bound to the active snippet's draft. It runs + * uncontrolled (raw `monaco-editor`, per docs/architecture/08): created once, + * keystrokes push into the store's `draftText` buffer, and the buffer is pulled + * back in only when the active snippet *changes* (select/create/delete) — never + * keystroke-by-keystroke, which would fight the cursor. Schema validation/ + * autocomplete, the draft/published toggle, and the inline error surface arrive + * in M2. + */ + +import { useEffect, useRef } from 'react'; +// `edcore.main` is the full standalone editor — every feature contribution +// (folding, suggest widget, word operations like Cmd+Backspace, find, bracket +// colorization, multi-cursor, …) — but WITHOUT the `monaco-editor` barrel's +// basic-languages (sql, abap, solidity, …) we never use. We then add only the +// JSON language service. Full editor UX, JSON-only weight. +import * as monaco from 'monaco-editor/esm/vs/editor/edcore.main'; +import 'monaco-editor/esm/vs/language/json/monaco.contribution'; +import '../infrastructure/monaco-env'; // side-effect: wire workers before create +import { configureVegaLiteJson } from '../infrastructure/monaco-schema'; +import { useAppStore } from '../stores/AppStore'; +import { useSnippetStore } from '../stores/SnippetStore'; +import styles from './SpecEditor.module.css'; + +// Register the bundled Vega-Lite schema once: resolves `$schema` locally (no +// network warning) and powers validation, autocomplete, and hover docs. +configureVegaLiteJson(); + +export function SpecEditor() { + const hostRef = useRef(null); + const editorRef = useRef(null); + const activeId = useSnippetStore((s) => s.activeSnippetId); + const uiTheme = useAppStore((s) => s.uiTheme); + + // Create the editor once, on mount. + useEffect(() => { + if (!hostRef.current) return; + const editor = monaco.editor.create(hostRef.current, { + value: useSnippetStore.getState().draftText, + language: 'json', + automaticLayout: true, + minimap: { enabled: false }, + fontSize: 13, + tabSize: 2, + wordWrap: 'on', + folding: true, + showFoldingControls: 'always', // keep fold arrows visible, not only on hover + scrollBeyondLastLine: false, + // Vega-Lite enum values ("bar", "quantitative", …) live inside JSON + // strings, where Monaco disables auto-suggest by default — turn it on so + // those keywords are hinted as you type, not just on Ctrl+Space. + quickSuggestions: { other: true, comments: false, strings: true }, + suggestOnTriggerCharacters: true, + }); + editorRef.current = editor; + + const sub = editor.onDidChangeModelContent(() => { + useSnippetStore.getState().updateDraft(editor.getValue()); + }); + + return () => { + sub.dispose(); + editor.dispose(); + editorRef.current = null; + }; + }, []); + + // Replace the buffer when the active snippet changes (not while typing). + useEffect(() => { + const editor = editorRef.current; + if (!editor) return; + const text = useSnippetStore.getState().draftText; + if (editor.getValue() !== text) editor.setValue(text); + editor.updateOptions({ readOnly: activeId === null }); + }, [activeId]); + + // Editor theme follows the UI theme (M1: light/dark stock themes). + useEffect(() => { + monaco.editor.setTheme(uiTheme === 'experimental' ? 'vs-dark' : 'vs'); + }, [uiTheme]); + + return ( +
+ {activeId === null &&
Select or create a snippet
} +
+
+ ); +} diff --git a/src/app/infrastructure/db.ts b/src/app/infrastructure/db.ts new file mode 100644 index 0000000..4b89d20 --- /dev/null +++ b/src/app/infrastructure/db.ts @@ -0,0 +1,80 @@ +/** + * IndexedDB wrapper (docs/architecture/02 §2). + * + * The ONLY low-level IndexedDB access in the app. Wraps the event-based native + * API into promises and exposes a tiny CRUD surface per object store. Typed + * stores (snippet-store, dataset-store) build on top of this; nothing outside + * `src/app/infrastructure/` imports `indexedDB` directly. + */ + +const DB_NAME = 'astrolabe'; +/** + * Store-layout version. Bump only when the set of object stores / indexes + * changes — independent of per-record schema versions (see snippet-migrations). + */ +const DB_VERSION = 1; + +export const SNIPPETS_STORE = 'snippets'; +export const DATASETS_STORE = 'datasets'; + +let dbPromise: Promise | null = null; + +/** Open (and memoize) the database, creating object stores on first run. */ +export function openDB(): Promise { + if (dbPromise) return dbPromise; + + dbPromise = new Promise((resolve, reject) => { + const req = indexedDB.open(DB_NAME, DB_VERSION); + + req.onupgradeneeded = () => { + const db = req.result; + // Guard every create so upgrades stay idempotent. + if (!db.objectStoreNames.contains(SNIPPETS_STORE)) { + db.createObjectStore(SNIPPETS_STORE, { keyPath: 'id' }); + } + if (!db.objectStoreNames.contains(DATASETS_STORE)) { + db.createObjectStore(DATASETS_STORE, { keyPath: 'id' }); + } + }; + + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error ?? new Error('Failed to open IndexedDB')); + }); + + return dbPromise; +} + +/** Run a transaction and resolve on commit (durability), not on request success. */ +function tx( + store: string, + mode: IDBTransactionMode, + run: (s: IDBObjectStore) => IDBRequest, +): Promise { + return openDB().then( + (db) => + new Promise((resolve, reject) => { + const transaction = db.transaction(store, mode); + const request = run(transaction.objectStore(store)); + transaction.oncomplete = () => resolve(request.result); + transaction.onerror = () => reject(transaction.error); + transaction.onabort = () => reject(transaction.error); + }), + ); +} + +export const get = (store: string, key: IDBValidKey): Promise => + tx(store, 'readonly', (s) => s.get(key) as IDBRequest); + +export const getAll = (store: string): Promise => + tx(store, 'readonly', (s) => s.getAll() as IDBRequest); + +export const put = (store: string, value: T): Promise => + tx(store, 'readwrite', (s) => s.put(value as unknown as Record)); + +export const del = (store: string, key: IDBValidKey): Promise => + tx(store, 'readwrite', (s) => s.delete(key) as IDBRequest); + +/** Test-only: forget the memoized connection so a fresh `openDB` reopens. */ +export function _resetDbForTests(): void { + dbPromise = null; +} diff --git a/src/app/infrastructure/monaco-env.ts b/src/app/infrastructure/monaco-env.ts new file mode 100644 index 0000000..83f20e0 --- /dev/null +++ b/src/app/infrastructure/monaco-env.ts @@ -0,0 +1,18 @@ +/** + * Monaco worker wiring (docs/architecture/08 §1). + * + * Self-hosting raw `monaco-editor` means we must point `MonacoEnvironment` at the + * web workers ourselves — the CDN loader is deliberately not used (offline, + * privacy, determinism). Vite's `?worker` imports become hashed bundles that the + * PWA precaches. The `json` worker is what powers JSON validation + completion; + * everything else uses the base editor worker. + * + * Side-effect module: import it once, before creating any editor. + */ + +import EditorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker'; +import JsonWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker'; + +self.MonacoEnvironment = { + getWorker: (_workerId, label) => (label === 'json' ? new JsonWorker() : new EditorWorker()), +}; diff --git a/src/app/infrastructure/monaco-schema.ts b/src/app/infrastructure/monaco-schema.ts new file mode 100644 index 0000000..cdc46c4 --- /dev/null +++ b/src/app/infrastructure/monaco-schema.ts @@ -0,0 +1,63 @@ +/** + * Vega-Lite schema service for Monaco (docs/architecture/08 §1). + * + * Registers the **bundled** Vega-Lite JSON schema with Monaco's JSON language + * service. This is what makes `$schema` resolve locally (no network fetch, no + * "unable to load schema" warning) and powers schema-aware validation, + * autocomplete, and hover docs. + * + * Two deliberate departures from vega/editor's setup: + * - `enableSchemaRequest: false` — offline-first; the schema is bundled, never + * fetched (vega/editor sets it true because it is an online tool). + * - `fileMatch: ['*']` — bind by model, not only by the doc's `$schema` value, + * so validation/autocomplete work even if the user removes the `$schema` + * line. (Every JSON model in this app is a Vega-Lite spec.) + */ + +import * as monaco from 'monaco-editor/esm/vs/editor/edcore.main'; +import 'monaco-editor/esm/vs/language/json/monaco.contribution'; +import vegaLiteSchema from 'vega-lite/vega-lite-schema.json'; + +/** The canonical URI specs reference via `$schema`; we register the schema here. */ +const VEGA_LITE_SCHEMA_URI = 'https://vega.github.io/schema/vega-lite/v6.json'; + +/** + * Recursively copy each `description` to `markdownDescription`. Monaco renders + * rich (markdown) hover docs only from `markdownDescription`; without this, the + * schema's docs would hover as plain text. Done once, in place, at setup. + */ +function addMarkdownDescriptions(node: unknown): void { + if (Array.isArray(node)) { + node.forEach(addMarkdownDescriptions); + return; + } + if (node !== null && typeof node === 'object') { + const obj = node as Record; + if (typeof obj.description === 'string' && obj.markdownDescription === undefined) { + obj.markdownDescription = obj.description; + } + for (const key of Object.keys(obj)) addMarkdownDescriptions(obj[key]); + } +} + +let configured = false; + +/** Register the Vega-Lite schema with Monaco's JSON service. Idempotent. */ +export function configureVegaLiteJson(): void { + if (configured) return; + configured = true; + + addMarkdownDescriptions(vegaLiteSchema); + + monaco.languages.json.jsonDefaults.setDiagnosticsOptions({ + validate: true, + enableSchemaRequest: false, + schemas: [ + { + uri: VEGA_LITE_SCHEMA_URI, + fileMatch: ['*'], + schema: vegaLiteSchema, + }, + ], + }); +} diff --git a/src/app/infrastructure/snippet-migrations.test.ts b/src/app/infrastructure/snippet-migrations.test.ts new file mode 100644 index 0000000..38348dc --- /dev/null +++ b/src/app/infrastructure/snippet-migrations.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, test } from 'vitest'; +import { CURRENT_SNIPPET_VERSION } from '@core/snippet'; +import { migrateSnippet } from './snippet-migrations'; + +describe('migrateSnippet', () => { + test('stamps the current version and fills missing fields with defaults', () => { + const s = migrateSnippet({ id: 7, spec: '{"mark":"bar"}' }); + + expect(s.id).toBe('7'); + expect(s.version).toBe(CURRENT_SNIPPET_VERSION); + expect(s.name).toBe('Untitled'); + expect(s.draftSpec).toBe('{"mark":"bar"}'); // defaults to published spec + expect(s.comment).toBe(''); + expect(s.tags).toEqual([]); + expect(s.datasetRefs).toEqual([]); + expect(s.meta).toEqual({}); + }); + + test('coerces an object-form spec into canonical JSON text', () => { + const s = migrateSnippet({ id: 'a', spec: { mark: 'point' } }); + expect(s.spec).toBe(JSON.stringify({ mark: 'point' }, null, 2)); + }); + + test('preserves unknown fields so a newer build round-trips without loss', () => { + const s = migrateSnippet({ id: 'a', spec: '{}', futureField: 42 }); + expect((s as unknown as Record).futureField).toBe(42); + }); + + test('keeps a distinct draftSpec when present', () => { + const s = migrateSnippet({ id: 'a', spec: '{"a":1}', draftSpec: '{"a":2}' }); + expect(s.spec).toBe('{"a":1}'); + expect(s.draftSpec).toBe('{"a":2}'); + }); +}); diff --git a/src/app/infrastructure/snippet-migrations.ts b/src/app/infrastructure/snippet-migrations.ts new file mode 100644 index 0000000..45032d8 --- /dev/null +++ b/src/app/infrastructure/snippet-migrations.ts @@ -0,0 +1,47 @@ +/** + * Read-time migration for Snippet records (docs/architecture/02 §4). + * + * The IndexedDB database version governs store *layout*; this governs the shape + * of an individual *record*. Every snippet read from storage passes through + * `migrateSnippet`, which fills in missing/old fields and stamps the current + * version. It must tolerate unknown fields (spread the original, only fill gaps) + * so a record written by a newer build round-trips without data loss. + */ + +import { CURRENT_SNIPPET_VERSION, type Snippet } from '@core/snippet'; + +/** Coerce a stored spec field (object or string) into the canonical string form. */ +function asSpecText(value: unknown, fallback: string): string { + if (typeof value === 'string') return value; + if (value == null) return fallback; + try { + return JSON.stringify(value, null, 2); + } catch { + return fallback; + } +} + +/** Upgrade a raw stored record to the current Snippet shape. */ +export function migrateSnippet(raw: unknown): Snippet { + const r = { ...(raw as Record) }; + // Records written before versioning existed are treated as v1. + // (No structural migrations yet; this is the hook for future versions.) + + const spec = asSpecText(r.spec, '{}'); + const draftSpec = asSpecText(r.draftSpec, spec); + + return { + ...r, + id: String(r.id), + version: CURRENT_SNIPPET_VERSION, + name: typeof r.name === 'string' ? r.name : 'Untitled', + created: typeof r.created === 'string' ? r.created : new Date(0).toISOString(), + modified: typeof r.modified === 'string' ? r.modified : new Date(0).toISOString(), + spec, + draftSpec, + comment: typeof r.comment === 'string' ? r.comment : '', + tags: Array.isArray(r.tags) ? (r.tags as string[]) : [], + datasetRefs: Array.isArray(r.datasetRefs) ? (r.datasetRefs as string[]) : [], + meta: typeof r.meta === 'object' && r.meta !== null ? (r.meta as Record) : {}, + }; +} diff --git a/src/app/infrastructure/snippet-store.ts b/src/app/infrastructure/snippet-store.ts new file mode 100644 index 0000000..38fab71 --- /dev/null +++ b/src/app/infrastructure/snippet-store.ts @@ -0,0 +1,42 @@ +/** + * Snippet persistence adapter (docs/architecture/02). + * + * The typed seam between the snippet store and IndexedDB. Exposes plain async + * functions returning domain `Snippet` objects; migrates every record on read; + * fails loudly on quota so the UI can warn rather than silently lose work. + */ + +import { CURRENT_SNIPPET_VERSION, type Snippet } from '@core/snippet'; +import { del, getAll, put, SNIPPETS_STORE } from './db'; +import { migrateSnippet } from './snippet-migrations'; + +/** Raised when a write fails because the snippet storage budget is exhausted. */ +export class StorageQuotaError extends Error { + constructor(message = 'Snippet storage is full. Export and remove snippets to free space.') { + super(message); + this.name = 'StorageQuotaError'; + } +} + +/** Load every snippet, upgrading each record to the current shape. */ +export async function loadSnippets(): Promise { + const records = await getAll(SNIPPETS_STORE); + return records.map(migrateSnippet); +} + +/** Persist a snippet at the current schema version. Propagates quota failures. */ +export async function saveSnippet(snippet: Snippet): Promise { + try { + await put(SNIPPETS_STORE, { ...snippet, version: CURRENT_SNIPPET_VERSION }); + } catch (err) { + if (err instanceof DOMException && err.name === 'QuotaExceededError') { + throw new StorageQuotaError(); + } + throw err; + } +} + +/** Permanently remove a snippet by id. */ +export async function deleteSnippet(id: string): Promise { + await del(SNIPPETS_STORE, id); +} diff --git a/src/app/orchestration/persistence.ts b/src/app/orchestration/persistence.ts new file mode 100644 index 0000000..f21a976 --- /dev/null +++ b/src/app/orchestration/persistence.ts @@ -0,0 +1,57 @@ +/** + * Persistence & auto-save wiring (docs/architecture/01 §5). + * + * Bridges the pure SnippetStore to the IndexedDB adapter via startup + * subscribers — the store stays browser-free, and all reads/writes funnel + * through `infrastructure/`. Two subscribers: + * + * 1. Debounced auto-save: editor keystrokes (`draftText`) settle into a + * `commitDraft()` after a pause (spec §03B). + * 2. Write-through: any change to the `snippets` array is diffed against the + * previous snapshot and persisted/deleted. Because commits and structural + * edits produce fresh object references, a reference diff catches exactly + * what changed. + */ + +import { deleteSnippet, saveSnippet } from '../infrastructure/snippet-store'; +import { useSnippetStore } from '../stores/SnippetStore'; + +/** Delay before a settled edit is committed to the stored draft (spec §03B). */ +export const AUTOSAVE_DEBOUNCE_MS = 400; + +type Unsubscribe = () => void; + +/** Debounce `commitDraft` on editor-buffer changes. */ +function wireDraftAutoSave(): Unsubscribe { + let timer: ReturnType | undefined; + return useSnippetStore.subscribe((s, prev) => { + if (s.draftText === prev.draftText) return; + clearTimeout(timer); + timer = setTimeout(() => useSnippetStore.getState().commitDraft(), AUTOSAVE_DEBOUNCE_MS); + }); +} + +/** Persist snippet upserts and deletions whenever the array changes. */ +function wireWriteThrough(): Unsubscribe { + let prevSnippets = useSnippetStore.getState().snippets; + return useSnippetStore.subscribe((s) => { + const next = s.snippets; + if (next === prevSnippets) return; + const prev = prevSnippets; + prevSnippets = next; + + for (const old of prev) { + if (!next.some((n) => n.id === old.id)) void deleteSnippet(old.id); + } + for (const n of next) { + const old = prev.find((p) => p.id === n.id); + if (old !== n) void saveSnippet(n); // new, or a changed reference + } + }); +} + +/** Wire all persistence subscribers. Returns a teardown that detaches them. */ +export function wirePersistence(): Unsubscribe { + const unsubs = [wireDraftAutoSave(), wireWriteThrough()]; + return () => unsubs.forEach((u) => u()); +} diff --git a/src/app/orchestration/startup.ts b/src/app/orchestration/startup.ts new file mode 100644 index 0000000..81ac72a --- /dev/null +++ b/src/app/orchestration/startup.ts @@ -0,0 +1,33 @@ +/** + * App startup orchestration. + * + * Loads the snippet library from IndexedDB into the store, seeds a sample + * snippet on first run (spec §02 → "On first run … seed one sample bar-chart + * snippet"), then wires the persistence subscribers. Called once from main.tsx; + * the UI renders immediately and fills in when hydration completes. + */ + +import { createSnippet } from '@core/snippet'; +import { loadSnippets, saveSnippet } from '../infrastructure/snippet-store'; +import { useSnippetStore } from '../stores/SnippetStore'; +import { wirePersistence } from './persistence'; + +let started = false; + +export async function initApp(): Promise { + if (started) return; // idempotent — guard against double-invocation (StrictMode) + started = true; + + let snippets = await loadSnippets(); + if (snippets.length === 0) { + const sample = createSnippet(); + await saveSnippet(sample); // ensure the seed survives even before the first edit + snippets = [sample]; + } + + useSnippetStore.getState().hydrate(snippets); + + // Wire persistence AFTER hydrate so write-through's baseline is the loaded set + // — otherwise it would redundantly re-save every snippet on each startup. + wirePersistence(); +} diff --git a/src/app/services/chart-renderer.ts b/src/app/services/chart-renderer.ts new file mode 100644 index 0000000..3e2f819 --- /dev/null +++ b/src/app/services/chart-renderer.ts @@ -0,0 +1,37 @@ +/** + * Chart renderer (docs/architecture/05 §2). + * + * The one place in the app that touches `vega-embed` and the chart DOM. + * Components ask it to draw a prepared spec into a node and own the returned + * handle's lifecycle: every successful embed yields a live Vega `View` that must + * be finalized before the next embed (or it leaks timers, listeners, and DOM). + */ + +import vegaEmbed, { type Result as EmbedResult } from 'vega-embed'; +import type { VisualizationSpec } from 'vega-embed'; +import type { Config } from 'vega-lite'; + +export interface RenderHandle { + /** Finalize the underlying Vega view and clear the node. */ + destroy(): void; +} + +/** Embed a prepared spec into `node`. Non-negotiable: no actions menu, SVG output. */ +export async function renderSpec( + node: HTMLElement, + spec: VisualizationSpec, + config: Config, +): Promise { + const result: EmbedResult = await vegaEmbed(node, spec, { + actions: false, // Astrolabe owns its own export/copy affordances + renderer: 'svg', + config, + }); + + return { + destroy() { + result.view.finalize(); + node.replaceChildren(); + }, + }; +} diff --git a/src/app/stores/SnippetStore.test.ts b/src/app/stores/SnippetStore.test.ts new file mode 100644 index 0000000..0df7fb0 --- /dev/null +++ b/src/app/stores/SnippetStore.test.ts @@ -0,0 +1,141 @@ +import { beforeEach, describe, expect, test } from 'vitest'; +import { createSnippet } from '@core/snippet'; +import { selectActiveSnippet, useSnippetStore } from './SnippetStore'; + +const store = () => useSnippetStore.getState(); +beforeEach(() => store().reset()); + +describe('hydrate', () => { + test('selects the newest-modified snippet by default and loads its draft', () => { + const older = createSnippet({ id: 'old', now: new Date('2026-01-01T00:00:00Z') }); + const newer = createSnippet({ id: 'new', now: new Date('2026-02-01T00:00:00Z') }); + store().hydrate([older, newer]); + + expect(store().activeSnippetId).toBe('new'); + expect(store().draftText).toBe(newer.draftSpec); + }); + + test('honors an explicit active id, including null for an empty library', () => { + store().hydrate([], null); + expect(store().activeSnippetId).toBeNull(); + expect(store().draftText).toBe(''); + }); +}); + +describe('createSnippet', () => { + test('prepends the new snippet and makes it active with its draft loaded', () => { + const a = createSnippet({ id: 'a', now: new Date('2026-01-01T00:00:00Z') }); + store().hydrate([a]); + + const id = store().createSnippet({ id: 'b', now: new Date('2026-03-01T00:00:00Z') }); + + expect(id).toBe('b'); + expect(store().activeSnippetId).toBe('b'); + expect(store().snippets[0].id).toBe('b'); + expect(store().draftText).toBe(store().snippets[0].draftSpec); + }); +}); + +describe('selectSnippet', () => { + test('switches the active snippet and replaces the editor buffer', () => { + const a = createSnippet({ id: 'a', spec: '{"a":1}', now: new Date('2026-01-01T00:00:00Z') }); + const b = createSnippet({ id: 'b', spec: '{"b":2}', now: new Date('2026-02-01T00:00:00Z') }); + store().hydrate([a, b], 'a'); + + store().selectSnippet('b'); + expect(store().activeSnippetId).toBe('b'); + expect(store().draftText).toBe('{"b":2}'); + }); + + test('flushes the outgoing draft so switching mid-edit does not lose valid work', () => { + const a = createSnippet({ id: 'a', spec: '{"a":1}', now: new Date('2026-01-01T00:00:00Z') }); + const b = createSnippet({ id: 'b', spec: '{"b":2}', now: new Date('2026-02-01T00:00:00Z') }); + store().hydrate([a, b], 'a'); + + store().updateDraft('{"a":99}'); // edit a, before the auto-save debounce fires + store().selectSnippet('b'); // switch away immediately + + expect(store().snippets.find((s) => s.id === 'a')!.spec).toBe('{"a":99}'); + expect(store().draftText).toBe('{"b":2}'); + }); + + test('an unparseable outgoing buffer is left uncommitted on switch', () => { + const a = createSnippet({ id: 'a', spec: '{"a":1}', now: new Date('2026-01-01T00:00:00Z') }); + const b = createSnippet({ id: 'b', spec: '{"b":2}', now: new Date('2026-02-01T00:00:00Z') }); + store().hydrate([a, b], 'a'); + + store().updateDraft('{"a":'); // half-typed + store().selectSnippet('b'); + + expect(store().snippets.find((s) => s.id === 'a')!.spec).toBe('{"a":1}'); // untouched + }); +}); + +describe('removeSnippet', () => { + test('removing the active snippet falls back to the newest remaining one', () => { + const a = createSnippet({ id: 'a', now: new Date('2026-01-01T00:00:00Z') }); + const b = createSnippet({ id: 'b', now: new Date('2026-02-01T00:00:00Z') }); + const c = createSnippet({ id: 'c', now: new Date('2026-03-01T00:00:00Z') }); + store().hydrate([a, b, c], 'b'); + + store().removeSnippet('b'); + expect(store().snippets.map((s) => s.id)).toEqual(['a', 'c']); + expect(store().activeSnippetId).toBe('c'); // newest remaining + }); + + test('removing a non-active snippet leaves the selection untouched', () => { + const a = createSnippet({ id: 'a', now: new Date('2026-01-01T00:00:00Z') }); + const b = createSnippet({ id: 'b', now: new Date('2026-02-01T00:00:00Z') }); + store().hydrate([a, b], 'a'); + + store().removeSnippet('b'); + expect(store().activeSnippetId).toBe('a'); + }); + + test('removing the last snippet clears the active id and buffer', () => { + const a = createSnippet({ id: 'a' }); + store().hydrate([a], 'a'); + + store().removeSnippet('a'); + expect(store().activeSnippetId).toBeNull(); + expect(store().draftText).toBe(''); + }); +}); + +describe('updateDraft + commitDraft (auto-save)', () => { + test('commit writes a valid buffer into the active snippet and bumps modified', () => { + const a = createSnippet({ id: 'a', spec: '{"a":1}', now: new Date('2026-01-01T00:00:00Z') }); + store().hydrate([a], 'a'); + + store().updateDraft('{"a":2}'); + expect(store().snippets[0].spec).toBe('{"a":1}'); // buffer-only until commit + + const committed = store().commitDraft(new Date('2026-05-01T00:00:00Z')); + expect(committed).toBe(true); + const s = selectActiveSnippet(store())!; + expect(s.spec).toBe('{"a":2}'); + expect(s.draftSpec).toBe('{"a":2}'); + expect(s.modified).toBe('2026-05-01T00:00:00.000Z'); + }); + + test('commit is skipped while the buffer is not valid JSON', () => { + const a = createSnippet({ id: 'a', spec: '{"a":1}' }); + store().hydrate([a], 'a'); + + store().updateDraft('{"a":'); // half-typed + expect(store().commitDraft()).toBe(false); + expect(store().snippets[0].spec).toBe('{"a":1}'); // stored draft untouched + }); + + test('commit is a no-op (false) when nothing changed', () => { + const a = createSnippet({ id: 'a', spec: '{"a":1}' }); + store().hydrate([a], 'a'); + expect(store().commitDraft()).toBe(false); + }); + + test('commit does nothing when no snippet is active', () => { + store().hydrate([], null); + store().updateDraft('{"a":1}'); + expect(store().commitDraft()).toBe(false); + }); +}); diff --git a/src/app/stores/SnippetStore.ts b/src/app/stores/SnippetStore.ts new file mode 100644 index 0000000..0ac0aba --- /dev/null +++ b/src/app/stores/SnippetStore.ts @@ -0,0 +1,133 @@ +/** + * Snippet library state (docs/architecture/01). + * + * Holds the durable domain state for the library: the snippets, which one is + * active, and the live editor buffer (`draftText`). Actions are the single place + * snippet state mutates, so they are unit-testable without a DOM or IndexedDB. + * + * Persistence is NOT done here — a startup subscriber (orchestration/persistence) + * observes this store and writes through to the IndexedDB adapter. That keeps the + * store pure and free of browser APIs. + * + * M1 note: there is no draft/published split yet — `commitDraft` writes the + * buffer to both `spec` and `draftSpec` ("edits save directly"). M2 introduces + * Publish and `commitDraft` will then write only `draftSpec`. + */ + +import { create } from 'zustand'; +import { createSnippet, type CreateSnippetOptions, type Snippet } from '@core/snippet'; + +export interface SnippetState { + snippets: Snippet[]; + activeSnippetId: string | null; + /** The live Monaco buffer for the active snippet's spec (may be mid-edit/invalid). */ + draftText: string; + + /** Replace the library from storage and choose an active snippet. */ + hydrate: (snippets: Snippet[], activeId?: string | null) => void; + /** Create a new snippet (sample template by default), prepend, and select it. */ + createSnippet: (options?: CreateSnippetOptions) => string; + /** Make a snippet active and load its spec into the editor buffer. */ + selectSnippet: (id: string) => void; + /** Remove a snippet; if it was active, fall back to the newest remaining one. */ + removeSnippet: (id: string) => void; + /** Update the editor buffer only (no persistence; debounced commit follows). */ + updateDraft: (text: string) => void; + /** + * Persist the editor buffer into the active snippet, if it parses as JSON. + * Returns whether it committed (a half-typed, unparseable buffer is skipped, + * per spec §03B). `now` is injectable for deterministic tests. + */ + commitDraft: (now?: Date) => boolean; + /** Reset to initial state (tests, future "new workspace"). */ + reset: () => void; +} + +/** Newest-modified first — the library's default ordering (spec §02 → Sort). */ +function byModifiedDesc(a: Snippet, b: Snippet): number { + return b.modified.localeCompare(a.modified); +} + +/** The id of the most-recently-modified snippet, or null if there are none. */ +function newestId(snippets: Snippet[]): string | null { + if (snippets.length === 0) return null; + return [...snippets].sort(byModifiedDesc)[0].id; +} + +function draftFor(snippets: Snippet[], id: string | null): string { + return snippets.find((s) => s.id === id)?.draftSpec ?? ''; +} + +export const useSnippetStore = create((set, get) => ({ + snippets: [], + activeSnippetId: null, + draftText: '', + + hydrate: (snippets, activeId) => { + const id = activeId !== undefined ? activeId : newestId(snippets); + set({ snippets, activeSnippetId: id, draftText: draftFor(snippets, id) }); + }, + + createSnippet: (options) => { + get().commitDraft(); // flush the outgoing snippet's valid edits before switching away + const snippet = createSnippet(options); + set((s) => ({ + snippets: [snippet, ...s.snippets], + activeSnippetId: snippet.id, + draftText: snippet.draftSpec, + })); + return snippet.id; + }, + + selectSnippet: (id) => { + if (id === get().activeSnippetId) return; + // Flush the outgoing snippet's valid in-progress edits before switching, so + // navigating away within the auto-save debounce window doesn't drop them + // (spec §03B — auto-save preserves in-progress work). An unparseable buffer + // is left uncommitted, exactly as the debounced auto-save would. + get().commitDraft(); + const { snippets } = get(); + set({ activeSnippetId: id, draftText: draftFor(snippets, id) }); + }, + + removeSnippet: (id) => { + set((s) => { + const snippets = s.snippets.filter((x) => x.id !== id); + if (s.activeSnippetId !== id) return { snippets }; + const activeSnippetId = newestId(snippets); + return { snippets, activeSnippetId, draftText: draftFor(snippets, activeSnippetId) }; + }); + }, + + updateDraft: (draftText) => set({ draftText }), + + commitDraft: (now) => { + const { activeSnippetId, draftText, snippets } = get(); + if (!activeSnippetId) return false; + + try { + JSON.parse(draftText); + } catch { + return false; // half-typed spec — skip, retry after the next pause + } + + const active = snippets.find((s) => s.id === activeSnippetId); + if (!active || (active.draftSpec === draftText && active.spec === draftText)) { + return false; // nothing changed + } + + const modified = (now ?? new Date()).toISOString(); + set({ + snippets: snippets.map((s) => + s.id === activeSnippetId ? { ...s, spec: draftText, draftSpec: draftText, modified } : s, + ), + }); + return true; + }, + + reset: () => set({ snippets: [], activeSnippetId: null, draftText: '' }), +})); + +/** Selector: the active snippet record, or null. Derive — never store. */ +export const selectActiveSnippet = (s: SnippetState): Snippet | null => + s.snippets.find((x) => x.id === s.activeSnippetId) ?? null; diff --git a/src/core/rendering.test.ts b/src/core/rendering.test.ts new file mode 100644 index 0000000..a8c9e8b --- /dev/null +++ b/src/core/rendering.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, test } from 'vitest'; +import { escapeVegaField, prepareSpecForRender } from './rendering'; + +describe('prepareSpecForRender', () => { + test('returns a deep copy, never the same reference', () => { + const spec = { mark: 'bar', encoding: { x: { field: 'a' } } }; + const out = prepareSpecForRender(spec); + expect(out).not.toBe(spec); + expect(out.encoding).not.toBe(spec.encoding); + expect(out).toEqual(spec); + }); + + test('never mutates the input spec (copy-not-mutate invariant)', () => { + const spec = { + data: { values: [{ a: 1 }] }, + mark: 'bar', + encoding: { x: { field: 'a', type: 'quantitative' } }, + }; + const before = structuredClone(spec); + const out = prepareSpecForRender(spec, { fitMode: 'width' }); + + // Mutating the output must not touch the input. + (out as { mark: string }).mark = 'point'; + expect(spec).toEqual(before); + }); + + test('M1 is a faithful pass-through of the spec content', () => { + const spec = { $schema: 'x', mark: 'line', width: 200, height: 100 }; + expect(prepareSpecForRender(spec)).toEqual(spec); + }); +}); + +describe('escapeVegaField', () => { + test('escapes dots and brackets that VL treats as accessors', () => { + expect(escapeVegaField('user.age')).toBe('user\\.age'); + expect(escapeVegaField('a[0]')).toBe('a\\[0\\]'); + }); + + test('leaves plain field names untouched', () => { + expect(escapeVegaField('category')).toBe('category'); + }); +}); diff --git a/src/core/rendering.ts b/src/core/rendering.ts new file mode 100644 index 0000000..d96abf9 --- /dev/null +++ b/src/core/rendering.ts @@ -0,0 +1,47 @@ +/** + * Rendering contract — pure spec preparation (spec §04 → Rendering Contract). + * + * Portable core: no browser APIs, no React, no vega-embed. `prepareSpecForRender` + * is the single transform that sits between "parsed spec the user authored" and + * "spec the preview actually embeds" (see docs/architecture/05). It performs two + * deterministic steps, in order, **on a deep copy** so the user's stored spec is + * never mutated by rendering: + * + * 1. Dataset reference resolution — arrives in M3 (no-op here). + * 2. Fit-mode sizing — arrives in M2 (no-op here). + * + * In M1 it is an identity transform over a copy: it establishes the + * copy-not-mutate invariant and the call site the renderer depends on, so M2/M3 + * can fill in the steps without the preview pipeline changing shape. + */ + +/** Preview sizing modes (spec §04 → Fit / Sizing Modes). `default` = Original. */ +export type FitMode = 'default' | 'width' | 'height' | 'full'; + +export interface PrepareOptions { + /** Active fit mode. Applied in M2; ignored in M1. */ + fitMode?: FitMode; +} + +/** + * Escape `.`/`[`/`]` so Vega-Lite treats a string as a literal field name rather + * than a nested-property accessor (docs/architecture/05 §4). Used wherever + * Astrolabe *constructs* a `field:` from a data-derived column name (chart + * builder, M4); hand-authored specs are the user's responsibility. + */ +export function escapeVegaField(name: string): string { + return name.replace(/([.[\]])/g, '\\$1'); +} + +/** + * Transform the authored spec into the spec to embed. Operates on a deep copy + * and returns it; the input is never mutated. + */ +export function prepareSpecForRender(spec: T, _options: PrepareOptions = {}): T { + const copy = structuredClone(spec); + + // M3: resolveDatasetRefs(copy, datasets) + // M2: applyFitMode(copy, options.fitMode) + + return copy; +} diff --git a/src/core/snippet.test.ts b/src/core/snippet.test.ts new file mode 100644 index 0000000..6b255fd --- /dev/null +++ b/src/core/snippet.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, test } from 'vitest'; +import { + CURRENT_SNIPPET_VERSION, + createSnippet, + generateSnippetName, + hasUnpublishedChanges, + SAMPLE_SPEC, + sampleSpecText, +} from './snippet'; + +describe('createSnippet', () => { + test('stamps current version, equal timestamps, and the sample template', () => { + const now = new Date('2026-06-04T14:30:07.000Z'); + const s = createSnippet({ now, id: 'fixed-id' }); + + expect(s.id).toBe('fixed-id'); + expect(s.version).toBe(CURRENT_SNIPPET_VERSION); + expect(s.created).toBe(now.toISOString()); + expect(s.modified).toBe(s.created); + expect(s.spec).toBe(sampleSpecText()); + // draft starts identical to published — nothing to publish yet. + expect(s.draftSpec).toBe(s.spec); + expect(s.comment).toBe(''); + expect(s.tags).toEqual([]); + expect(s.datasetRefs).toEqual([]); + expect(s.meta).toEqual({}); + }); + + test('generates a unique id by default', () => { + const a = createSnippet(); + const b = createSnippet(); + expect(a.id).not.toBe(b.id); + }); + + test('accepts name and spec overrides', () => { + const s = createSnippet({ name: 'Custom', spec: '{"mark":"point"}' }); + expect(s.name).toBe('Custom'); + expect(s.spec).toBe('{"mark":"point"}'); + expect(s.draftSpec).toBe('{"mark":"point"}'); + }); +}); + +describe('the sample template', () => { + test('is valid JSON that round-trips', () => { + expect(JSON.parse(sampleSpecText())).toEqual(SAMPLE_SPEC); + }); + + test('is a bar chart with inline data', () => { + expect(SAMPLE_SPEC.mark).toBe('bar'); + expect(SAMPLE_SPEC.data.values.length).toBeGreaterThan(0); + }); +}); + +describe('generateSnippetName', () => { + test('formats as "Snippet YYYY-MM-DD HH:MM:SS" with zero-padding', () => { + // Construct via local-time parts so the test is timezone-independent. + const now = new Date(2026, 0, 5, 9, 7, 3); // 2026-01-05 09:07:03 local + expect(generateSnippetName(now)).toBe('Snippet 2026-01-05 09:07:03'); + }); + + test('differs second-to-second so quick successive creates stay distinct', () => { + const a = generateSnippetName(new Date(2026, 5, 4, 14, 30, 7)); + const b = generateSnippetName(new Date(2026, 5, 4, 14, 30, 8)); + expect(a).not.toBe(b); + }); +}); + +describe('hasUnpublishedChanges', () => { + test('false when draft matches published, true once draft diverges', () => { + const s = createSnippet({ now: new Date('2026-06-04T00:00:00Z') }); + expect(hasUnpublishedChanges(s)).toBe(false); + expect(hasUnpublishedChanges({ ...s, draftSpec: s.spec + ' ' })).toBe(true); + }); +}); diff --git a/src/core/snippet.ts b/src/core/snippet.ts new file mode 100644 index 0000000..6cb9e5a --- /dev/null +++ b/src/core/snippet.ts @@ -0,0 +1,123 @@ +/** + * Snippet — the primary user-authored entity (spec §09A). + * + * Portable core: no browser APIs, no React. Defines the record shape, the + * current record schema version, and pure factories for creating new snippets + * (with the sample bar-chart template and an auto-generated date/time name). + * + * Specs are stored as **JSON text** (string). The data model permits a spec to + * be an object or a string; we standardize on the string form because it is what + * the Monaco editor edits and what survives round-tripping without reformatting. + * The preview parses the text into an object before rendering (see rendering.ts). + */ + +/** Current schema version for a Snippet record (read-time migration target). */ +export const CURRENT_SNIPPET_VERSION = 1; + +export interface Snippet { + /** Unique, stable identifier. */ + id: string; + /** Record schema version, for read-time migration. */ + version: number; + /** Human-readable title shown in the library. */ + name: string; + /** ISO timestamp — when first created. */ + created: string; + /** ISO timestamp — when last saved. */ + modified: string; + /** The published (stable) Vega-Lite spec, as JSON text. */ + spec: string; + /** The working-draft Vega-Lite spec being edited, as JSON text. */ + draftSpec: string; + /** Free-form user note. */ + comment: string; + /** User-assigned labels. */ + tags: string[]; + /** Names of datasets referenced by this spec (maintained on publish). */ + datasetRefs: string[]; + /** Free-form, extensible metadata bag. */ + meta: Record; +} + +/** + * The sample bar-chart template a fresh snippet starts from (spec §02 → Create + * New: "a small sample Vega-Lite bar-chart template with a few inline rows"). + * Inline data only — datasets arrive in M3. + */ +export const SAMPLE_SPEC = { + $schema: 'https://vega.github.io/schema/vega-lite/v6.json', + description: 'A simple bar chart.', + data: { + values: [ + { category: 'A', value: 28 }, + { category: 'B', value: 55 }, + { category: 'C', value: 43 }, + { category: 'D', value: 91 }, + { category: 'E', value: 81 }, + ], + }, + mark: 'bar', + encoding: { + x: { field: 'category', type: 'nominal', axis: { labelAngle: 0 } }, + y: { field: 'value', type: 'quantitative' }, + }, +} as const; + +/** The sample template rendered as pretty-printed JSON text. */ +export function sampleSpecText(): string { + return JSON.stringify(SAMPLE_SPEC, null, 2); +} + +/** Two-digit zero-pad for the date/time name. */ +function pad(n: number): string { + return String(n).padStart(2, '0'); +} + +/** + * Auto-generated default name from a timestamp, e.g. "Snippet 2026-06-04 14:30:07". + * Including seconds keeps names unique for snippets created in quick succession. + */ +export function generateSnippetName(now: Date): string { + const date = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`; + const time = `${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`; + return `Snippet ${date} ${time}`; +} + +export interface CreateSnippetOptions { + /** Override the auto-generated name. */ + name?: string; + /** Override the starting spec text (defaults to the sample template). */ + spec?: string; + /** Clock injection for deterministic tests; defaults to the current time. */ + now?: Date; + /** Id injection for deterministic tests; defaults to a random UUID. */ + id?: string; +} + +/** + * Create a new snippet. `spec` and `draftSpec` start identical (nothing to + * publish yet); timestamps are equal at creation. + */ +export function createSnippet(options: CreateSnippetOptions = {}): Snippet { + const now = options.now ?? new Date(); + const iso = now.toISOString(); + const spec = options.spec ?? sampleSpecText(); + return { + id: options.id ?? crypto.randomUUID(), + version: CURRENT_SNIPPET_VERSION, + name: options.name ?? generateSnippetName(now), + created: iso, + modified: iso, + spec, + draftSpec: spec, + comment: '', + tags: [], + datasetRefs: [], + meta: {}, + }; +} + +/** True when the snippet's draft differs from its published spec (§03D). */ +export function hasUnpublishedChanges(snippet: Snippet): boolean { + return snippet.draftSpec !== snippet.spec; +} diff --git a/src/core/vega-themes.ts b/src/core/vega-themes.ts new file mode 100644 index 0000000..667b135 --- /dev/null +++ b/src/core/vega-themes.ts @@ -0,0 +1,60 @@ +/** + * Vega-Lite chart config per UI theme (docs/architecture/05 §3). + * + * Portable core: a Vega-Lite `Config` styles every chart globally so charts + * visually belong to the app rather than looking like stock Vega-Lite. This is + * the single source of truth mapping a `UiTheme` to a config; it is applied at + * embed time (never baked into the user's stored spec). Adding a UI theme = one + * config object plus one map entry here. + */ + +import type { Config } from 'vega-lite'; +import type { UiTheme } from './theme'; + +export const lightChartConfig: Config = { + background: 'transparent', + font: '"Inter", system-ui, sans-serif', + title: { fontSize: 15, fontWeight: 600, color: '#1c1c1e' }, + axis: { + domainColor: '#1c1c1e', + gridColor: '#e4e4e7', + gridDash: [3, 3], + labelColor: '#52525b', + titleColor: '#1c1c1e', + labelFontSize: 11, + titleFontSize: 12, + }, + range: { + category: ['#2f6df6', '#f5a524', '#17b890', '#e5484d', '#8b5cf6', '#0ea5e9'], + }, + view: { stroke: 'transparent' }, +}; + +export const experimentalChartConfig: Config = { + background: 'transparent', + font: '"Inter", system-ui, sans-serif', + title: { fontSize: 15, fontWeight: 600, color: '#f4f4f5' }, + axis: { + domainColor: '#a1a1aa', + gridColor: '#3f3f46', + gridDash: [3, 3], + labelColor: '#a1a1aa', + titleColor: '#f4f4f5', + labelFontSize: 11, + titleFontSize: 12, + }, + range: { + category: ['#5b8def', '#f5a524', '#2dd4a7', '#f0666b', '#a78bfa', '#38bdf8'], + }, + view: { stroke: 'transparent' }, +}; + +const CHART_CONFIG: Record = { + light: lightChartConfig, + experimental: experimentalChartConfig, +}; + +/** The Vega-Lite config for a UI theme — the only theme → config mapping. */ +export function chartConfigFor(theme: UiTheme): Config { + return CHART_CONFIG[theme]; +} diff --git a/src/main.tsx b/src/main.tsx index f5e7bc5..d39cebc 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,5 +1,6 @@ import { createRoot } from 'react-dom/client'; import { App } from './app/App'; +import { initApp } from './app/orchestration/startup'; import { useAppStore } from './app/stores/AppStore'; import '../styles/base.css'; @@ -13,4 +14,9 @@ useAppStore.subscribe((state, prev) => { if (state.uiTheme !== prev.uiTheme) applyTheme(state.uiTheme); }); +// Load the library from IndexedDB (seeding a sample on first run) and wire +// persistence. Fire-and-forget: the UI renders immediately and fills in when +// hydration resolves. +void initApp(); + createRoot(document.getElementById('app')!).render(); diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 229b575..25999b8 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -7,3 +7,17 @@ declare module '*.module.css' { const classes: { readonly [key: string]: string }; export default classes; } + +// The bundled Vega-Lite JSON schema is ~1.5 MB. Declaring it as a generic object +// stops `tsc` from inferring a giant literal type (slow/memory-heavy) on import. +declare module 'vega-lite/vega-lite-schema.json' { + const schema: Record; + export default schema; +} + +// `edcore.main` (full editor features, no bundled languages) ships no types of +// its own; at runtime it re-exports the editor API, so map its types there. +// This also re-pulls monaco's global `MonacoEnvironment` augmentation. +declare module 'monaco-editor/esm/vs/editor/edcore.main' { + export * from 'monaco-editor/esm/vs/editor/editor.api'; +} diff --git a/vite.config.ts b/vite.config.ts index aade8e8..60f2fbc 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -16,6 +16,19 @@ export default defineConfig({ define: { __APP_VERSION__: JSON.stringify(pkg.version), }, + build: { + rollupOptions: { + output: { + // Split the heavy vendors so the chunk graph stays legible and the PWA + // can precache/update them independently of app code. Monaco and the + // Vega stack are each multi-MB; isolating them keeps app rebuilds small. + manualChunks: { + monaco: ['monaco-editor'], + vega: ['vega', 'vega-lite', 'vega-embed'], + }, + }, + }, + }, plugins: [ react(), VitePWA({ @@ -30,6 +43,11 @@ export default defineConfig({ display: 'standalone', icons: [], }, + workbox: { + // Monaco and Vega vendor chunks exceed Workbox's 2 MiB default; raise the + // ceiling so the whole app (offline is a core requirement) is precached. + maximumFileSizeToCacheInBytes: 6 * 1024 * 1024, + }, }), ], // Vitest config (shares this file)