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
+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();
}