Replace the first-run placeholder seed with an onboarding canvas

This commit is contained in:
2026-06-09 14:03:47 +03:00
parent 7d247e8d2c
commit 81f563a3e7
16 changed files with 852 additions and 93 deletions
+49
View File
@@ -0,0 +1,49 @@
import { describe, expect, test } from 'vitest';
import { compile } from 'vega-lite';
import { CHART_EXAMPLES, exampleSpecText } from './examples';
import { SAMPLE_SPEC, VEGA_LITE_SCHEMA_URL } from './snippet';
describe('chart examples', () => {
test('the gallery is non-empty and ids are unique', () => {
expect(CHART_EXAMPLES.length).toBeGreaterThan(0);
const ids = CHART_EXAMPLES.map((e) => e.id);
expect(new Set(ids).size).toBe(ids.length);
});
test('the bar example reuses the blank-snippet template (one source of truth)', () => {
const bar = CHART_EXAMPLES.find((e) => e.id === 'bar');
expect(bar?.spec).toBe(SAMPLE_SPEC);
});
// The whole promise of the gallery is that every card renders. A spec that
// doesn't compile would render to an error on first paint — so each example
// must be valid, self-contained Vega-Lite.
describe.each(CHART_EXAMPLES.map((e) => [e.id, e] as const))('example: %s', (_id, example) => {
test('has a name and a one-line description', () => {
expect(example.name.trim()).not.toBe('');
expect(example.description.trim()).not.toBe('');
});
test('pins the bundled Vega-Lite schema', () => {
expect(example.spec.$schema).toBe(VEGA_LITE_SCHEMA_URL);
});
test('carries inline data only (no named/url reference, no dataset coupling)', () => {
const data = example.spec.data as { values?: unknown[]; name?: string; url?: string };
expect(Array.isArray(data?.values)).toBe(true);
expect(data.values?.length ?? 0).toBeGreaterThan(0);
expect(data.name).toBeUndefined();
expect(data.url).toBeUndefined();
});
test('compiles as valid Vega-Lite', () => {
// compile() is pure (produces a Vega spec, no DOM/render); it throws on an
// invalid Vega-Lite spec, which is exactly the failure we want to catch.
expect(() => compile(example.spec as unknown as Parameters<typeof compile>[0])).not.toThrow();
});
test('serializes to JSON text that round-trips to the spec', () => {
expect(JSON.parse(exampleSpecText(example))).toEqual(example.spec);
});
});
});
+184
View File
@@ -0,0 +1,184 @@
/**
* Example snippets — the onboarding gallery (spec §02 → First-Run & Empty
* Workspace).
*
* Portable core: no browser APIs, no React. A small, curated set of simple
* Vega-Lite specs that each showcase a distinct capability (a mark type, the
* temporal field type, a colour encoding, stacking, a non-cartesian mark, a
* data transform). They exist so a first-time user can populate their library
* from working examples instead of a blank canvas or a placeholder seed.
*
* Constraints that keep them safe and self-contained:
* - **Inline data only** — every example carries its own `data.values`, so it
* always renders offline and never couples to the dataset library.
* - **Meaningful names** — added examples become ordinary snippets; they carry
* real names ("Bar chart", "Scatter plot"), never an auto-generated stamp.
* - **Valid Vega-Lite** — each spec compiles (asserted in examples.test.ts).
*
* The bar-chart example reuses `SAMPLE_SPEC` — the same template `createSnippet`
* starts a blank snippet from — so "the starting template" and "the first
* example" are one source of truth, not two copies that can drift.
*/
import { SAMPLE_SPEC, VEGA_LITE_SCHEMA_URL } from './snippet';
export interface ChartExample {
/** Stable key — React list key and test identity (not user-visible). */
id: string;
/** The snippet name applied when this example is added to the library. */
name: string;
/** One-line gallery description of what the chart shows. */
description: string;
/** The Vega-Lite spec as a plain object: rendered live, serialized when added. */
spec: Record<string, unknown>;
}
/**
* The curated gallery, in pedagogical order (simplest first). Adding an example
* = one entry here; the onboarding canvas and the validation test both iterate
* this array, so nothing else needs touching.
*/
export const CHART_EXAMPLES: ReadonlyArray<ChartExample> = [
{
id: 'bar',
name: 'Bar chart',
description: 'Compare a value across categories.',
// Reuses the blank-snippet template, so the starting point and the first
// example never drift apart.
spec: SAMPLE_SPEC,
},
{
id: 'line',
name: 'Time-series line',
description: 'Trace a value over time (a temporal axis).',
spec: {
$schema: VEGA_LITE_SCHEMA_URL,
description: 'A line chart over time.',
data: {
values: [
{ date: '2024-01-01', price: 120 },
{ date: '2024-02-01', price: 135 },
{ date: '2024-03-01', price: 128 },
{ date: '2024-04-01', price: 156 },
{ date: '2024-05-01', price: 172 },
{ date: '2024-06-01', price: 165 },
],
},
mark: 'line',
encoding: {
x: { field: 'date', type: 'temporal', title: 'Month' },
y: { field: 'price', type: 'quantitative', title: 'Price' },
},
},
},
{
id: 'scatter',
name: 'Scatter plot',
description: 'Relate two measures, coloured by group.',
spec: {
$schema: VEGA_LITE_SCHEMA_URL,
description: 'A scatter plot with a colour encoding.',
data: {
values: [
{ x: 1.2, y: 3.4, group: 'A' },
{ x: 2.5, y: 1.9, group: 'B' },
{ x: 3.1, y: 4.7, group: 'A' },
{ x: 4.0, y: 2.2, group: 'B' },
{ x: 2.2, y: 3.0, group: 'C' },
{ x: 3.8, y: 4.1, group: 'C' },
],
},
mark: 'point',
encoding: {
x: { field: 'x', type: 'quantitative' },
y: { field: 'y', type: 'quantitative' },
color: { field: 'group', type: 'nominal' },
},
},
},
{
id: 'area',
name: 'Stacked area',
description: 'Stack series totals over time.',
spec: {
$schema: VEGA_LITE_SCHEMA_URL,
description: 'A stacked area chart.',
data: {
values: [
{ month: '2024-01-01', series: 'North', sales: 28 },
{ month: '2024-01-01', series: 'South', sales: 19 },
{ month: '2024-02-01', series: 'North', sales: 35 },
{ month: '2024-02-01', series: 'South', sales: 22 },
{ month: '2024-03-01', series: 'North', sales: 31 },
{ month: '2024-03-01', series: 'South', sales: 27 },
],
},
mark: 'area',
encoding: {
x: { field: 'month', type: 'temporal', title: 'Month' },
y: { field: 'sales', type: 'quantitative', stack: 'zero' },
color: { field: 'series', type: 'nominal' },
},
},
},
{
id: 'donut',
name: 'Donut',
description: 'Show parts of a whole (an arc mark).',
spec: {
$schema: VEGA_LITE_SCHEMA_URL,
description: 'A donut chart.',
data: {
values: [
{ category: 'A', value: 40 },
{ category: 'B', value: 30 },
{ category: 'C', value: 20 },
{ category: 'D', value: 10 },
],
},
mark: { type: 'arc', innerRadius: 50 },
encoding: {
theta: { field: 'value', type: 'quantitative' },
color: { field: 'category', type: 'nominal' },
},
},
},
{
id: 'histogram',
name: 'Histogram',
description: 'Bin values to show a distribution.',
spec: {
$schema: VEGA_LITE_SCHEMA_URL,
description: 'A binned histogram.',
data: {
values: [
{ value: 12 },
{ value: 18 },
{ value: 21 },
{ value: 22 },
{ value: 24 },
{ value: 25 },
{ value: 27 },
{ value: 31 },
{ value: 33 },
{ value: 35 },
{ value: 38 },
{ value: 42 },
{ value: 45 },
{ value: 51 },
{ value: 55 },
],
},
mark: 'bar',
encoding: {
x: { field: 'value', type: 'quantitative', bin: true },
y: { aggregate: 'count' },
},
},
},
];
/** The example's spec as pretty-printed JSON text — what a snippet stores. */
export function exampleSpecText(example: ChartExample): string {
return JSON.stringify(example.spec, null, 2);
}