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