mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Replace the first-run placeholder seed with an onboarding canvas
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
/* Onboarding canvas — fills the editor+preview space when the library is empty. */
|
||||
.onboarding {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
/* A readable, centered column; the gallery grid widens within it. */
|
||||
.inner {
|
||||
width: 100%;
|
||||
max-width: 760px;
|
||||
padding: var(--space-7) var(--space-6);
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0 0 var(--space-2);
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.tagline {
|
||||
margin: 0 0 var(--space-6);
|
||||
max-width: 56ch;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Primary call to action — the accent button, matching the library's Create. */
|
||||
.primary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
height: 40px;
|
||||
padding: 0 var(--space-5);
|
||||
border: var(--border-width) solid transparent;
|
||||
border-radius: var(--radius);
|
||||
background: var(--accent);
|
||||
color: var(--accent-contrast);
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background var(--dur-fast) var(--ease);
|
||||
}
|
||||
.primary:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
.primary:focus-visible {
|
||||
outline: 2px solid var(--focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* The "or start from an example" header row, with Add all pushed to the end. */
|
||||
.galleryHead {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
margin: var(--space-7) 0 var(--space-4);
|
||||
padding-top: var(--space-5);
|
||||
border-top: var(--border-width) solid var(--border);
|
||||
}
|
||||
|
||||
.galleryTitle {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Secondary button — bordered, transparent (like the header actions). */
|
||||
.addAll {
|
||||
flex: 0 0 auto;
|
||||
height: 32px;
|
||||
padding: 0 var(--space-4);
|
||||
border: var(--border-width) solid var(--border-strong);
|
||||
border-radius: var(--radius);
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background var(--dur-fast) var(--ease);
|
||||
}
|
||||
.addAll:hover {
|
||||
background: var(--layer-01);
|
||||
}
|
||||
.addAll:focus-visible {
|
||||
outline: 2px solid var(--focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Responsive gallery: cards as wide as ~240px, filling the column. */
|
||||
.gallery {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3);
|
||||
border: var(--border-width) solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--layer-01);
|
||||
}
|
||||
|
||||
/* The live chart's host. Width fills the card; height is fixed inline (THUMB_HEIGHT). */
|
||||
.thumb {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.cardBody {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.cardName {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.cardDesc {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* Per-example Add — small bordered action, aligned to the card's start. */
|
||||
.add {
|
||||
align-self: flex-start;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
height: 30px;
|
||||
padding: 0 var(--space-4);
|
||||
border: var(--border-width) solid var(--border-strong);
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background var(--dur-fast) var(--ease);
|
||||
}
|
||||
.add:hover {
|
||||
background: var(--layer-02, var(--layer-01));
|
||||
}
|
||||
.add:focus-visible {
|
||||
outline: 2px solid var(--focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Onboarding canvas — the empty-workspace actions (spec §02).
|
||||
*
|
||||
* The live chart rendering is integration (vega-embed) and is mocked away here;
|
||||
* what these tests own is the wiring: each affordance creates the right snippets
|
||||
* in the store. Card previews are exercised only insofar as they don't crash.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { CHART_EXAMPLES, exampleSpecText } from '@core/examples';
|
||||
import { sampleSpecText } from '@core/snippet';
|
||||
import { useSnippetStore } from '../stores/SnippetStore';
|
||||
import { Onboarding } from './Onboarding';
|
||||
|
||||
// The gallery renders one chart per card; stub the shared renderer so the test
|
||||
// never touches vega-embed. A resolved no-op handle is enough — Onboarding only
|
||||
// finalizes it on unmount.
|
||||
vi.mock('../services/chart-renderer', () => ({
|
||||
renderSpec: () => Promise.resolve({ destroy() {}, resize() {} }),
|
||||
}));
|
||||
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
useSnippetStore.getState().reset();
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
act(() => root.render(<Onboarding />));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
useSnippetStore.getState().reset();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
/** Click the button whose accessible name (aria-label, else text) matches. */
|
||||
function click(name: string) {
|
||||
const button = Array.from(container.querySelectorAll('button')).find((b) =>
|
||||
(b.getAttribute('aria-label') ?? b.textContent ?? '').includes(name),
|
||||
);
|
||||
if (!button) throw new Error(`button not found: ${name}`);
|
||||
act(() => button.click());
|
||||
}
|
||||
|
||||
describe('Onboarding', () => {
|
||||
test('greets the user and offers the primary create action', () => {
|
||||
expect(container.textContent).toContain('Welcome to Astrolabe');
|
||||
expect(container.textContent).toContain('Create your first snippet');
|
||||
});
|
||||
|
||||
test('renders one card per example, each with an Add control', () => {
|
||||
for (const example of CHART_EXAMPLES) {
|
||||
expect(container.textContent).toContain(example.name);
|
||||
expect(container.textContent).toContain(example.description);
|
||||
expect(container.querySelector(`button[aria-label="Add ${example.name}"]`)).not.toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
test('"Create your first snippet" creates one snippet from the sample template', () => {
|
||||
click('Create your first snippet');
|
||||
const { snippets, activeSnippetId } = useSnippetStore.getState();
|
||||
expect(snippets).toHaveLength(1);
|
||||
expect(snippets[0].spec).toBe(sampleSpecText());
|
||||
expect(activeSnippetId).toBe(snippets[0].id);
|
||||
});
|
||||
|
||||
test('an example’s Add creates that snippet, named and active', () => {
|
||||
const scatter = CHART_EXAMPLES.find((e) => e.id === 'scatter')!;
|
||||
click(`Add ${scatter.name}`);
|
||||
const { snippets, activeSnippetId } = useSnippetStore.getState();
|
||||
expect(snippets).toHaveLength(1);
|
||||
expect(snippets[0].name).toBe(scatter.name);
|
||||
expect(snippets[0].spec).toBe(exampleSpecText(scatter));
|
||||
expect(activeSnippetId).toBe(snippets[0].id);
|
||||
});
|
||||
|
||||
test('"Add all" adds every example and makes the bar chart active', () => {
|
||||
click('Add all');
|
||||
const { snippets, activeSnippetId } = useSnippetStore.getState();
|
||||
expect(snippets).toHaveLength(CHART_EXAMPLES.length);
|
||||
// Every example name is present.
|
||||
const names = new Set(snippets.map((s) => s.name));
|
||||
for (const example of CHART_EXAMPLES) expect(names.has(example.name)).toBe(true);
|
||||
// The first example (bar) is the active one (staggered newest).
|
||||
const active = snippets.find((s) => s.id === activeSnippetId);
|
||||
expect(active?.name).toBe(CHART_EXAMPLES[0].name);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* Onboarding canvas — the empty-workspace surface (spec §02 → First-Run & Empty
|
||||
* Workspace).
|
||||
*
|
||||
* Shown by `App` in place of the editor + preview whenever the library is empty
|
||||
* (first run, or after the last snippet is deleted). Rather than seeding a
|
||||
* placeholder snippet, it greets the user and offers two deliberate ways in: a
|
||||
* primary "Create your first snippet" (the sample template) and a gallery of
|
||||
* example snippets, each previewed live and addable on its own or all at once.
|
||||
*
|
||||
* The previews render through the shared `chart-renderer` — the one place that
|
||||
* touches vega-embed — so there is no parallel embed path. Each card's chart
|
||||
* lives in its own node and owns its handle's lifecycle (finalized on unmount),
|
||||
* independent of `LivePreview`'s single-host render serialization.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import type { VisualizationSpec } from 'vega-embed';
|
||||
import { CHART_EXAMPLES, exampleSpecText, type ChartExample } from '@core/examples';
|
||||
import { createSnippet as createSnippetRecord } from '@core/snippet';
|
||||
import { chartConfigFor } from '@core/vega-themes';
|
||||
import { renderSpec, type RenderHandle } from '../services/chart-renderer';
|
||||
import { useAppStore } from '../stores/AppStore';
|
||||
import { usePanesStore } from '../stores/PanesStore';
|
||||
import { useSnippetStore } from '../stores/SnippetStore';
|
||||
import { Icon } from './Icon';
|
||||
import styles from './Onboarding.module.css';
|
||||
|
||||
/** Fixed thumbnail height; width fills the card via Vega's `container` sizing. */
|
||||
const THUMB_HEIGHT = 140;
|
||||
|
||||
/**
|
||||
* Live preview of one example. Renders the example spec at card width through the
|
||||
* shared renderer, finalizing the Vega view on unmount or when the spec/theme
|
||||
* changes — a chart that isn't finalized leaks its timers and listeners.
|
||||
*/
|
||||
function ExampleThumbnail({ spec }: { spec: Record<string, unknown> }) {
|
||||
const hostRef = useRef<HTMLDivElement>(null);
|
||||
const uiTheme = useAppStore((s) => s.uiTheme);
|
||||
|
||||
useEffect(() => {
|
||||
const node = hostRef.current;
|
||||
if (!node) return;
|
||||
// TODO: thumbnails render once at mount width and don't call handle.resize(),
|
||||
// so a `width: 'container'` chart won't re-fit when the window resizes and the
|
||||
// grid reflows the card. Cosmetic only (the card clips/letterboxes via overflow
|
||||
// hidden) on a brief first-run surface; wire a resize observer if it ever shows.
|
||||
let handle: RenderHandle | null = null;
|
||||
let cancelled = false;
|
||||
// `container` width fits the card; a fixed height keeps every card uniform.
|
||||
// Spread onto a copy so the shared example object is never mutated.
|
||||
const sized = { ...spec, width: 'container', height: THUMB_HEIGHT } as VisualizationSpec;
|
||||
void renderSpec(node, sized, chartConfigFor(uiTheme))
|
||||
.then((h) => {
|
||||
if (cancelled) h.destroy();
|
||||
else handle = h;
|
||||
})
|
||||
.catch(() => {
|
||||
// Examples are schema-validated (examples.test.ts), but a thumbnail that
|
||||
// somehow fails to render must never break onboarding — leave the card
|
||||
// image blank and let the name + description carry it.
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
handle?.destroy();
|
||||
};
|
||||
}, [spec, uiTheme]);
|
||||
|
||||
// Decorative: the name, description, and Add button carry the meaning, so a
|
||||
// screen reader hears "Bar chart … Add", not a tree of chart SVG nodes (arch §10).
|
||||
return (
|
||||
<div
|
||||
ref={hostRef}
|
||||
className={styles.thumb}
|
||||
style={{ height: THUMB_HEIGHT }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function Onboarding() {
|
||||
const createSnippet = useSnippetStore((s) => s.createSnippet);
|
||||
const addSnippets = useSnippetStore((s) => s.addSnippets);
|
||||
const applyOnboardingSplit = usePanesStore((s) => s.applyOnboardingSplit);
|
||||
|
||||
// Leaving onboarding lays the workspace out at the default 25·25·50 split, so
|
||||
// the first chart opens with a generous preview (spec §02). The canvas fills
|
||||
// the window here, so its width is a good proxy for the panes container.
|
||||
const layoutWorkspace = () => applyOnboardingSplit(window.innerWidth);
|
||||
|
||||
// Primary path and per-example add both go through the normal create action,
|
||||
// so an added example opens in the editor exactly like any new snippet.
|
||||
const handleCreate = () => {
|
||||
createSnippet();
|
||||
layoutWorkspace();
|
||||
};
|
||||
const handleAdd = (example: ChartExample) => {
|
||||
createSnippet({ name: example.name, spec: exampleSpecText(example) });
|
||||
layoutWorkspace();
|
||||
};
|
||||
|
||||
const handleAddAll = () => {
|
||||
// Stagger the timestamps so the first example (the bar chart) is the newest:
|
||||
// it then sorts to the top of the library and `addSnippets` makes it active
|
||||
// (it selects the newest when nothing is active — true during onboarding).
|
||||
const base = Date.now();
|
||||
const records = CHART_EXAMPLES.map((example, i) =>
|
||||
createSnippetRecord({
|
||||
name: example.name,
|
||||
spec: exampleSpecText(example),
|
||||
now: new Date(base - i * 1000),
|
||||
}),
|
||||
);
|
||||
addSnippets(records);
|
||||
layoutWorkspace();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.onboarding}>
|
||||
<div className={styles.inner}>
|
||||
<h2 className={styles.title}>Welcome to Astrolabe</h2>
|
||||
<p className={styles.tagline}>
|
||||
A local library for your Vega-Lite charts — authored as JSON, rendered live, and kept on
|
||||
your device.
|
||||
</p>
|
||||
|
||||
<button type="button" className={styles.primary} onClick={handleCreate}>
|
||||
<Icon name="add" /> Create your first snippet
|
||||
</button>
|
||||
|
||||
<div className={styles.galleryHead}>
|
||||
<h3 className={styles.galleryTitle}>Or start from an example</h3>
|
||||
<button type="button" className={styles.addAll} onClick={handleAddAll}>
|
||||
Add all
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ul className={styles.gallery}>
|
||||
{CHART_EXAMPLES.map((example) => (
|
||||
<li key={example.id} className={styles.card}>
|
||||
<ExampleThumbnail spec={example.spec} />
|
||||
<div className={styles.cardBody}>
|
||||
<span className={styles.cardName}>{example.name}</span>
|
||||
<span className={styles.cardDesc}>{example.description}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.add}
|
||||
aria-label={`Add ${example.name}`}
|
||||
onClick={() => handleAdd(example)}
|
||||
>
|
||||
<Icon name="add" /> Add
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -159,16 +159,6 @@ describe('SnippetLibrary metadata panel (spec §02)', () => {
|
||||
expect(container.textContent).toContain('No snippets match your search');
|
||||
});
|
||||
|
||||
test('shows the empty-library state when there are no snippets', () => {
|
||||
useSnippetStore.getState().hydrate([], null);
|
||||
|
||||
act(() => {
|
||||
root.render(<SnippetLibrary />);
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain('No snippets yet');
|
||||
});
|
||||
|
||||
test('Duplicate adds an independent copy and makes it active', async () => {
|
||||
const s = createSnippet({ id: 'a', name: 'Chart', now: new Date('2026-01-01T00:00:00Z') });
|
||||
useSnippetStore.getState().hydrate([s], 'a');
|
||||
|
||||
@@ -199,8 +199,10 @@ export function SnippetLibrary() {
|
||||
[snippets, searchQuery, sortBy, sortOrder],
|
||||
);
|
||||
|
||||
// Empty-state copy splits two cases (council EMPTY STATES; Carbon two types):
|
||||
// a genuinely empty library vs. a search that matched nothing.
|
||||
// The list owes a single empty state: a search that matched nothing (council
|
||||
// EMPTY STATES). A genuinely empty library is handled one level up — App replaces
|
||||
// the whole pane chrome with the onboarding canvas (spec §02 → First-Run & Empty
|
||||
// Workspace), so this component never renders with zero snippets.
|
||||
const searching = searchQuery.trim() !== '';
|
||||
const noMatches = ordered.length === 0;
|
||||
|
||||
@@ -299,19 +301,14 @@ export function SnippetLibrary() {
|
||||
|
||||
<ul className={styles.list}>
|
||||
{noMatches && searching && (
|
||||
// Empty state (b): a search that matched nothing (council EMPTY STATES).
|
||||
// The list's one empty state: a search that matched nothing (council
|
||||
// EMPTY STATES). A genuinely empty library never reaches here — App shows
|
||||
// the onboarding canvas in place of this pane (spec §02).
|
||||
<li className={styles.empty}>
|
||||
<span className={styles.emptyTitle}>No snippets match your search</span>
|
||||
<span className={styles.emptyHint}>Try a different term.</span>
|
||||
</li>
|
||||
)}
|
||||
{noMatches && !searching && (
|
||||
// Empty state (a): a genuinely empty library — guide to Create.
|
||||
<li className={styles.empty}>
|
||||
<span className={styles.emptyTitle}>No snippets yet</span>
|
||||
<span className={styles.emptyHint}>Create your first one with the button above.</span>
|
||||
</li>
|
||||
)}
|
||||
{ordered.map((s) => {
|
||||
// Size is omitted under ~1 KB per spec §02; null collapses the suffix.
|
||||
const size = formatSnippetSize(snippetSizeBytes(s));
|
||||
|
||||
Reference in New Issue
Block a user