mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
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:
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user