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,42 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { escapeVegaField, prepareSpecForRender } from './rendering';
|
||||
|
||||
describe('prepareSpecForRender', () => {
|
||||
test('returns a deep copy, never the same reference', () => {
|
||||
const spec = { mark: 'bar', encoding: { x: { field: 'a' } } };
|
||||
const out = prepareSpecForRender(spec);
|
||||
expect(out).not.toBe(spec);
|
||||
expect(out.encoding).not.toBe(spec.encoding);
|
||||
expect(out).toEqual(spec);
|
||||
});
|
||||
|
||||
test('never mutates the input spec (copy-not-mutate invariant)', () => {
|
||||
const spec = {
|
||||
data: { values: [{ a: 1 }] },
|
||||
mark: 'bar',
|
||||
encoding: { x: { field: 'a', type: 'quantitative' } },
|
||||
};
|
||||
const before = structuredClone(spec);
|
||||
const out = prepareSpecForRender(spec, { fitMode: 'width' });
|
||||
|
||||
// Mutating the output must not touch the input.
|
||||
(out as { mark: string }).mark = 'point';
|
||||
expect(spec).toEqual(before);
|
||||
});
|
||||
|
||||
test('M1 is a faithful pass-through of the spec content', () => {
|
||||
const spec = { $schema: 'x', mark: 'line', width: 200, height: 100 };
|
||||
expect(prepareSpecForRender(spec)).toEqual(spec);
|
||||
});
|
||||
});
|
||||
|
||||
describe('escapeVegaField', () => {
|
||||
test('escapes dots and brackets that VL treats as accessors', () => {
|
||||
expect(escapeVegaField('user.age')).toBe('user\\.age');
|
||||
expect(escapeVegaField('a[0]')).toBe('a\\[0\\]');
|
||||
});
|
||||
|
||||
test('leaves plain field names untouched', () => {
|
||||
expect(escapeVegaField('category')).toBe('category');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Rendering contract — pure spec preparation (spec §04 → Rendering Contract).
|
||||
*
|
||||
* Portable core: no browser APIs, no React, no vega-embed. `prepareSpecForRender`
|
||||
* is the single transform that sits between "parsed spec the user authored" and
|
||||
* "spec the preview actually embeds" (see docs/architecture/05). It performs two
|
||||
* deterministic steps, in order, **on a deep copy** so the user's stored spec is
|
||||
* never mutated by rendering:
|
||||
*
|
||||
* 1. Dataset reference resolution — arrives in M3 (no-op here).
|
||||
* 2. Fit-mode sizing — arrives in M2 (no-op here).
|
||||
*
|
||||
* In M1 it is an identity transform over a copy: it establishes the
|
||||
* copy-not-mutate invariant and the call site the renderer depends on, so M2/M3
|
||||
* can fill in the steps without the preview pipeline changing shape.
|
||||
*/
|
||||
|
||||
/** Preview sizing modes (spec §04 → Fit / Sizing Modes). `default` = Original. */
|
||||
export type FitMode = 'default' | 'width' | 'height' | 'full';
|
||||
|
||||
export interface PrepareOptions {
|
||||
/** Active fit mode. Applied in M2; ignored in M1. */
|
||||
fitMode?: FitMode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape `.`/`[`/`]` so Vega-Lite treats a string as a literal field name rather
|
||||
* than a nested-property accessor (docs/architecture/05 §4). Used wherever
|
||||
* Astrolabe *constructs* a `field:` from a data-derived column name (chart
|
||||
* builder, M4); hand-authored specs are the user's responsibility.
|
||||
*/
|
||||
export function escapeVegaField(name: string): string {
|
||||
return name.replace(/([.[\]])/g, '\\$1');
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform the authored spec into the spec to embed. Operates on a deep copy
|
||||
* and returns it; the input is never mutated.
|
||||
*/
|
||||
export function prepareSpecForRender<T>(spec: T, _options: PrepareOptions = {}): T {
|
||||
const copy = structuredClone(spec);
|
||||
|
||||
// M3: resolveDatasetRefs(copy, datasets)
|
||||
// M2: applyFitMode(copy, options.fitMode)
|
||||
|
||||
return copy;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import {
|
||||
CURRENT_SNIPPET_VERSION,
|
||||
createSnippet,
|
||||
generateSnippetName,
|
||||
hasUnpublishedChanges,
|
||||
SAMPLE_SPEC,
|
||||
sampleSpecText,
|
||||
} from './snippet';
|
||||
|
||||
describe('createSnippet', () => {
|
||||
test('stamps current version, equal timestamps, and the sample template', () => {
|
||||
const now = new Date('2026-06-04T14:30:07.000Z');
|
||||
const s = createSnippet({ now, id: 'fixed-id' });
|
||||
|
||||
expect(s.id).toBe('fixed-id');
|
||||
expect(s.version).toBe(CURRENT_SNIPPET_VERSION);
|
||||
expect(s.created).toBe(now.toISOString());
|
||||
expect(s.modified).toBe(s.created);
|
||||
expect(s.spec).toBe(sampleSpecText());
|
||||
// draft starts identical to published — nothing to publish yet.
|
||||
expect(s.draftSpec).toBe(s.spec);
|
||||
expect(s.comment).toBe('');
|
||||
expect(s.tags).toEqual([]);
|
||||
expect(s.datasetRefs).toEqual([]);
|
||||
expect(s.meta).toEqual({});
|
||||
});
|
||||
|
||||
test('generates a unique id by default', () => {
|
||||
const a = createSnippet();
|
||||
const b = createSnippet();
|
||||
expect(a.id).not.toBe(b.id);
|
||||
});
|
||||
|
||||
test('accepts name and spec overrides', () => {
|
||||
const s = createSnippet({ name: 'Custom', spec: '{"mark":"point"}' });
|
||||
expect(s.name).toBe('Custom');
|
||||
expect(s.spec).toBe('{"mark":"point"}');
|
||||
expect(s.draftSpec).toBe('{"mark":"point"}');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the sample template', () => {
|
||||
test('is valid JSON that round-trips', () => {
|
||||
expect(JSON.parse(sampleSpecText())).toEqual(SAMPLE_SPEC);
|
||||
});
|
||||
|
||||
test('is a bar chart with inline data', () => {
|
||||
expect(SAMPLE_SPEC.mark).toBe('bar');
|
||||
expect(SAMPLE_SPEC.data.values.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateSnippetName', () => {
|
||||
test('formats as "Snippet YYYY-MM-DD HH:MM:SS" with zero-padding', () => {
|
||||
// Construct via local-time parts so the test is timezone-independent.
|
||||
const now = new Date(2026, 0, 5, 9, 7, 3); // 2026-01-05 09:07:03 local
|
||||
expect(generateSnippetName(now)).toBe('Snippet 2026-01-05 09:07:03');
|
||||
});
|
||||
|
||||
test('differs second-to-second so quick successive creates stay distinct', () => {
|
||||
const a = generateSnippetName(new Date(2026, 5, 4, 14, 30, 7));
|
||||
const b = generateSnippetName(new Date(2026, 5, 4, 14, 30, 8));
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasUnpublishedChanges', () => {
|
||||
test('false when draft matches published, true once draft diverges', () => {
|
||||
const s = createSnippet({ now: new Date('2026-06-04T00:00:00Z') });
|
||||
expect(hasUnpublishedChanges(s)).toBe(false);
|
||||
expect(hasUnpublishedChanges({ ...s, draftSpec: s.spec + ' ' })).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Vega-Lite chart config per UI theme (docs/architecture/05 §3).
|
||||
*
|
||||
* Portable core: a Vega-Lite `Config` styles every chart globally so charts
|
||||
* visually belong to the app rather than looking like stock Vega-Lite. This is
|
||||
* the single source of truth mapping a `UiTheme` to a config; it is applied at
|
||||
* embed time (never baked into the user's stored spec). Adding a UI theme = one
|
||||
* config object plus one map entry here.
|
||||
*/
|
||||
|
||||
import type { Config } from 'vega-lite';
|
||||
import type { UiTheme } from './theme';
|
||||
|
||||
export const lightChartConfig: Config = {
|
||||
background: 'transparent',
|
||||
font: '"Inter", system-ui, sans-serif',
|
||||
title: { fontSize: 15, fontWeight: 600, color: '#1c1c1e' },
|
||||
axis: {
|
||||
domainColor: '#1c1c1e',
|
||||
gridColor: '#e4e4e7',
|
||||
gridDash: [3, 3],
|
||||
labelColor: '#52525b',
|
||||
titleColor: '#1c1c1e',
|
||||
labelFontSize: 11,
|
||||
titleFontSize: 12,
|
||||
},
|
||||
range: {
|
||||
category: ['#2f6df6', '#f5a524', '#17b890', '#e5484d', '#8b5cf6', '#0ea5e9'],
|
||||
},
|
||||
view: { stroke: 'transparent' },
|
||||
};
|
||||
|
||||
export const experimentalChartConfig: Config = {
|
||||
background: 'transparent',
|
||||
font: '"Inter", system-ui, sans-serif',
|
||||
title: { fontSize: 15, fontWeight: 600, color: '#f4f4f5' },
|
||||
axis: {
|
||||
domainColor: '#a1a1aa',
|
||||
gridColor: '#3f3f46',
|
||||
gridDash: [3, 3],
|
||||
labelColor: '#a1a1aa',
|
||||
titleColor: '#f4f4f5',
|
||||
labelFontSize: 11,
|
||||
titleFontSize: 12,
|
||||
},
|
||||
range: {
|
||||
category: ['#5b8def', '#f5a524', '#2dd4a7', '#f0666b', '#a78bfa', '#38bdf8'],
|
||||
},
|
||||
view: { stroke: 'transparent' },
|
||||
};
|
||||
|
||||
const CHART_CONFIG: Record<UiTheme, Config> = {
|
||||
light: lightChartConfig,
|
||||
experimental: experimentalChartConfig,
|
||||
};
|
||||
|
||||
/** The Vega-Lite config for a UI theme — the only theme → config mapping. */
|
||||
export function chartConfigFor(theme: UiTheme): Config {
|
||||
return CHART_CONFIG[theme];
|
||||
}
|
||||
Reference in New Issue
Block a user