From 81f563a3e79a0a8fe1e58adeecd7a61999000cc5 Mon Sep 17 00:00:00 2001 From: Oleh Omelchenko Date: Tue, 9 Jun 2026 14:03:47 +0300 Subject: [PATCH] Replace the first-run placeholder seed with an onboarding canvas --- docs/IMPLEMENTATION-PLAN.md | 6 +- .../10-interaction-and-feedback.md | 41 +++- docs/spec-implementation-audit.md | 5 +- docs/spec/02-snippet-library.md | 17 +- src/app/App.module.css | 9 + src/app/App.tsx | 91 +++++---- src/app/components/Onboarding.module.css | 170 ++++++++++++++++ src/app/components/Onboarding.test.tsx | 96 +++++++++ src/app/components/Onboarding.tsx | 160 +++++++++++++++ src/app/components/SnippetLibrary.test.tsx | 10 - src/app/components/SnippetLibrary.tsx | 17 +- src/app/orchestration/startup.ts | 33 +--- src/app/stores/PanesStore.test.ts | 23 +++ src/app/stores/PanesStore.ts | 34 ++++ src/core/examples.test.ts | 49 +++++ src/core/examples.ts | 184 ++++++++++++++++++ 16 files changed, 852 insertions(+), 93 deletions(-) create mode 100644 src/app/components/Onboarding.module.css create mode 100644 src/app/components/Onboarding.test.tsx create mode 100644 src/app/components/Onboarding.tsx create mode 100644 src/core/examples.test.ts create mode 100644 src/core/examples.ts diff --git a/docs/IMPLEMENTATION-PLAN.md b/docs/IMPLEMENTATION-PLAN.md index 6f1b234..0384bbf 100644 --- a/docs/IMPLEMENTATION-PLAN.md +++ b/docs/IMPLEMENTATION-PLAN.md @@ -94,6 +94,8 @@ preview, and have it survive reload. Single source kind: inline-data specs only - `stores/SnippetStore.ts` — `useSnippetStore` with `snippets`, `activeSnippetId`, selector-derived `activeSnippet`; load-on-startup; create/select/delete/update actions (debounced auto-save of edits, spec §03B). Seed one sample snippet on first run. + _(Superseded later: an empty library now shows the onboarding canvas instead of a + placeholder seed — spec §02 → First-Run & Empty Workspace.)_ - `components/SnippetLibrary.tsx` — list + "Create New" pinned item + select/delete. - `components/SpecEditor.tsx` — Monaco JSON editor bound to active snippet's spec; debounced write-back to the store. (Worker wiring via Vite `?worker` imports — @@ -111,7 +113,9 @@ preview, and have it survive reload. Single source kind: inline-data specs only **Manual checks** -- Fresh load seeds a sample snippet that renders a bar chart. +- Fresh load shows the onboarding canvas (welcome + Create + live example gallery); + Create or an example's Add lands you in the editor with a rendered chart. _(M1 originally + seeded a sample snippet; replaced by the onboarding canvas — spec §02.)_ - Type in the editor → preview updates after the debounce; bad JSON → editor keeps working, preview shows an error, recovers when fixed. - Reload → snippets and selection persist. diff --git a/docs/architecture/10-interaction-and-feedback.md b/docs/architecture/10-interaction-and-feedback.md index 175b0d8..368aaf7 100644 --- a/docs/architecture/10-interaction-and-feedback.md +++ b/docs/architecture/10-interaction-and-feedback.md @@ -265,15 +265,38 @@ default Modified/desc); search is **not** persisted (it's a transient view narro _(Consulted via /council → WAI-ARIA APG disclosure + menu-button, NN/g #6. This bullet is the contract; cite it, not the source.)_ -**Resolved — the two library empty states (Carbon's two types).** The snippet list owes -**two distinct empty states** (Carbon empty-state types), chosen by cause and never conflated: -(a) a **genuinely empty library** (first run) is **positive and concise and guides to the -Create action** — title "No snippets yet" + a one-line nudge to create one; (b) **no search -matches** says "No snippets match your search" + a hint to try a different term. Both are -plain-language and minimal (NN/g aesthetic-and-minimalist; §3 empty ≠ error). The library -derives **which** to show from whether a query is active, not from the raw count alone. -_(Consulted via /council → Carbon empty-state, NN/g. This bullet is the contract; cite it, -not the source.)_ +**Resolved — the empty-library onboarding canvas (and the list's single empty state).** The +snippet list owes **one empty state**: **no search matches** — "No snippets match your search" + +- a hint to try a different term. There is **no separate "empty library" list state**, because + a genuinely empty library never shows the list at all: rather than seeding a placeholder + snippet (the old behavior), the workspace replaces the **entire pane chrome — toggle strip, + library list, editor, and preview — with a full-width onboarding canvas** (spec §02 → + First-Run & Empty Workspace): with no snippets, the library controls and pane toggles have + nothing to act on. (So the list's own empty copy is reached only mid-search, never on a cold + start.) The canvas is a welcome, a primary "Create your first + snippet", and a gallery of example snippets; leaving it (creating the first snippet) lays the + panes out at a default **25·25·50** split via `PanesStore.applyOnboardingSplit` so the first + chart opens with a generous preview. Each card + **renders live through the shared `chart-renderer`** (no parallel embed path; each card owns + its `RenderHandle` and finalizes on unmount — the per-card nodes are independent, so they + don't touch `LivePreview`'s single-host serialization), and adds as an ordinary snippet. + Empty stays calm and positive, never an error (NN/g aesthetic-and-minimalist; §3 empty ≠ + error). Council resolutions for the canvas: (1) **each card preview is decorative** — + `aria-hidden`, skipped by screen readers (Carbon empty-states a11y / WCAG decorative + images); the card **name + one-line description + a uniquely-labelled `Add` button** ("Add + Bar chart", APG button) carry the meaning, so AT users reach no dead end. (2) The canvas is + the **single empty surface** — because it replaces the library list outright, there is no + competing "No snippets yet" status elsewhere to keep in sync. Its heading **owns the app + identity** ("Welcome to Astrolabe") and it carries the only Create nudge, so the empty-state + message lives in exactly one place (Carbon "keep words to a minimum"; no duplication). + (3) The **primary action dominates** (accent "Create your first snippet" first; the example + gallery is framed as a secondary "Or start from an example") — Carbon sanctions starter + content as an in-depth first-use empty state only when one action stays primary. (4) + **Domain vocabulary stands**: "Vega-Lite", "JSON", "snippet" are kept despite Carbon's + avoid-jargon rule, because SOUL #2 (Vega-Lite Native) makes them the user's real language — + a deliberate divergence. _(Consulted via /council → Carbon empty-state + content, GOV.UK + headings, WAI-ARIA APG button, NN/g. This bullet is the contract; cite it, not the source.)_ **Resolved — one live region per shared message.** When the same error feeds two surfaces (the §1 "one producer, two subscribers" case — render errors via `PreviewStore`), exactly diff --git a/docs/spec-implementation-audit.md b/docs/spec-implementation-audit.md index 39c378b..e1e406b 100644 --- a/docs/spec-implementation-audit.md +++ b/docs/spec-implementation-audit.md @@ -136,8 +136,9 @@ and is tested (e.g. `storage-estimate.test.ts`), so M6 is wiring, not building f keyboard router, URL routing w/ back-forward, toasts, modal system all present. Gaps: About/Donate modals (deferred), toast fade-out (partial), Settings-modal drift. - **§02 Snippet Library — partial.** List, list-item (status + linked datasets), metadata - panel (rename/comment/timestamps), create/duplicate/delete, naming, first-run seed all - done. Deferred: search, sort UI+persistence, dual empty states, storage monitor. + panel (rename/comment/timestamps), create/duplicate/delete, naming, first-run onboarding + canvas (welcome + example gallery, replacing the old placeholder seed), and the list's + no-search-matches empty state all done. Deferred: search, sort UI+persistence, storage monitor. - **§03 Editor — mostly complete.** Draft/Published, auto-save, debounced auto-render, publish/revert + confirm, inline errors, Extract-to-Dataset all working. Gap: auto-reformat-on-type (**missing**, see above). diff --git a/docs/spec/02-snippet-library.md b/docs/spec/02-snippet-library.md index 3fc4097..a355e4a 100644 --- a/docs/spec/02-snippet-library.md +++ b/docs/spec/02-snippet-library.md @@ -10,8 +10,21 @@ The list shows every saved snippet and is always visible. A persistent "Create N - A "Create New Snippet" item is pinned at the top of the list; activating it creates and selects a new snippet (see _Snippet Operations_). - Selecting a snippet makes it the **active snippet**: it loads into the editor and preview, becomes highlighted in the list, and the URL updates to reflect the selected snippet so the state is shareable and survives a page reload (see _Application Shell & Navigation_). - Exactly one snippet is active at a time. -- When no snippets match the current search, the list shows an empty-state message ("No snippets match your search", with a hint to try a different term); when there are genuinely no snippets, it shows "No snippets yet" with a nudge to create one. -- On first run, when no snippets exist, the app seeds one sample bar-chart snippet so the user starts with a working example. +- When no snippets match the current search, the list shows an empty-state message ("No snippets match your search", with a hint to try a different term). This is the list's only empty state: a genuinely empty library never shows the list at all (see next). +- When the library is empty (first run, or after the last snippet is deleted), the workspace presents a full-width **onboarding canvas** in place of the panes — including the library list — rather than seeding placeholder content (see _First-Run & Empty Workspace_). + +## First-Run & Empty Workspace + +When the library is empty — on first run, or after the user deletes their last snippet — the app does **not** seed placeholder content. Instead the **onboarding canvas takes the full workspace**, replacing the pane chrome (the pane toggle strip, the library list, the editor, and the preview): with no snippets, the library's create/search/sort/storage controls and the pane toggles have nothing to act on, so the welcome gets the whole width. The user starts from a deliberate choice rather than dropped into the middle of an unfamiliar spec. + +- The canvas briefly identifies what Astrolabe is, then offers two ways to begin. +- **Create your first snippet** — the primary action; starts a new snippet from the sample bar-chart template and opens it in the editor (identical to _Create New_ under _Snippet Operations_). +- An **example gallery** of a few simple snippets showcasing distinct Vega-Lite capabilities (e.g. a bar chart, a time-series line, a scatter plot, a stacked area, a donut, a binned histogram). Each example shows a **live preview** of the chart and a one-line description. + - **Add** on an example creates it as an ordinary snippet and makes it active (opening it in the editor). + - **Add all** creates the whole set at once and makes one of them active. +- Added examples are **ordinary snippets**: meaningfully named (not auto-generated timestamps), and thereafter editable, duplicable, and deletable like any other — they are the user's, not a special class (_own your data_). +- Leaving the canvas by creating the first snippet(s) lays the workspace out at a sensible **default split** (library · editor · preview ≈ 25 · 25 · 50) with all three panes shown, so the first chart opens with a generous preview rather than the generic remembered widths. +- The onboarding canvas is shown **only while the library is empty**; as soon as any snippet exists, the normal panes return. Re-emptying the library brings it back. ## List Item diff --git a/src/app/App.module.css b/src/app/App.module.css index 68fe05a..28e45b4 100644 --- a/src/app/App.module.css +++ b/src/app/App.module.css @@ -117,3 +117,12 @@ overflow: hidden; background: var(--bg); } + +/* Onboarding canvas fills the whole panes area when the library is empty (it + replaces the entire chrome, not just editor+preview); it scrolls internally. */ +.paneOnboarding { + flex: 1 1 0; + min-width: 0; + overflow: hidden; + background: var(--bg); +} diff --git a/src/app/App.tsx b/src/app/App.tsx index d15d741..a75b8f3 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef } from 'react'; import { ConfirmDialog } from './components/ConfirmDialog'; import { LivePreview } from './components/LivePreview'; import { ModalShell } from './components/ModalShell'; +import { Onboarding } from './components/Onboarding'; import { PaneToggleStrip } from './components/PaneToggleStrip'; import { ResizeHandle } from './components/ResizeHandle'; import { SnippetLibrary } from './components/SnippetLibrary'; @@ -11,6 +12,7 @@ import { Toaster } from './components/Toaster'; import { openModal, setConfirm } from './modals/ModalCoordinator'; import { confirm } from './stores/ConfirmStore'; import { usePanesStore } from './stores/PanesStore'; +import { useSnippetStore } from './stores/SnippetStore'; import { exportWorkspace, importWorkspace } from './services/transfer'; import styles from './App.module.css'; @@ -20,9 +22,12 @@ import styles from './App.module.css'; * * The center editor flexes; the library and preview carry remembered widths and * are resized via the drag handles between them (spec §01A). Each pane can be - * shown/hidden from the always-present toggle strip (the leftmost rail); a hidden - * pane frees its space and the rest redistribute — when the editor is hidden the - * two side panes flex proportionally to their remembered widths. + * shown/hidden from the toggle strip (the leftmost rail); a hidden pane frees its + * space and the rest redistribute — when the editor is hidden the two side panes + * flex proportionally to their remembered widths. + * + * When the library is empty, this whole pane chrome (strip + panes) is replaced + * by the full-width onboarding canvas (spec §02 → First-Run & Empty Workspace). */ export function App() { const libraryWidth = usePanesStore((s) => s.libraryWidth); @@ -30,6 +35,10 @@ export function App() { const libraryVisible = usePanesStore((s) => s.libraryVisible); const editorVisible = usePanesStore((s) => s.editorVisible); const previewVisible = usePanesStore((s) => s.previewVisible); + // An empty library is the onboarding surface: the whole pane chrome (toggle + // strip, library list, editor, preview) is replaced by the full-width Onboarding + // canvas (spec §02 → First-Run & Empty Workspace). + const hasSnippets = useSnippetStore((s) => s.snippets.length > 0); // Side-pane sizing: fixed remembered width while the editor (the flex filler) is // present; when it's hidden, the side panes grow proportionally to those widths @@ -120,38 +129,50 @@ export function App() { {/* tabIndex -1 makes the landmark a focus target for the skip link. */}
- {/* Always-present rail: shows/hides panes and shortcuts to Datasets (§01A). */} - - {libraryVisible && ( -
- -
- )} - {/* A resize handle only sits between two visible panes that flank the editor. */} - {libraryVisible && editorVisible && ( - - )} - {editorVisible && ( -
- -
- )} - {editorVisible && previewVisible && ( - - )} - {previewVisible && ( -
- + {hasSnippets ? ( + <> + {/* Always-present rail: shows/hides panes and shortcuts to Datasets (§01A). */} + + {libraryVisible && ( +
+ +
+ )} + {/* A resize handle only sits between two visible panes that flank the editor. */} + {libraryVisible && editorVisible && ( + + )} + {editorVisible && ( +
+ +
+ )} + {editorVisible && previewVisible && ( + + )} + {previewVisible && ( +
+ +
+ )} + + ) : ( + // Empty library → the onboarding canvas takes the full workspace. The + // pane chrome (toggle strip, library list, editor, preview) is hidden: + // with no snippets, Create/Search/Sort/Storage and the pane toggles have + // nothing to act on, so the welcome gets the whole width (spec §02). +
+
)}
diff --git a/src/app/components/Onboarding.module.css b/src/app/components/Onboarding.module.css new file mode 100644 index 0000000..9730d30 --- /dev/null +++ b/src/app/components/Onboarding.module.css @@ -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; +} diff --git a/src/app/components/Onboarding.test.tsx b/src/app/components/Onboarding.test.tsx new file mode 100644 index 0000000..1c7f5cc --- /dev/null +++ b/src/app/components/Onboarding.test.tsx @@ -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()); +}); + +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); + }); +}); diff --git a/src/app/components/Onboarding.tsx b/src/app/components/Onboarding.tsx new file mode 100644 index 0000000..a15722b --- /dev/null +++ b/src/app/components/Onboarding.tsx @@ -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 }) { + const hostRef = useRef(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 ( +