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