/** * 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, useState } from 'react'; import type { VisualizationSpec } from 'vega-embed'; import { CHART_EXAMPLES, exampleSpecText, type ChartExample } from '@core/examples'; import { createSnippet as createSnippetRecord, deriveSnippetName } from '@core/snippet'; import { chartConfigFor } from '@core/vega-themes'; import { openModal } from '../modals/ModalCoordinator'; import { renderSpec, type RenderHandle } from '../services/chart-renderer'; import { importWorkspace } from '../services/transfer'; import { useAppStore } from '../stores/AppStore'; import { usePanesStore } from '../stores/PanesStore'; import { useSnippetStore } from '../stores/SnippetStore'; import { Button } from './Button'; 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 }) { const hostRef = useRef(null); const uiTheme = useAppStore((s) => s.uiTheme); useEffect(() => { const node = hostRef.current; if (!node) return; 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. }); // A `container`-width chart only re-reads its size on a window resize, so the // grid reflowing a card (window resize → new column width) needs the same // synthesized re-fit LivePreview uses. The observer reads `handle` lazily, so // it no-ops until the async render resolves. let ro: ResizeObserver | undefined; if (typeof ResizeObserver !== 'undefined') { ro = new ResizeObserver(() => handle?.resize()); ro.observe(node); } return () => { cancelled = true; ro?.disconnect(); 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 (