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:
2026-06-05 00:16:24 +03:00
parent 056644450c
commit ca54bb66b1
34 changed files with 1557 additions and 74 deletions
+14 -8
View File
@@ -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);
}
+10 -7
View File
@@ -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() {
<main className={styles.panes}>
<section className={styles.pane} aria-label="Snippet library">
<div className={styles.paneLabel}>Library</div>
<SnippetLibrary />
</section>
<section className={styles.pane} aria-label="Spec editor">
<div className={styles.paneLabel}>Editor</div>
<section className={styles.paneEditor} aria-label="Spec editor">
<SpecEditor />
</section>
<section className={styles.pane} aria-label="Live preview">
<div className={styles.paneLabel}>Preview</div>
<LivePreview />
</section>
</main>
</div>
+24
View File
@@ -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;
}
+103
View File
@@ -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);
}
+78
View File
@@ -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>
);
}
+22
View File
@@ -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;
}
+90
View File
@@ -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>
);
}
+80
View File
@@ -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;
}
+18
View File
@@ -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()),
};
+63
View File
@@ -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>) : {},
};
}
+42
View File
@@ -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);
}
+57
View File
@@ -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());
}
+33
View File
@@ -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();
}
+37
View File
@@ -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();
},
};
}
+141
View File
@@ -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);
});
});
+133
View File
@@ -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;