mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Implement M1 authoring loop: library, editor, live preview, persistence
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/
This commit is contained in:
@@ -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
|
|
||||||
@@ -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<State>()` 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.
|
|
||||||
@@ -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.
|
|
||||||
@@ -7,3 +7,7 @@ dev-dist
|
|||||||
*.log
|
*.log
|
||||||
coverage
|
coverage
|
||||||
.vite
|
.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/
|
||||||
|
|||||||
@@ -79,7 +79,16 @@ npm run format # Prettier
|
|||||||
### AI Developer Protocol
|
### AI Developer Protocol
|
||||||
|
|
||||||
- **No git on your own initiative** — don't `add`/`commit`/`push` unless explicitly invited.
|
- **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
|
- **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.
|
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.
|
- **Core-first** — for each feature, build the pure `src/core/` logic with tests before UI.
|
||||||
|
|||||||
@@ -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)
|
(one source of truth, derive-don't-duplicate, actions outside components, thin components)
|
||||||
are the durable part — they would survive a change of library.
|
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
|
## 1. The Primitives
|
||||||
|
|||||||
@@ -73,6 +73,29 @@ self-hosting.
|
|||||||
**Accepted cost:** the explicit worker wiring (§1) is inherent to self-hosting — it is the
|
**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.
|
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
|
> **The single biggest surprise:** vega/editor does **not** use `vega-embed` for its live
|
||||||
|
|||||||
+14
-8
@@ -39,20 +39,26 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.pane {
|
.pane {
|
||||||
flex: 1;
|
flex: 1 1 0;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding: var(--space-4);
|
padding: var(--space-4);
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
border-right: 1px solid var(--color-border);
|
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 {
|
.pane:last-child {
|
||||||
border-right: none;
|
border-right: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.paneLabel {
|
|
||||||
font-size: 11px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
}
|
|
||||||
|
|||||||
+10
-7
@@ -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';
|
import styles from './App.module.css';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Application shell — the three-pane workspace from spec §01A
|
* Application shell — the three-pane workspace from spec §01A
|
||||||
* (library · editor · preview) under a fixed header.
|
* (library · editor · preview) under a fixed header.
|
||||||
*
|
*
|
||||||
* This is the skeleton: panes are placeholders. Each milestone fills one in
|
* M1 fills the panes with the MVP authoring loop. Pane resizing/toggling,
|
||||||
* (see docs/IMPLEMENTATION-PLAN.md). Resizing, toggling, modals, routing, and
|
* modals, routing, and shortcuts arrive in later milestones (see
|
||||||
* shortcuts arrive in later milestones.
|
* docs/IMPLEMENTATION-PLAN.md).
|
||||||
*/
|
*/
|
||||||
export function App() {
|
export function App() {
|
||||||
return (
|
return (
|
||||||
@@ -19,13 +22,13 @@ export function App() {
|
|||||||
|
|
||||||
<main className={styles.panes}>
|
<main className={styles.panes}>
|
||||||
<section className={styles.pane} aria-label="Snippet library">
|
<section className={styles.pane} aria-label="Snippet library">
|
||||||
<div className={styles.paneLabel}>Library</div>
|
<SnippetLibrary />
|
||||||
</section>
|
</section>
|
||||||
<section className={styles.pane} aria-label="Spec editor">
|
<section className={styles.paneEditor} aria-label="Spec editor">
|
||||||
<div className={styles.paneLabel}>Editor</div>
|
<SpecEditor />
|
||||||
</section>
|
</section>
|
||||||
<section className={styles.pane} aria-label="Live preview">
|
<section className={styles.pane} aria-label="Live preview">
|
||||||
<div className={styles.paneLabel}>Preview</div>
|
<LivePreview />
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<HTMLDivElement>(null);
|
||||||
|
const handleRef = useRef<RenderHandle | null>(null);
|
||||||
|
const generationRef = useRef(0);
|
||||||
|
const draftText = useSnippetStore((s) => s.draftText);
|
||||||
|
const uiTheme = useAppStore((s) => s.uiTheme);
|
||||||
|
const [error, setError] = useState<string | null>(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 (
|
||||||
|
<div className={styles.preview}>
|
||||||
|
<div className={styles.chart} ref={hostRef} hidden={error !== null} />
|
||||||
|
{error !== null && <pre className={styles.error}>{error}</pre>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className={styles.library}>
|
||||||
|
<button className={styles.createNew} onClick={() => createSnippet()}>
|
||||||
|
+ Create New Snippet
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<ul className={styles.list}>
|
||||||
|
{ordered.length === 0 && <li className={styles.empty}>No snippets found</li>}
|
||||||
|
{ordered.map((s) => (
|
||||||
|
<li
|
||||||
|
key={s.id}
|
||||||
|
className={`${styles.item} ${s.id === activeId ? styles.active : ''}`}
|
||||||
|
aria-current={s.id === activeId}
|
||||||
|
onClick={() => selectSnippet(s.id)}
|
||||||
|
>
|
||||||
|
<div className={styles.itemMain}>
|
||||||
|
<span className={styles.name}>{s.name}</span>
|
||||||
|
<span className={styles.date}>{relativeDate(s.modified)}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className={styles.delete}
|
||||||
|
aria-label={`Delete ${s.name}`}
|
||||||
|
title="Delete snippet"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleDelete(s.id, s.name);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<HTMLDivElement>(null);
|
||||||
|
const editorRef = useRef<monaco.editor.IStandaloneCodeEditor | null>(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 (
|
||||||
|
<div className={styles.editorPane}>
|
||||||
|
{activeId === null && <div className={styles.placeholder}>Select or create a snippet</div>}
|
||||||
|
<div className={styles.editor} ref={hostRef} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<IDBDatabase> | null = null;
|
||||||
|
|
||||||
|
/** Open (and memoize) the database, creating object stores on first run. */
|
||||||
|
export function openDB(): Promise<IDBDatabase> {
|
||||||
|
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<T>(
|
||||||
|
store: string,
|
||||||
|
mode: IDBTransactionMode,
|
||||||
|
run: (s: IDBObjectStore) => IDBRequest<T>,
|
||||||
|
): Promise<T> {
|
||||||
|
return openDB().then(
|
||||||
|
(db) =>
|
||||||
|
new Promise<T>((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 = <T>(store: string, key: IDBValidKey): Promise<T | undefined> =>
|
||||||
|
tx<T | undefined>(store, 'readonly', (s) => s.get(key) as IDBRequest<T | undefined>);
|
||||||
|
|
||||||
|
export const getAll = <T>(store: string): Promise<T[]> =>
|
||||||
|
tx<T[]>(store, 'readonly', (s) => s.getAll() as IDBRequest<T[]>);
|
||||||
|
|
||||||
|
export const put = <T>(store: string, value: T): Promise<IDBValidKey> =>
|
||||||
|
tx<IDBValidKey>(store, 'readwrite', (s) => s.put(value as unknown as Record<string, unknown>));
|
||||||
|
|
||||||
|
export const del = (store: string, key: IDBValidKey): Promise<undefined> =>
|
||||||
|
tx<undefined>(store, 'readwrite', (s) => s.delete(key) as IDBRequest<undefined>);
|
||||||
|
|
||||||
|
/** Test-only: forget the memoized connection so a fresh `openDB` reopens. */
|
||||||
|
export function _resetDbForTests(): void {
|
||||||
|
dbPromise = null;
|
||||||
|
}
|
||||||
@@ -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()),
|
||||||
|
};
|
||||||
@@ -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<string, unknown>;
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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<string, unknown>).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}');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string, unknown>) };
|
||||||
|
// 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<string, unknown>) : {},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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<Snippet[]> {
|
||||||
|
const records = await getAll<unknown>(SNIPPETS_STORE);
|
||||||
|
return records.map(migrateSnippet);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Persist a snippet at the current schema version. Propagates quota failures. */
|
||||||
|
export async function saveSnippet(snippet: Snippet): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
await del(SNIPPETS_STORE, id);
|
||||||
|
}
|
||||||
@@ -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<typeof setTimeout> | 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());
|
||||||
|
}
|
||||||
@@ -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<void> {
|
||||||
|
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();
|
||||||
|
}
|
||||||
@@ -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<RenderHandle> {
|
||||||
|
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();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<SnippetState>((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;
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<T>(spec: T, _options: PrepareOptions = {}): T {
|
||||||
|
const copy = structuredClone(spec);
|
||||||
|
|
||||||
|
// M3: resolveDatasetRefs(copy, datasets)
|
||||||
|
// M2: applyFitMode(copy, options.fitMode)
|
||||||
|
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
@@ -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<UiTheme, Config> = {
|
||||||
|
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];
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
import { App } from './app/App';
|
import { App } from './app/App';
|
||||||
|
import { initApp } from './app/orchestration/startup';
|
||||||
import { useAppStore } from './app/stores/AppStore';
|
import { useAppStore } from './app/stores/AppStore';
|
||||||
import '../styles/base.css';
|
import '../styles/base.css';
|
||||||
|
|
||||||
@@ -13,4 +14,9 @@ useAppStore.subscribe((state, prev) => {
|
|||||||
if (state.uiTheme !== prev.uiTheme) applyTheme(state.uiTheme);
|
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(<App />);
|
createRoot(document.getElementById('app')!).render(<App />);
|
||||||
|
|||||||
Vendored
+14
@@ -7,3 +7,17 @@ declare module '*.module.css' {
|
|||||||
const classes: { readonly [key: string]: string };
|
const classes: { readonly [key: string]: string };
|
||||||
export default classes;
|
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<string, unknown>;
|
||||||
|
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';
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,6 +16,19 @@ export default defineConfig({
|
|||||||
define: {
|
define: {
|
||||||
__APP_VERSION__: JSON.stringify(pkg.version),
|
__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: [
|
plugins: [
|
||||||
react(),
|
react(),
|
||||||
VitePWA({
|
VitePWA({
|
||||||
@@ -30,6 +43,11 @@ export default defineConfig({
|
|||||||
display: 'standalone',
|
display: 'standalone',
|
||||||
icons: [],
|
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)
|
// Vitest config (shares this file)
|
||||||
|
|||||||
Reference in New Issue
Block a user