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
+123
View File
@@ -0,0 +1,123 @@
/**
* Snippet — the primary user-authored entity (spec §09A).
*
* Portable core: no browser APIs, no React. Defines the record shape, the
* current record schema version, and pure factories for creating new snippets
* (with the sample bar-chart template and an auto-generated date/time name).
*
* Specs are stored as **JSON text** (string). The data model permits a spec to
* be an object or a string; we standardize on the string form because it is what
* the Monaco editor edits and what survives round-tripping without reformatting.
* The preview parses the text into an object before rendering (see rendering.ts).
*/
/** Current schema version for a Snippet record (read-time migration target). */
export const CURRENT_SNIPPET_VERSION = 1;
export interface Snippet {
/** Unique, stable identifier. */
id: string;
/** Record schema version, for read-time migration. */
version: number;
/** Human-readable title shown in the library. */
name: string;
/** ISO timestamp — when first created. */
created: string;
/** ISO timestamp — when last saved. */
modified: string;
/** The published (stable) Vega-Lite spec, as JSON text. */
spec: string;
/** The working-draft Vega-Lite spec being edited, as JSON text. */
draftSpec: string;
/** Free-form user note. */
comment: string;
/** User-assigned labels. */
tags: string[];
/** Names of datasets referenced by this spec (maintained on publish). */
datasetRefs: string[];
/** Free-form, extensible metadata bag. */
meta: Record<string, unknown>;
}
/**
* The sample bar-chart template a fresh snippet starts from (spec §02 → Create
* New: "a small sample Vega-Lite bar-chart template with a few inline rows").
* Inline data only — datasets arrive in M3.
*/
export const SAMPLE_SPEC = {
$schema: 'https://vega.github.io/schema/vega-lite/v6.json',
description: 'A simple bar chart.',
data: {
values: [
{ category: 'A', value: 28 },
{ category: 'B', value: 55 },
{ category: 'C', value: 43 },
{ category: 'D', value: 91 },
{ category: 'E', value: 81 },
],
},
mark: 'bar',
encoding: {
x: { field: 'category', type: 'nominal', axis: { labelAngle: 0 } },
y: { field: 'value', type: 'quantitative' },
},
} as const;
/** The sample template rendered as pretty-printed JSON text. */
export function sampleSpecText(): string {
return JSON.stringify(SAMPLE_SPEC, null, 2);
}
/** Two-digit zero-pad for the date/time name. */
function pad(n: number): string {
return String(n).padStart(2, '0');
}
/**
* Auto-generated default name from a timestamp, e.g. "Snippet 2026-06-04 14:30:07".
* Including seconds keeps names unique for snippets created in quick succession.
*/
export function generateSnippetName(now: Date): string {
const date = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
const time = `${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`;
return `Snippet ${date} ${time}`;
}
export interface CreateSnippetOptions {
/** Override the auto-generated name. */
name?: string;
/** Override the starting spec text (defaults to the sample template). */
spec?: string;
/** Clock injection for deterministic tests; defaults to the current time. */
now?: Date;
/** Id injection for deterministic tests; defaults to a random UUID. */
id?: string;
}
/**
* Create a new snippet. `spec` and `draftSpec` start identical (nothing to
* publish yet); timestamps are equal at creation.
*/
export function createSnippet(options: CreateSnippetOptions = {}): Snippet {
const now = options.now ?? new Date();
const iso = now.toISOString();
const spec = options.spec ?? sampleSpecText();
return {
id: options.id ?? crypto.randomUUID(),
version: CURRENT_SNIPPET_VERSION,
name: options.name ?? generateSnippetName(now),
created: iso,
modified: iso,
spec,
draftSpec: spec,
comment: '',
tags: [],
datasetRefs: [],
meta: {},
};
}
/** True when the snippet's draft differs from its published spec (§03D). */
export function hasUnpublishedChanges(snippet: Snippet): boolean {
return snippet.draftSpec !== snippet.spec;
}