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:
@@ -94,6 +94,8 @@ preview, and have it survive reload. Single source kind: inline-data specs only
|
|||||||
- `stores/SnippetStore.ts` — `useSnippetStore` with `snippets`, `activeSnippetId`,
|
- `stores/SnippetStore.ts` — `useSnippetStore` with `snippets`, `activeSnippetId`,
|
||||||
selector-derived `activeSnippet`; load-on-startup; create/select/delete/update actions
|
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.
|
(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/SnippetLibrary.tsx` — list + "Create New" pinned item + select/delete.
|
||||||
- `components/SpecEditor.tsx` — Monaco JSON editor bound to active snippet's spec;
|
- `components/SpecEditor.tsx` — Monaco JSON editor bound to active snippet's spec;
|
||||||
debounced write-back to the store. (Worker wiring via Vite `?worker` imports —
|
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**
|
**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
|
- Type in the editor → preview updates after the debounce; bad JSON → editor keeps
|
||||||
working, preview shows an error, recovers when fixed.
|
working, preview shows an error, recovers when fixed.
|
||||||
- Reload → snippets and selection persist.
|
- Reload → snippets and selection persist.
|
||||||
|
|||||||
@@ -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
|
_(Consulted via /council → WAI-ARIA APG disclosure + menu-button, NN/g #6. This bullet is the
|
||||||
contract; cite it, not the source.)_
|
contract; cite it, not the source.)_
|
||||||
|
|
||||||
**Resolved — the two library empty states (Carbon's two types).** The snippet list owes
|
**Resolved — the empty-library onboarding canvas (and the list's single empty state).** The
|
||||||
**two distinct empty states** (Carbon empty-state types), chosen by cause and never conflated:
|
snippet list owes **one empty state**: **no search matches** — "No snippets match your search"
|
||||||
(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
|
- a hint to try a different term. There is **no separate "empty library" list state**, because
|
||||||
matches** says "No snippets match your search" + a hint to try a different term. Both are
|
a genuinely empty library never shows the list at all: rather than seeding a placeholder
|
||||||
plain-language and minimal (NN/g aesthetic-and-minimalist; §3 empty ≠ error). The library
|
snippet (the old behavior), the workspace replaces the **entire pane chrome — toggle strip,
|
||||||
derives **which** to show from whether a query is active, not from the raw count alone.
|
library list, editor, and preview — with a full-width onboarding canvas** (spec §02 →
|
||||||
_(Consulted via /council → Carbon empty-state, NN/g. This bullet is the contract; cite it,
|
First-Run & Empty Workspace): with no snippets, the library controls and pane toggles have
|
||||||
not the source.)_
|
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
|
**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
|
(the §1 "one producer, two subscribers" case — render errors via `PreviewStore`), exactly
|
||||||
|
|||||||
@@ -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:
|
keyboard router, URL routing w/ back-forward, toasts, modal system all present. Gaps:
|
||||||
About/Donate modals (deferred), toast fade-out (partial), Settings-modal drift.
|
About/Donate modals (deferred), toast fade-out (partial), Settings-modal drift.
|
||||||
- **§02 Snippet Library — partial.** List, list-item (status + linked datasets), metadata
|
- **§02 Snippet Library — partial.** List, list-item (status + linked datasets), metadata
|
||||||
panel (rename/comment/timestamps), create/duplicate/delete, naming, first-run seed all
|
panel (rename/comment/timestamps), create/duplicate/delete, naming, first-run onboarding
|
||||||
done. Deferred: search, sort UI+persistence, dual empty states, storage monitor.
|
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,
|
- **§03 Editor — mostly complete.** Draft/Published, auto-save, debounced auto-render,
|
||||||
publish/revert + confirm, inline errors, Extract-to-Dataset all working. Gap:
|
publish/revert + confirm, inline errors, Extract-to-Dataset all working. Gap:
|
||||||
auto-reformat-on-type (**missing**, see above).
|
auto-reformat-on-type (**missing**, see above).
|
||||||
|
|||||||
@@ -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_).
|
- 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_).
|
- 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.
|
- 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.
|
- 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).
|
||||||
- On first run, when no snippets exist, the app seeds one sample bar-chart snippet so the user starts with a working example.
|
- 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
|
## List Item
|
||||||
|
|
||||||
|
|||||||
@@ -117,3 +117,12 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: var(--bg);
|
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);
|
||||||
|
}
|
||||||
|
|||||||
+56
-35
@@ -2,6 +2,7 @@ import { useEffect, useRef } from 'react';
|
|||||||
import { ConfirmDialog } from './components/ConfirmDialog';
|
import { ConfirmDialog } from './components/ConfirmDialog';
|
||||||
import { LivePreview } from './components/LivePreview';
|
import { LivePreview } from './components/LivePreview';
|
||||||
import { ModalShell } from './components/ModalShell';
|
import { ModalShell } from './components/ModalShell';
|
||||||
|
import { Onboarding } from './components/Onboarding';
|
||||||
import { PaneToggleStrip } from './components/PaneToggleStrip';
|
import { PaneToggleStrip } from './components/PaneToggleStrip';
|
||||||
import { ResizeHandle } from './components/ResizeHandle';
|
import { ResizeHandle } from './components/ResizeHandle';
|
||||||
import { SnippetLibrary } from './components/SnippetLibrary';
|
import { SnippetLibrary } from './components/SnippetLibrary';
|
||||||
@@ -11,6 +12,7 @@ import { Toaster } from './components/Toaster';
|
|||||||
import { openModal, setConfirm } from './modals/ModalCoordinator';
|
import { openModal, setConfirm } from './modals/ModalCoordinator';
|
||||||
import { confirm } from './stores/ConfirmStore';
|
import { confirm } from './stores/ConfirmStore';
|
||||||
import { usePanesStore } from './stores/PanesStore';
|
import { usePanesStore } from './stores/PanesStore';
|
||||||
|
import { useSnippetStore } from './stores/SnippetStore';
|
||||||
import { exportWorkspace, importWorkspace } from './services/transfer';
|
import { exportWorkspace, importWorkspace } from './services/transfer';
|
||||||
import styles from './App.module.css';
|
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
|
* 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
|
* 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
|
* shown/hidden from the toggle strip (the leftmost rail); a hidden pane frees its
|
||||||
* pane frees its space and the rest redistribute — when the editor is hidden the
|
* space and the rest redistribute — when the editor is hidden the two side panes
|
||||||
* two side panes flex proportionally to their remembered widths.
|
* 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() {
|
export function App() {
|
||||||
const libraryWidth = usePanesStore((s) => s.libraryWidth);
|
const libraryWidth = usePanesStore((s) => s.libraryWidth);
|
||||||
@@ -30,6 +35,10 @@ export function App() {
|
|||||||
const libraryVisible = usePanesStore((s) => s.libraryVisible);
|
const libraryVisible = usePanesStore((s) => s.libraryVisible);
|
||||||
const editorVisible = usePanesStore((s) => s.editorVisible);
|
const editorVisible = usePanesStore((s) => s.editorVisible);
|
||||||
const previewVisible = usePanesStore((s) => s.previewVisible);
|
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
|
// 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
|
// 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. */}
|
{/* tabIndex -1 makes the landmark a focus target for the skip link. */}
|
||||||
<main id="main" className={styles.panes} tabIndex={-1}>
|
<main id="main" className={styles.panes} tabIndex={-1}>
|
||||||
{/* Always-present rail: shows/hides panes and shortcuts to Datasets (§01A). */}
|
{hasSnippets ? (
|
||||||
<PaneToggleStrip />
|
<>
|
||||||
{libraryVisible && (
|
{/* Always-present rail: shows/hides panes and shortcuts to Datasets (§01A). */}
|
||||||
<section
|
<PaneToggleStrip />
|
||||||
id="pane-library"
|
{libraryVisible && (
|
||||||
className={styles.pane}
|
<section
|
||||||
style={sideStyle(libraryWidth)}
|
id="pane-library"
|
||||||
aria-label="Snippet library"
|
className={styles.pane}
|
||||||
>
|
style={sideStyle(libraryWidth)}
|
||||||
<SnippetLibrary />
|
aria-label="Snippet library"
|
||||||
</section>
|
>
|
||||||
)}
|
<SnippetLibrary />
|
||||||
{/* A resize handle only sits between two visible panes that flank the editor. */}
|
</section>
|
||||||
{libraryVisible && editorVisible && (
|
)}
|
||||||
<ResizeHandle side="library" label="Resize snippet library" />
|
{/* A resize handle only sits between two visible panes that flank the editor. */}
|
||||||
)}
|
{libraryVisible && editorVisible && (
|
||||||
{editorVisible && (
|
<ResizeHandle side="library" label="Resize snippet library" />
|
||||||
<section id="pane-editor" className={styles.paneEditor} aria-label="Spec editor">
|
)}
|
||||||
<SpecEditor />
|
{editorVisible && (
|
||||||
</section>
|
<section id="pane-editor" className={styles.paneEditor} aria-label="Spec editor">
|
||||||
)}
|
<SpecEditor />
|
||||||
{editorVisible && previewVisible && (
|
</section>
|
||||||
<ResizeHandle side="preview" label="Resize live preview" />
|
)}
|
||||||
)}
|
{editorVisible && previewVisible && (
|
||||||
{previewVisible && (
|
<ResizeHandle side="preview" label="Resize live preview" />
|
||||||
<section
|
)}
|
||||||
id="pane-preview"
|
{previewVisible && (
|
||||||
className={styles.pane}
|
<section
|
||||||
style={sideStyle(previewWidth)}
|
id="pane-preview"
|
||||||
aria-label="Live preview"
|
className={styles.pane}
|
||||||
>
|
style={sideStyle(previewWidth)}
|
||||||
<LivePreview />
|
aria-label="Live preview"
|
||||||
|
>
|
||||||
|
<LivePreview />
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
// 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).
|
||||||
|
<section className={styles.paneOnboarding} aria-label="Getting started">
|
||||||
|
<Onboarding />
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -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');
|
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 () => {
|
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') });
|
const s = createSnippet({ id: 'a', name: 'Chart', now: new Date('2026-01-01T00:00:00Z') });
|
||||||
useSnippetStore.getState().hydrate([s], 'a');
|
useSnippetStore.getState().hydrate([s], 'a');
|
||||||
|
|||||||
@@ -199,8 +199,10 @@ export function SnippetLibrary() {
|
|||||||
[snippets, searchQuery, sortBy, sortOrder],
|
[snippets, searchQuery, sortBy, sortOrder],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Empty-state copy splits two cases (council EMPTY STATES; Carbon two types):
|
// The list owes a single empty state: a search that matched nothing (council
|
||||||
// a genuinely empty library vs. a search that matched nothing.
|
// 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 searching = searchQuery.trim() !== '';
|
||||||
const noMatches = ordered.length === 0;
|
const noMatches = ordered.length === 0;
|
||||||
|
|
||||||
@@ -299,19 +301,14 @@ export function SnippetLibrary() {
|
|||||||
|
|
||||||
<ul className={styles.list}>
|
<ul className={styles.list}>
|
||||||
{noMatches && searching && (
|
{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}>
|
<li className={styles.empty}>
|
||||||
<span className={styles.emptyTitle}>No snippets match your search</span>
|
<span className={styles.emptyTitle}>No snippets match your search</span>
|
||||||
<span className={styles.emptyHint}>Try a different term.</span>
|
<span className={styles.emptyHint}>Try a different term.</span>
|
||||||
</li>
|
</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) => {
|
{ordered.map((s) => {
|
||||||
// Size is omitted under ~1 KB per spec §02; null collapses the suffix.
|
// Size is omitted under ~1 KB per spec §02; null collapses the suffix.
|
||||||
const size = formatSnippetSize(snippetSizeBytes(s));
|
const size = formatSnippetSize(snippetSizeBytes(s));
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
/**
|
/**
|
||||||
* App startup orchestration.
|
* App startup orchestration.
|
||||||
*
|
*
|
||||||
* Loads the snippet library from IndexedDB into the store, seeds a sample
|
* Loads the snippet library from IndexedDB into the store, then wires the
|
||||||
* snippet on first run (spec §02 → "On first run … seed one sample bar-chart
|
* persistence subscribers. Called once from main.tsx; the UI renders immediately
|
||||||
* snippet"), then wires the persistence subscribers. Called once from main.tsx;
|
* and fills in when hydration completes. An empty library is NOT seeded with a
|
||||||
* the UI renders immediately and fills in when hydration completes.
|
* placeholder — the workspace shows the onboarding canvas instead (spec §02 →
|
||||||
|
* First-Run & Empty Workspace).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createSnippet, type Snippet } from '@core/snippet';
|
import type { Snippet } from '@core/snippet';
|
||||||
import type { Dataset } from '@core/dataset';
|
import type { Dataset } from '@core/dataset';
|
||||||
import { loadSnippets, saveSnippet } from '../infrastructure/snippet-store';
|
import { loadSnippets } from '../infrastructure/snippet-store';
|
||||||
import { loadDatasets } from '../infrastructure/dataset-store';
|
import { loadDatasets } from '../infrastructure/dataset-store';
|
||||||
import { storageErrorNotification } from '../services/storage-errors';
|
import { storageErrorNotification } from '../services/storage-errors';
|
||||||
import { notify } from '../stores/NotificationStore';
|
import { notify } from '../stores/NotificationStore';
|
||||||
@@ -28,31 +29,15 @@ export async function initApp(): Promise<void> {
|
|||||||
|
|
||||||
// If storage can't be opened (private-browsing, blocked storage), don't fail
|
// If storage can't be opened (private-browsing, blocked storage), don't fail
|
||||||
// startup silently: warn, then run this session in memory so the app is still
|
// startup silently: warn, then run this session in memory so the app is still
|
||||||
// usable. `storageOk` gates the seed-save below — there's no point trying to
|
// usable. An empty result — whether genuinely empty or a failed read — leaves
|
||||||
// persist a seed into storage we just failed to read.
|
// the library empty and the onboarding canvas takes over (spec §02).
|
||||||
let snippets: Snippet[] = [];
|
let snippets: Snippet[] = [];
|
||||||
let storageOk = true;
|
|
||||||
try {
|
try {
|
||||||
snippets = await loadSnippets();
|
snippets = await loadSnippets();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
storageOk = false;
|
|
||||||
notify(storageErrorNotification('load', err));
|
notify(storageErrorNotification('load', err));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (snippets.length === 0) {
|
|
||||||
const sample = createSnippet();
|
|
||||||
snippets = [sample];
|
|
||||||
if (storageOk) {
|
|
||||||
// Ensure the seed survives even before the first edit; a failure here is
|
|
||||||
// surfaced, not swallowed.
|
|
||||||
try {
|
|
||||||
await saveSnippet(sample);
|
|
||||||
} catch (err) {
|
|
||||||
notify(storageErrorNotification('save', err));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load the dataset library too (no first-run seed — datasets are user-created).
|
// Load the dataset library too (no first-run seed — datasets are user-created).
|
||||||
// A failure here is surfaced; the app stays usable with an empty dataset library.
|
// A failure here is surfaced; the app stays usable with an empty dataset library.
|
||||||
let datasets: Dataset[] = [];
|
let datasets: Dataset[] = [];
|
||||||
|
|||||||
@@ -138,3 +138,26 @@ describe('pane visibility (§01A)', () => {
|
|||||||
expect(store().libraryWidth).toBe(250);
|
expect(store().libraryWidth).toBe(250);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('applyOnboardingSplit (spec §02 → First-Run & Empty Workspace)', () => {
|
||||||
|
test('lays out 25·25·50 on a roomy container', () => {
|
||||||
|
const W = 1600;
|
||||||
|
store().applyOnboardingSplit(W);
|
||||||
|
expect(store().libraryWidth).toBe(W * 0.25); // 400
|
||||||
|
expect(store().previewWidth).toBe(W * 0.5); // 800 → editor flexes to the remaining ~25%
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shows all three panes, even ones that were hidden', () => {
|
||||||
|
store().hydrate({}, { library: false, editor: false, preview: false });
|
||||||
|
store().applyOnboardingSplit(1600);
|
||||||
|
expect(store().libraryVisible).toBe(true);
|
||||||
|
expect(store().editorVisible).toBe(true);
|
||||||
|
expect(store().previewVisible).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('never drops a pane below its minimum on a narrow container', () => {
|
||||||
|
store().applyOnboardingSplit(600);
|
||||||
|
expect(store().libraryWidth).toBeGreaterThanOrEqual(PANE_MIN.library);
|
||||||
|
expect(store().previewWidth).toBeGreaterThanOrEqual(PANE_MIN.preview);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -99,6 +99,15 @@ export interface PanesState {
|
|||||||
previewVisible: boolean;
|
previewVisible: boolean;
|
||||||
/** Set a side pane's width (already clamped by the caller). */
|
/** Set a side pane's width (already clamped by the caller). */
|
||||||
setWidth: (side: PaneSide, width: number) => void;
|
setWidth: (side: PaneSide, width: number) => void;
|
||||||
|
/**
|
||||||
|
* Lay the workspace out at the onboarding default split — library 25% · editor
|
||||||
|
* 25% · preview 50% of `containerWidth` — and show all three panes. Applied when
|
||||||
|
* the user leaves the empty/onboarding state by creating their first snippet(s),
|
||||||
|
* so the first chart opens with a generous preview rather than the generic
|
||||||
|
* remembered widths (spec §02 → First-Run & Empty Workspace). Widths are clamped
|
||||||
|
* to keep every pane at least its minimum.
|
||||||
|
*/
|
||||||
|
applyOnboardingSplit: (containerWidth: number) => void;
|
||||||
/** Show/hide one pane. Hiding all panes is permitted (§01A) — the strip stays. */
|
/** Show/hide one pane. Hiding all panes is permitted (§01A) — the strip stays. */
|
||||||
togglePane: (pane: PaneName) => void;
|
togglePane: (pane: PaneName) => void;
|
||||||
/** Restore persisted widths + visibility on startup; missing values keep defaults. */
|
/** Restore persisted widths + visibility on startup; missing values keep defaults. */
|
||||||
@@ -124,6 +133,31 @@ export const usePanesStore = create<PanesState>((set) => ({
|
|||||||
setWidth: (side, width) =>
|
setWidth: (side, width) =>
|
||||||
set(side === 'library' ? { libraryWidth: width } : { previewWidth: width }),
|
set(side === 'library' ? { libraryWidth: width } : { previewWidth: width }),
|
||||||
|
|
||||||
|
applyOnboardingSplit: (containerWidth) =>
|
||||||
|
set(() => {
|
||||||
|
// Clamp preview (the larger share) first, then library against it, so the
|
||||||
|
// editor keeps at least its minimum wherever the window is wide enough.
|
||||||
|
const previewWidth = clampSideWidth(
|
||||||
|
'preview',
|
||||||
|
Math.round(containerWidth * 0.5),
|
||||||
|
containerWidth,
|
||||||
|
Math.round(containerWidth * 0.25),
|
||||||
|
);
|
||||||
|
const libraryWidth = clampSideWidth(
|
||||||
|
'library',
|
||||||
|
Math.round(containerWidth * 0.25),
|
||||||
|
containerWidth,
|
||||||
|
previewWidth,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
libraryWidth,
|
||||||
|
previewWidth,
|
||||||
|
libraryVisible: true,
|
||||||
|
editorVisible: true,
|
||||||
|
previewVisible: true,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
togglePane: (pane) => set((s) => ({ [VISIBLE_KEY[pane]]: !s[VISIBLE_KEY[pane]] })),
|
togglePane: (pane) => set((s) => ({ [VISIBLE_KEY[pane]]: !s[VISIBLE_KEY[pane]] })),
|
||||||
|
|
||||||
hydrate: (layout, visibility) =>
|
hydrate: (layout, visibility) =>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user