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
+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;