From ca70b3e4917f1b5653430cc2ae201ba1dbd7effe Mon Sep 17 00:00:00 2001
From: Oleh Omelchenko
Date: Fri, 12 Jun 2026 21:46:25 +0300
Subject: [PATCH] =?UTF-8?q?Chart=20builder:=20discoverable=20entry=20point?=
=?UTF-8?q?s=20=E2=80=94=20Build=20Chart=20door,=20dataset=20picker,=20no-?=
=?UTF-8?q?datasets=20state?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../10-interaction-and-feedback.md | 17 ++++
docs/chart-builder-enhancement-scope.md | 42 +++++++--
docs/spec/01-application-shell.md | 17 ++--
docs/spec/02-snippet-library.md | 11 ++-
docs/spec/06-chart-builder.md | 23 ++++-
.../components/ChartBuilderModal.module.css | 35 +++++---
src/app/components/ChartBuilderModal.test.tsx | 27 +++++-
src/app/components/ChartBuilderModal.tsx | 72 +++++++++++++--
src/app/components/Icon.tsx | 11 +++
src/app/components/Onboarding.module.css | 31 +++++++
src/app/components/Onboarding.tsx | 21 ++++-
src/app/components/SnippetLibrary.module.css | 54 +++++++++--
src/app/components/SnippetLibrary.tsx | 43 +++++----
src/app/infrastructure/url-hash.test.ts | 3 +
src/app/infrastructure/url-hash.ts | 8 +-
src/app/modals/ModalCoordinator.ts | 6 ++
src/app/modals/UrlStateSync.ts | 9 +-
src/app/modals/modal-registry.ts | 6 +-
src/app/stores/ChartBuilderStore.test.ts | 77 ++++++++++++++++
src/app/stores/ChartBuilderStore.ts | 74 +++++++++++++--
src/core/chart-builder.test.ts | 89 +++++++++++++++++++
src/core/chart-builder.ts | 24 +++++
22 files changed, 625 insertions(+), 75 deletions(-)
diff --git a/docs/architecture/10-interaction-and-feedback.md b/docs/architecture/10-interaction-and-feedback.md
index 5746f1a..259b76a 100644
--- a/docs/architecture/10-interaction-and-feedback.md
+++ b/docs/architecture/10-interaction-and-feedback.md
@@ -362,6 +362,23 @@ first-use empty state only when one action stays primary. (4) **Domain vocabular
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 — the creation surface is builder-forward, Monaco-intact (3D).** The guided
+path must be visible where the intent to make a chart forms: the library's creation surface
+is a **primary "Build Chart"** (opens the Chart Builder) beside a **ghost "New JSON
+snippet"** (the old instant create, unchanged) — two plain buttons with clear hierarchy, no
+split/menu-button widget (two static choices don't earn an ARIA menu). The expert path
+stays one visible click away, never hidden (NN/g #6 recognition over recall, #7 flexibility/
+efficiency; Carbon: one primary per surface, a tertiary for the secondary CTA). The builder
+itself opens **without a preselected dataset** (it picks the most recently modified; a
+header **Dataset picker** switches without leaving), and with an empty dataset library it
+shows a **no-datasets state** per the Carbon no-data pattern: what the space does + one
+primary next step ("Add a dataset" → the Datasets create form) — never a dead end. The
+dataset-row "Build Chart" stays as the contextual shortcut; the onboarding canvas gains the
+data-first door ("Build a chart from your data") beside its primary. _(Consulted via
+/council → NN/g #6/#7, Carbon empty-states; recorded in
+docs/chart-builder-enhancement-scope.md §3 · 3D. 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
**one** subscriber is the live region (`role="alert"` on the editor, where focus is); the
diff --git a/docs/chart-builder-enhancement-scope.md b/docs/chart-builder-enhancement-scope.md
index 5afefbc..f75c08c 100644
--- a/docs/chart-builder-enhancement-scope.md
+++ b/docs/chart-builder-enhancement-scope.md
@@ -25,6 +25,34 @@
Newest first. The at-a-glance build-order tracker is §4; per-item detail is §3. This log is
the quick "where are we" — read it first.
+- **2026-06-12 (3D)** — **entry points & discoverability shipped. Up next: 3A (the
+ front door now has somewhere to be found).**
+ - **Library creation surface forked:** primary **Build Chart** (accent, takes the
+ slack) + ghost **New JSON snippet** (the old instant create) — two plain buttons,
+ no menu widget; labels collapse in two stages as the pane narrows (the long ghost
+ label first). New `chart` icon (rising columns, pane-icon rect style).
+ - **The builder opens un-targeted:** `init(null)` picks the most recently modified
+ dataset; a **Dataset picker** heads the config pane (replaces the "Building from"
+ line). Switching with an untouched config re-derives smart defaults; a built-on
+ config is **rebased** via the new core `rebaseBuilderConfig` (mark/title/size/
+ sort/stack/calculates/expression-filters kept; encodings + predicate filters bound
+ to columns the new dataset lacks shed; same-schema switch keeps everything).
+ - **No-datasets state** (Carbon no-data): what the builder does + primary "Add a
+ dataset" → Datasets create form. A defensive "Choose a dataset" chooser covers the
+ datasets-exist-but-none-loaded case. New `#build` hash serializes the
+ no-dataset builder (spec §01E table).
+ - **Onboarding gains the data-first door** ("Build a chart from your data", ghost
+ beside the primary; applies the workspace split up front since the builder can
+ create the first snippet). Dataset-row "Build Chart" unchanged (the contextual
+ shortcut).
+ - **Contract updated:** spec §02 (creation surface, onboarding), §06 (Opening
+ rewritten: doors, dataset picker, no-datasets state), §01E (`#build`); council
+ resolutions recorded in `architecture/10` §5 ("builder-forward, Monaco-intact").
+ - **Verified:** `typecheck` + `test` (873, +10: core rebase, store init(null)/
+ switchDataset, url-hash `#build`, modal empty states) + `eslint` + prettier.
+ **Owed:** a visual pass on the forked creation surface + builder picker/empty
+ states (user-driven, batched with the 2B visual debt).
+
- **2026-06-12 (polish batch)** — **the 2B visual-pass findings fixed; the parked council
batch resolved and applied; two capability gaps closed.**
- **Aggregation for any field type.** `validAggregateOps` replaces the quantitative-only
@@ -400,7 +428,7 @@ builder only creates) and the disabled-button affordance (disabled-with-reason v
park in `ux-second-pass.md` if non-obvious). _Spec impact: §02 (library row action) + §06
(hydration + the strict gate)._
-**3D · Entry points & discoverability** — _decided 2026-06-12, council-reviewed; lands with or just before 3A_
+**3D · Entry points & discoverability** — _done 2026-06-12 (council-reviewed; see status log); 3A opens onto it next_
Today the builder has **one entry, two levels deep**: Datasets modal → per-dataset "Build
Chart" (`DatasetsModal.tsx`) — the data model's shape ("charts come from datasets"), not
the user's intent ("I want to make a chart"). Meanwhile the library's pinned primary
@@ -423,9 +451,11 @@ The decided shape:
ghost/tertiary **New JSON snippet** (today's instant create, unchanged). Two plain
buttons with clear hierarchy — no split/menu-button widget (two static choices don't
earn an ARIA menu).
-2. **Dataset picker inside the builder** (header select, default = most recently used), so
- the builder opens without a preselected dataset. Build-time detail: switching datasets
- mid-build re-derives smart defaults vs. prunes incompatible encodings — decide then.
+2. **Dataset picker inside the builder** (header select, default = most recently
+ modified), so the builder opens without a preselected dataset. Build-time decision
+ taken: switching with an **untouched** opening config re-derives fresh smart defaults;
+ switching a **built-on** config rebases it (`rebaseBuilderConfig` — chart-level intent
+ kept, bindings to missing columns shed; a same-schema dataset keeps everything).
Useful beyond 3D (switch data without leaving the builder).
3. **No-datasets empty state** in the builder, per the Carbon no-data pattern: what the
builder does + one primary action ("Add a dataset" → Datasets modal). No dead end.
@@ -526,8 +556,8 @@ Phase 1 1A actionable hints ✓ done
1E expr-validate ✓ done (syntax + unknown-field; completion popup deferred)
Phase 2 2A value-or-field channels (Property model) ✓ done
2B field shelf + in-place type cycling ✓ done (field-first + on-chart shelves)
-Phase 3 3D entry points & discoverability ← next, with/just before 3A (its prerequisite)
- 3A intent-first front door (Tier C) ← built on 2B; the defining feature
+Phase 3 3D entry points & discoverability ✓ done (2026-06-12)
+ 3A intent-first front door (Tier C) ← next; built on 2B; the defining feature
3B starter examples
3C open in builder (strict hydration) ← added 2026-06-11; pairs with 3B
Phase 4 (gated) theta/facets/styling-overrides/undo/lookup — decide after Phase 3
diff --git a/docs/spec/01-application-shell.md b/docs/spec/01-application-shell.md
index 70cc39f..2599052 100644
--- a/docs/spec/01-application-shell.md
+++ b/docs/spec/01-application-shell.md
@@ -82,13 +82,16 @@ The app reflects its current location in the URL hash so that reloading restores
States and their hash forms:
-| State | Hash |
-| --------------------------- | ------------------------------ |
-| A selected snippet | `#snippet-` |
-| Datasets manager (list) | `#datasets` |
-| A specific dataset | `#datasets/dataset-` |
-| New-dataset form | `#datasets/new` |
-| Chart Builder for a dataset | `#datasets/dataset-/build` |
+| State | Hash |
+| ------------------------------ | ------------------------------ |
+| A selected snippet | `#snippet-` |
+| Datasets manager (list) | `#datasets` |
+| A specific dataset | `#datasets/dataset-` |
+| New-dataset form | `#datasets/new` |
+| Chart Builder for a dataset | `#datasets/dataset-/build` |
+| Chart Builder, no dataset open | `#build` |
+
+`#build` is the Chart Builder opened with an empty dataset library (its no-datasets state). When datasets exist, an un-targeted builder open immediately lands on one (see _Chart Builder → Opening_), so the URL shows the dataset form instead.
Behavior:
diff --git a/docs/spec/02-snippet-library.md b/docs/spec/02-snippet-library.md
index 8d05be4..e3437a9 100644
--- a/docs/spec/02-snippet-library.md
+++ b/docs/spec/02-snippet-library.md
@@ -4,10 +4,14 @@ The Snippet Library is the left pane and the primary entry point to the app. A *
## The List
-The list shows every saved snippet and is always visible. A persistent "Create New Snippet" affordance sits at the top of the list, above all snippets, so the user can always start a new snippet regardless of scroll position.
+The list shows every saved snippet and is always visible. A persistent **creation surface** sits at the top of the list, above all snippets, so the user can always start a new chart regardless of scroll position. It offers the two ways in with a clear hierarchy:
+
+- **Build Chart** — the primary action; opens the **Chart Builder** (see _Chart Builder_), the guided no-JSON path. The builder opens on the most recently modified dataset (or its no-datasets state when the library has none) — it is not gated on first selecting a dataset.
+- **New JSON snippet** — a ghost/tertiary action beside it; creates and selects a new snippet directly in the editor (see _Snippet Operations_ → Create New), the expert path — always visible, one click, never hidden behind the guided one.
+
+When the library pane is dragged narrow, the buttons shed their labels (the long ghost label first) and keep their icons and accessible names.
- The list shows all snippets, ordered newest-modified first by default (see _Sort_).
-- 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). This is the list's only empty state: a genuinely empty library never shows the list at all (see next).
@@ -17,8 +21,9 @@ The list shows every saved snippet and is always visible. A persistent "Create N
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.
+- The canvas briefly identifies what Astrolabe is, then offers the 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_).
+- **Build a chart from your data** — the data-first door beside the primary; opens the **Chart Builder** over the canvas. With no datasets yet, the builder's no-datasets state explains itself and leads to "Add a dataset" (see _Chart Builder → Opening_) — the path never dead-ends. Opening the builder also lays the workspace out at the default split below, since creating from the builder leaves the canvas directly.
- 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.
diff --git a/docs/spec/06-chart-builder.md b/docs/spec/06-chart-builder.md
index fc491fa..afb09f5 100644
--- a/docs/spec/06-chart-builder.md
+++ b/docs/spec/06-chart-builder.md
@@ -6,15 +6,30 @@ The Chart Builder is a visual, no-JSON way to compose a Vega-Lite chart from a s
## Opening
-- Launched from a selected dataset in the _Datasets_ manager via that dataset's "build chart" action.
-- Opens as a modal dialog over the application; the URL reflects the dataset's "build" action so the open builder is shareable/restorable (see _Application Shell & Navigation_).
-- On open, the builder loads the selected dataset, displays its name, and pre-populates sensible defaults (see below). If no dataset is available, it shows a "No dataset loaded" message and offers no controls.
+The builder has several doors; the guided creation path must be visible where the intent to make a chart forms, not buried behind the data model.
+
+- **The library's creation surface** — the primary **Build Chart** action (see _Snippet Library → The List_). Opens the builder with no preselected dataset; the builder picks the **most recently modified dataset** itself.
+- **The onboarding canvas** — the data-first "Build a chart from your data" door (see _Snippet Library → First-Run & Empty Workspace_).
+- **A dataset's "Build Chart" action** in the _Datasets_ manager — the contextual shortcut; opens the builder on that dataset.
+- Opens as a modal dialog over the application; the URL reflects the loaded dataset's "build" form (or `#build` while no dataset is loaded) so the open builder is shareable/restorable (see _Application Shell & Navigation_).
+- On open, the builder loads its dataset and pre-populates sensible defaults (see below).
+
+### Dataset picker
+
+Which data the chart builds from is itself a builder choice. A **Dataset** picker sits at the top of the configuration pane, showing the loaded dataset and letting the user switch to any other without leaving the builder.
+
+- Switching while the configuration is still the untouched opening default **re-derives fresh smart defaults** for the new dataset.
+- Switching after the user has built on the configuration **rebases** it instead: chart-level intent survives (mark, title/subtitle, explicit size, sort/stack, calculated fields, expression filters), while bindings to columns the new dataset lacks are shed (encodings cleared, predicate filters dropped). A same-schema dataset — the common switch — keeps everything.
+
+### No datasets
+
+With an empty dataset library the builder shows a **no-datasets state** instead of controls: it says what the builder does and offers one primary next step — **Add a dataset**, which opens the _Datasets_ manager on its create form. The guided path never dead-ends.
## Layout
A two-pane modal:
-- **Left — configuration:** dataset name, a **Data** section (row filters, calculated fields, and a collapsible row preview — see _Data_ below), mark type selector, a **field shelf** (the dataset's columns, field-first), a **Marks** card (the Colour and Size encodings), and a "Create Snippet" action. Chart-level properties (title/subtitle, width/height) sit on the **preview side**, under the chart (see _Chart properties_).
+- **Left — configuration:** the dataset picker, a **Data** section (row filters, calculated fields, and a collapsible row preview — see _Data_ below), mark type selector, a **field shelf** (the dataset's columns, field-first), a **Marks** card (the Colour and Size encodings), and a "Create Snippet" action. Chart-level properties (title/subtitle, width/height) sit on the **preview side**, under the chart (see _Chart properties_).
- **Right — live preview:** the **Columns** (X) and **Rows** (Y) shelves stacked above a rendered chart that updates as the configuration changes, with a placeholder/error area. Position is a property of the chart, so its controls sit on the chart (Tableau's Columns/Rows metaphor).
## Data (preview, filters, calculated fields)
diff --git a/src/app/components/ChartBuilderModal.module.css b/src/app/components/ChartBuilderModal.module.css
index dce290c..ec8ef6d 100644
--- a/src/app/components/ChartBuilderModal.module.css
+++ b/src/app/components/ChartBuilderModal.module.css
@@ -13,10 +13,28 @@
min-width: 0;
}
-.muted {
+/* ── No-dataset states: Carbon no-data empty state / dataset chooser ──── */
+
+.emptyState {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: var(--space-4);
+ max-width: 48ch;
+ padding: var(--space-7) var(--space-6);
+}
+
+.emptyTitle {
+ margin: 0;
+ font-size: 16px;
+ font-weight: 600;
+ color: var(--text);
+}
+
+.emptyBody {
margin: 0;
- padding: var(--space-5);
font-size: 14px;
+ line-height: 1.5;
color: var(--text-secondary);
}
@@ -33,14 +51,11 @@
min-height: 0;
}
-.datasetName {
- margin: 0;
- font-size: 13px;
- color: var(--text-secondary);
-}
-
-.datasetName strong {
- color: var(--text);
+/* The dataset picker pinned at the top of the config pane. */
+.datasetRow {
+ display: flex;
+ align-items: center;
+ gap: var(--space-3);
}
/* ── Data section: filters, calculated fields, row preview (spec §06 → Data) ──── */
diff --git a/src/app/components/ChartBuilderModal.test.tsx b/src/app/components/ChartBuilderModal.test.tsx
index 716de13..6c5ceb9 100644
--- a/src/app/components/ChartBuilderModal.test.tsx
+++ b/src/app/components/ChartBuilderModal.test.tsx
@@ -80,7 +80,7 @@ describe('ChartBuilderModal', () => {
await Promise.resolve();
});
- expect(container.textContent).toContain('Building from');
+ expect(container.textContent).toContain('Nums'); // the dataset picker names the data
expect(container.textContent).toContain('scatter'); // the guidance hint rendered
});
@@ -155,12 +155,33 @@ describe('ChartBuilderModal', () => {
vi.useRealTimers();
});
- test('shows the empty state when no dataset is loaded', async () => {
+ test('shows the no-datasets empty state with its one next step (3D)', async () => {
await act(async () => {
root.render();
await Promise.resolve();
});
- expect(container.textContent).toContain('No dataset loaded');
+ expect(container.textContent).toContain('No datasets yet');
+ const action = Array.from(container.querySelectorAll('button')).find(
+ (b) => b.textContent === 'Add a dataset',
+ );
+ expect(action).toBeDefined();
+ });
+
+ test('offers a dataset chooser when datasets exist but none is loaded', async () => {
+ const ds = createDataset({
+ name: 'Waiting',
+ data: [{ a: 1 }],
+ format: 'json',
+ source: 'inline',
+ now: T,
+ });
+ useDatasetStore.getState().add(ds);
+
+ await act(async () => {
+ root.render();
+ await Promise.resolve();
+ });
+ expect(container.textContent).toContain('Choose a dataset');
});
test('a complete filter row reaches the renderer as a top-level transform (1C)', async () => {
diff --git a/src/app/components/ChartBuilderModal.tsx b/src/app/components/ChartBuilderModal.tsx
index 6783fa5..d2b8be8 100644
--- a/src/app/components/ChartBuilderModal.tsx
+++ b/src/app/components/ChartBuilderModal.tsx
@@ -61,7 +61,7 @@ import type { ColumnType } from '@core/type-inference';
import { DatasetNotFoundError, prepareSpecForRender } from '@core/rendering';
import { chartConfigFor } from '@core/vega-themes';
import { ChartTooLargeError, renderSpec, type RenderHandle } from '../services/chart-renderer';
-import { closeModal } from '../modals/ModalCoordinator';
+import { closeModal, openModal } from '../modals/ModalCoordinator';
import { useAppStore } from '../stores/AppStore';
import { useDatasetStore } from '../stores/DatasetStore';
import {
@@ -1264,9 +1264,62 @@ function ChartProps() {
);
}
+/**
+ * The dataset picker at the top of the config pane (spec §06 → Dataset picker):
+ * which data the chart builds from is itself a builder choice, so the builder can
+ * open without a preselected dataset and the data can be switched without leaving.
+ * Switching re-derives smart defaults while the config is untouched, and rebases
+ * (keeps chart-level intent, sheds bindings to missing columns) once it isn't.
+ */
+function DatasetPicker({ datasetId }: { datasetId: number | null }) {
+ const datasets = useDatasetStore(useShallow((s) => s.datasets));
+ const switchDataset = useChartBuilderStore((s) => s.switchDataset);
+ return (
+
+ );
+}
+
+/**
+ * The no-datasets state (spec §06 → Opening; Carbon no-data empty state): says
+ * what the builder does and offers the one next step — never a dead end. The
+ * action swaps this modal for the Datasets manager opened on its create form.
+ */
+function NoDatasets() {
+ return (
+
+
No datasets yet
+
+ The Chart Builder turns a saved dataset into a chart — pick columns, watch the chart take
+ shape, and save it as a snippet. Add a dataset to start building.
+
No dataset loaded. Open this from a dataset in Datasets.
;
+ if (datasetCount === 0) return ;
+ // Datasets exist but none is loaded (init auto-picks, so this is a fallback
+ // for a builder restored into an odd state): offer the choice directly.
+ return (
+
+
diff --git a/src/app/components/Icon.tsx b/src/app/components/Icon.tsx
index 70856d2..8945b1b 100644
--- a/src/app/components/Icon.tsx
+++ b/src/app/components/Icon.tsx
@@ -27,6 +27,7 @@ export type IconName =
| 'dataset' // "references a dataset" — Carbon DataTable
| 'delete' // delete — Carbon TrashCan
| 'add' // add / create-new — Carbon Add
+ | 'chart' // build / open a chart — rising columns on a baseline (pane-icon style)
| 'search' // live library search — Carbon Search
| 'settings' // per-pane settings disclosure (gear) — Carbon Settings
| 'import' // import a workspace file — Carbon Upload (a file goes in)
@@ -55,6 +56,16 @@ const GLYPHS: Record = {
),
add: ,
+ // Rising columns on a baseline — drawn in the pane-icon rect style (not a Carbon
+ // trace) so it reads at 16px next to the pane glyphs.
+ chart: (
+ <>
+
+
+
+
+ >
+ ),
// Carbon Search — magnifying glass, the active-search affordance (council SEARCH).
search: (
diff --git a/src/app/components/Onboarding.module.css b/src/app/components/Onboarding.module.css
index 9730d30..9d1eb38 100644
--- a/src/app/components/Onboarding.module.css
+++ b/src/app/components/Onboarding.module.css
@@ -28,6 +28,13 @@
line-height: 1.5;
}
+/* The two ways in, side by side: sample-first (primary) and data-first (ghost). */
+.ctaRow {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--space-3);
+}
+
/* Primary call to action — the accent button, matching the library's Create. */
.primary {
display: inline-flex;
@@ -52,6 +59,30 @@
outline-offset: 2px;
}
+/* The data-first door — bordered ghost, same height as the primary beside it. */
+.buildCta {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--space-2);
+ height: 40px;
+ padding: 0 var(--space-5);
+ border: var(--border-width) solid var(--border-strong);
+ border-radius: var(--radius);
+ background: transparent;
+ color: var(--text);
+ font: inherit;
+ font-weight: 600;
+ cursor: pointer;
+ transition: background var(--dur-fast) var(--ease);
+}
+.buildCta:hover {
+ background: var(--layer-01);
+}
+.buildCta: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;
diff --git a/src/app/components/Onboarding.tsx b/src/app/components/Onboarding.tsx
index 2079b20..ba6b8e6 100644
--- a/src/app/components/Onboarding.tsx
+++ b/src/app/components/Onboarding.tsx
@@ -19,6 +19,7 @@ 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 { openModal } from '../modals/ModalCoordinator';
import { renderSpec, type RenderHandle } from '../services/chart-renderer';
import { useAppStore } from '../stores/AppStore';
import { usePanesStore } from '../stores/PanesStore';
@@ -105,6 +106,15 @@ export function Onboarding() {
layoutWorkspace();
};
+ // The data-first door (spec §02): straight into the Chart Builder, which picks
+ // up the freshest dataset — or, with none yet, explains itself and offers "Add a
+ // dataset". The workspace split is applied up front: if the builder creates the
+ // first snippet, onboarding unmounts without another chance to lay the panes out.
+ const handleBuild = () => {
+ layoutWorkspace();
+ openModal('chartBuilder');
+ };
+
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
@@ -130,9 +140,14 @@ export function Onboarding() {
your device.
-
+
+
+
+
Or start from an example
diff --git a/src/app/components/SnippetLibrary.module.css b/src/app/components/SnippetLibrary.module.css
index d52b030..7ef8908 100644
--- a/src/app/components/SnippetLibrary.module.css
+++ b/src/app/components/SnippetLibrary.module.css
@@ -28,15 +28,23 @@
color: var(--text-secondary);
}
-.createNew {
+/* The creation surface: primary Build Chart beside the ghost New-JSON action
+ (spec §02). One row, the primary takes the slack. */
+.createRow {
flex: 0 0 auto;
+ display: flex;
+ gap: var(--space-2);
+ margin: var(--space-4);
+}
+
+.createNew {
+ flex: 1 1 auto;
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
- margin: var(--space-4);
height: 40px;
- padding: 0 var(--space-5);
+ padding: 0 var(--space-4);
border: var(--border-width) solid transparent;
border-radius: var(--radius);
background: var(--accent);
@@ -54,10 +62,42 @@
background: var(--accent-hover);
}
-/* Narrow pane → the CTA sheds its label and centres the "+" icon (the button stays
- full-width, so it keeps a large target). The threshold sits comfortably above the
- 216px pane minimum, so the full label is shown across the normal width range and
- only collapses when the pane is dragged tight. */
+/* The expert accelerator: a quiet bordered ghost beside the primary. */
+.createGhost {
+ flex: 0 1 auto;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: var(--space-2);
+ height: 40px;
+ padding: 0 var(--space-3);
+ border: var(--border-width) solid var(--border);
+ border-radius: var(--radius);
+ background: transparent;
+ color: var(--text-secondary);
+ font: inherit;
+ white-space: nowrap;
+ cursor: pointer;
+ transition:
+ background var(--dur-fast) var(--ease),
+ color var(--dur-fast) var(--ease);
+}
+
+.createGhost:hover {
+ background: var(--layer-01);
+ color: var(--text);
+}
+
+/* Two-stage label collapse as the pane narrows: the long ghost label goes first
+ (its icon + title still name it), then below the tight threshold the primary's
+ label too — both buttons stay side by side with large targets. The lower
+ threshold sits comfortably above the 216px pane minimum. */
+@container (max-width: 340px) {
+ .createGhost .createNewLabel {
+ display: none;
+ }
+}
+
@container (max-width: 250px) {
.createNewLabel {
display: none;
diff --git a/src/app/components/SnippetLibrary.tsx b/src/app/components/SnippetLibrary.tsx
index 80ed0fb..aae5bd1 100644
--- a/src/app/components/SnippetLibrary.tsx
+++ b/src/app/components/SnippetLibrary.tsx
@@ -20,6 +20,7 @@ import {
type Snippet,
} from '@core/snippet';
import { filterAndSortSnippets } from '@core/snippet-sort';
+import { openModal } from '../modals/ModalCoordinator';
import { confirm } from '../stores/ConfirmStore';
import { notify } from '../stores/NotificationStore';
import { selectActiveSnippet, useSnippetStore } from '../stores/SnippetStore';
@@ -251,21 +252,33 @@ export function SnippetLibrary() {
- {/* Create raises no toast: the new snippet opens in the editor, so the
- result is already on-screen (spec §02; docs/architecture/10 → Toast
- copy). Delete/duplicate toast because the outcome isn't visible. */}
- {/* Primary CTA. When the library pane is dragged narrow the label collapses
- to just the "+" icon (a @container query) so it never wraps to two lines;
- `aria-label` keeps the accessible name when the text is hidden. */}
-
+ {/* The creation surface (spec §02): the guided path is the primary — Build
+ Chart opens the Chart Builder — and raw JSON stays one visible click away
+ as a ghost action (NN/g #6 recognition / #7 expert accelerator; one
+ primary per surface — Carbon). Neither raises a toast: both outcomes
+ open on-screen (the builder, or the new snippet in the editor). When the
+ pane is dragged narrow the labels collapse to icons (a @container
+ query); `aria-label` keeps each accessible name. */}
+
+
+
+
{/* Search + Sort controls, pinned above the list (spec §02; council
SEARCH + SORT). Search is an unlabelled Carbon active-search: a search
diff --git a/src/app/infrastructure/url-hash.test.ts b/src/app/infrastructure/url-hash.test.ts
index 6bca42b..3e774d5 100644
--- a/src/app/infrastructure/url-hash.test.ts
+++ b/src/app/infrastructure/url-hash.test.ts
@@ -16,6 +16,7 @@ const ALL_VIEWS: ViewState[] = [
{ kind: 'dataset', datasetId: 7 },
{ kind: 'dataset-new' },
{ kind: 'dataset-build', datasetId: 42 },
+ { kind: 'build' },
];
describe('serializeHash', () => {
@@ -28,6 +29,7 @@ describe('serializeHash', () => {
expect(serializeHash({ kind: 'dataset-build', datasetId: 42 })).toBe(
'#datasets/dataset-42/build',
);
+ expect(serializeHash({ kind: 'build' })).toBe('#build');
});
});
@@ -42,6 +44,7 @@ describe('parseHash', () => {
kind: 'dataset-build',
datasetId: 42,
});
+ expect(parseHash('#build')).toEqual({ kind: 'build' });
});
it('tolerates a missing leading "#"', () => {
diff --git a/src/app/infrastructure/url-hash.ts b/src/app/infrastructure/url-hash.ts
index d9f01cb..851a746 100644
--- a/src/app/infrastructure/url-hash.ts
+++ b/src/app/infrastructure/url-hash.ts
@@ -18,6 +18,7 @@
* dataset → #datasets/dataset- (id is a decimal number)
* dataset-new → #datasets/new
* dataset-build → #datasets/dataset-/build
+ * build → #build (builder, no dataset yet)
*/
/** The serialized view. Snippet id is opaque; dataset id is the numeric id. */
@@ -27,7 +28,8 @@ export type ViewState =
| { kind: 'datasets' } // #datasets
| { kind: 'dataset'; datasetId: number } // #datasets/dataset-
| { kind: 'dataset-new' } // #datasets/new
- | { kind: 'dataset-build'; datasetId: number }; // #datasets/dataset-/build
+ | { kind: 'dataset-build'; datasetId: number } // #datasets/dataset-/build
+ | { kind: 'build' }; // #build — the Chart Builder with no dataset loaded yet
/**
* Parse a raw hash (with or without the leading `#`) into a typed `ViewState`.
@@ -41,6 +43,8 @@ export function parseHash(rawHash: string): ViewState {
const snippet = /^snippet-(.+)$/.exec(hash);
if (snippet) return { kind: 'snippet', snippetId: snippet[1] };
+ if (hash === 'build') return { kind: 'build' };
+
const parts = hash.split('/').filter(Boolean);
if (parts[0] === 'datasets') {
if (parts.length === 1) return { kind: 'datasets' };
@@ -75,6 +79,8 @@ export function serializeHash(view: ViewState): string {
return '#datasets/new';
case 'dataset-build':
return `#datasets/dataset-${view.datasetId}/build`;
+ case 'build':
+ return '#build';
default: {
const _exhaustive: never = view;
return _exhaustive;
diff --git a/src/app/modals/ModalCoordinator.ts b/src/app/modals/ModalCoordinator.ts
index 4a6c09e..8013ed4 100644
--- a/src/app/modals/ModalCoordinator.ts
+++ b/src/app/modals/ModalCoordinator.ts
@@ -31,6 +31,12 @@ function snapshotOf(name: ModalName | null): string | null {
/** Open `name`, optionally with a sub-target (dataset id, source key). */
export function openModal(name: ModalName, arg?: string): void {
+ // TODO: setActiveModal fires the UrlStateSync subscribers before init() has
+ // loaded the target, so a navigable modal pushes one hash derived from the
+ // *previous* modal state (e.g. a stale `#datasets/dataset-N/build`) before the
+ // correct one — a spurious Back step. Swapping the two lines or suppressing
+ // sync until init completes would fix it; check init implementations don't
+ // assume the modal is already active.
useAppStore.getState().setActiveModal(name);
getModalConfig(name)?.init?.(arg);
stateSnapshot = snapshotOf(name);
diff --git a/src/app/modals/UrlStateSync.ts b/src/app/modals/UrlStateSync.ts
index f6b8536..9231449 100644
--- a/src/app/modals/UrlStateSync.ts
+++ b/src/app/modals/UrlStateSync.ts
@@ -58,7 +58,7 @@ export function deriveViewState(): ViewState {
if (modal && getModalConfig(modal)?.isUrlNavigable) {
if (modal === 'chartBuilder') {
const datasetId = useChartBuilderStore.getState().datasetId;
- return datasetId !== null ? { kind: 'dataset-build', datasetId } : { kind: 'datasets' };
+ return datasetId !== null ? { kind: 'dataset-build', datasetId } : { kind: 'build' };
}
if (modal === 'datasets') {
const ds = useDatasetStore.getState();
@@ -136,6 +136,13 @@ export function applyView(view: ViewState): void {
app.setActiveModal('datasets');
useDatasetStore.getState().startCreate();
return;
+ case 'build':
+ // The no-target builder door. init(null) picks the freshest dataset, so
+ // with any datasets present the derived view immediately self-corrects to
+ // `dataset-build`; only an empty dataset library stays on `#build`.
+ useChartBuilderStore.getState().init(null);
+ app.setActiveModal('chartBuilder');
+ return;
}
} finally {
applying = false;
diff --git a/src/app/modals/modal-registry.ts b/src/app/modals/modal-registry.ts
index e2bc510..98f4f59 100644
--- a/src/app/modals/modal-registry.ts
+++ b/src/app/modals/modal-registry.ts
@@ -71,8 +71,10 @@ const MODAL_REGISTRY: Partial> = {
getState: () => ({ name: useExtractStore.getState().name }),
},
- // Opened from a selected dataset's "Build Chart" action; `arg` is its id. Loads
- // the dataset and pre-populates a smart default config (§06). Applies on Create
+ // Opened from a dataset's "Build Chart" action (`arg` is its id) or from the
+ // library / onboarding doors with no arg — init(null) then picks the most
+ // recently modified dataset (§06 → Opening). Loads the dataset and
+ // pre-populates a smart default config (§06). Applies on Create
// (a new snippet), so there is nothing transient to lose on close — no getState.
// Backdrop dismissal is off: the config is real in-progress work, and a stray
// click outside this large surface shouldn't throw it away (Escape/× still close).
diff --git a/src/app/stores/ChartBuilderStore.test.ts b/src/app/stores/ChartBuilderStore.test.ts
index 4a84d69..14a9377 100644
--- a/src/app/stores/ChartBuilderStore.test.ts
+++ b/src/app/stores/ChartBuilderStore.test.ts
@@ -370,3 +370,80 @@ describe('constant values (setChannelConstant — the Property model)', () => {
expect(cb().config.encodings.x).toBe(before); // unchanged — no-op
});
});
+
+describe('init(null) — the un-targeted doors (library / onboarding)', () => {
+ test('picks the most recently modified dataset', () => {
+ const dsOld = createDataset({
+ name: 'Older',
+ data: [{ a: 'x', b: 1 }],
+ format: 'json',
+ source: 'inline',
+ now: new Date('2026-05-01T00:00:00Z'),
+ });
+ const dsNew = createDataset({
+ name: 'Fresher',
+ data: [{ c: 'y', d: 2 }],
+ format: 'json',
+ source: 'inline',
+ now: new Date('2026-06-01T00:00:00Z'),
+ });
+ useDatasetStore.getState().add(dsOld);
+ useDatasetStore.getState().add(dsNew);
+
+ cb().init(null);
+ expect(cb().config.datasetName).toBe('Fresher');
+ expect(cb().datasetId).not.toBeNull();
+ });
+
+ test('lands empty when the dataset library is empty', () => {
+ cb().init(null);
+ expect(cb().datasetId).toBeNull();
+ expect(cb().config.encodings).toEqual({});
+ });
+});
+
+describe('switchDataset (the builder dataset picker)', () => {
+ test('an untouched default config re-derives smart defaults for the new dataset', () => {
+ const aId = seedDataset('A', [{ region: 'N', revenue: 5 }]);
+ const bId = seedDataset('B', [{ day: '2026-01-01', visits: 10 }]);
+ cb().init(aId);
+
+ cb().switchDataset(bId);
+ expect(cb().datasetId).toBe(bId);
+ expect(cb().config.datasetName).toBe('B');
+ // Fresh defaults for B's columns, not a pruned remnant of A's.
+ expect(cb().config.encodings.x).toEqual({ field: 'day', type: 'temporal' });
+ });
+
+ test('a built-on config is rebased: chart intent kept, missing-column bindings shed', () => {
+ const aId = seedDataset('A', [{ region: 'N', revenue: 5 }]);
+ const bId = seedDataset('B', [{ region: 'S', profit: 2 }]);
+ cb().init(aId);
+ cb().setChannelColumn('x', 'region');
+ cb().setChannelColumn('y', 'revenue');
+ cb().setTitle('Revenue by region');
+ cb().addFilter();
+ const fid = useChartBuilderStore.getState().config.filters![0].id;
+ cb().setFilterField(fid, 'revenue');
+
+ cb().switchDataset(bId);
+ const config = cb().config;
+ expect(config.datasetName).toBe('B');
+ expect(config.title).toBe('Revenue by region');
+ expect(config.encodings.x).toEqual({ field: 'region', type: 'nominal' }); // shared column survives
+ expect(config.encodings.y).toBeNull(); // `revenue` doesn't exist on B
+ expect(config.filters).toEqual([]); // the revenue filter is shed too
+ });
+
+ test('no-op for an unknown id or the already-loaded dataset', () => {
+ const aId = seedDataset('A', [{ region: 'N', revenue: 5 }]);
+ cb().init(aId);
+ cb().setTitle('Kept');
+
+ const before = cb().config;
+ cb().switchDataset(999);
+ expect(cb().config).toBe(before);
+ cb().switchDataset(aId);
+ expect(cb().config).toBe(before);
+ });
+});
diff --git a/src/app/stores/ChartBuilderStore.ts b/src/app/stores/ChartBuilderStore.ts
index eb7e760..95b2b5b 100644
--- a/src/app/stores/ChartBuilderStore.ts
+++ b/src/app/stores/ChartBuilderStore.ts
@@ -8,9 +8,11 @@
* that, plus the create-flow side effects (new snippet, toast, activate, close).
*
* `init(datasetId)` loads the dataset's columns and pre-populates a smart default
- * config; with no dataset it lands empty so the modal can show "No dataset loaded".
- * The mark is sticky after open (changing a column does not re-derive it) so the
- * user's choice is never overridden mid-edit.
+ * config. `init(null)` — the library's Build-Chart door, which opens without a
+ * target (spec §06 → Opening) — picks the most recently modified dataset; only
+ * with an empty dataset library does the builder land empty (the modal then shows
+ * the no-datasets state). The mark is sticky after open (changing a column does
+ * not re-derive it) so the user's choice is never overridden mid-edit.
*/
import { create } from 'zustand';
@@ -27,6 +29,7 @@ import {
isChannelTypeAllowed,
isColumnAllowedOnChannel,
pruneEncodings,
+ rebaseBuilderConfig,
supportsBin,
supportsTimeUnit,
validAggregateOps,
@@ -81,6 +84,12 @@ export interface ChartBuilderState {
rowCount: number | null;
/** The working configuration the preview and the produced spec read from. */
config: BuilderConfig;
+ /**
+ * The exact config object `init` produced. Mutations replace `config` via
+ * spread, so `config === initialConfig` means "still the untouched opening
+ * default" — the dataset switch uses it to choose re-derive vs rebase.
+ */
+ initialConfig: BuilderConfig;
/**
* The channel "armed" to receive the next clicked field (field-first assignment,
* spec §06 → Encoding). Clicking an empty channel slot arms it; clicking a field in
@@ -89,8 +98,19 @@ export interface ChartBuilderState {
*/
activeChannel: ChannelName | null;
- /** Load a dataset and pre-populate a smart default config (spec §06 → Opening). */
+ /**
+ * Load a dataset and pre-populate a smart default config (spec §06 → Opening).
+ * `null` picks the most recently modified dataset (the un-targeted entry points).
+ */
init: (datasetId: number | null) => void;
+ /**
+ * Re-point the open builder at another dataset (the header picker, spec §06).
+ * An untouched default config re-derives fresh smart defaults for the new
+ * dataset; a config the user has built on is rebased instead — chart-level
+ * intent kept, bindings to columns the new dataset lacks shed (core
+ * `rebaseBuilderConfig`).
+ */
+ switchDataset: (datasetId: number) => void;
setMark: (mark: MarkType) => void;
/**
* Map a column to a channel (null = "None", `COUNT_FIELD` = a field-less count);
@@ -220,19 +240,27 @@ export const useChartBuilderStore = create((set, get) => ({
columns: EMPTY_COLUMNS,
rowCount: null,
config: EMPTY_CONFIG,
+ initialConfig: EMPTY_CONFIG,
activeChannel: null,
init: (datasetId) => {
+ const all = useDatasetStore.getState().datasets;
+ // An un-targeted open (the library / onboarding doors) lands on the most
+ // recently modified dataset — the freshest data is the likeliest subject.
const dataset =
datasetId === null
- ? undefined
- : useDatasetStore.getState().datasets.find((d) => d.id === datasetId);
+ ? all.reduce<(typeof all)[number] | undefined>(
+ (best, d) => (best === undefined || d.modified > best.modified ? d : best),
+ undefined,
+ )
+ : all.find((d) => d.id === datasetId);
if (!dataset) {
set({
datasetId: null,
columns: EMPTY_COLUMNS,
rowCount: null,
config: EMPTY_CONFIG,
+ initialConfig: EMPTY_CONFIG,
activeChannel: null,
});
return;
@@ -242,11 +270,42 @@ export const useChartBuilderStore = create((set, get) => ({
columnTypes: dataset.columnTypes,
columnStats: dataset.columnStats,
};
+ const config = defaultBuilderConfig(dataset.name, columns);
set({
datasetId: dataset.id,
columns,
rowCount: dataset.rowCount,
- config: defaultBuilderConfig(dataset.name, columns),
+ config,
+ initialConfig: config,
+ activeChannel: null,
+ });
+ },
+
+ switchDataset: (datasetId) => {
+ const s = get();
+ if (datasetId === s.datasetId) return;
+ const dataset = useDatasetStore.getState().datasets.find((d) => d.id === datasetId);
+ if (!dataset) return;
+ // An untouched opening config (every mutation replaces `config` via spread, so
+ // reference identity with the init-produced object means nothing changed) — or
+ // no dataset at all — re-derives fresh defaults for the new one; anything the
+ // user has built on is rebased so their work survives the switch.
+ if (s.datasetId === null || s.config === s.initialConfig) {
+ get().init(datasetId);
+ return;
+ }
+ const columns: BuilderColumns = {
+ columns: dataset.columns,
+ columnTypes: dataset.columnTypes,
+ columnStats: dataset.columnStats,
+ };
+ // `initialConfig` is deliberately left stale: a rebased config is still the
+ // user's built-on work, so a later switch must rebase again, never re-derive.
+ set({
+ datasetId: dataset.id,
+ columns,
+ rowCount: dataset.rowCount,
+ config: rebaseBuilderConfig(s.config, dataset.name, columns),
activeChannel: null,
});
},
@@ -499,6 +558,7 @@ export const useChartBuilderStore = create((set, get) => ({
columns: EMPTY_COLUMNS,
rowCount: null,
config: EMPTY_CONFIG,
+ initialConfig: EMPTY_CONFIG,
activeChannel: null,
}),
}));
diff --git a/src/core/chart-builder.test.ts b/src/core/chart-builder.test.ts
index 776ed8a..69f71af 100644
--- a/src/core/chart-builder.test.ts
+++ b/src/core/chart-builder.test.ts
@@ -28,6 +28,7 @@ import {
calculatedFieldNames,
effectiveColumns,
pruneEncodings,
+ rebaseBuilderConfig,
type BuilderCalculate,
type BuilderColumns,
type BuilderConfig,
@@ -1290,3 +1291,91 @@ describe('pruneEncodings', () => {
expect(pruneEncodings(config, columns)).toBe(config);
});
});
+
+describe('rebaseBuilderConfig (dataset switch)', () => {
+ // The new dataset shares `category` but lacks `value`/`when`/`flag`.
+ const newColumns: BuilderColumns = {
+ columns: ['category', 'profit'],
+ columnTypes: [
+ { name: 'category', type: 'string' },
+ { name: 'profit', type: 'number' },
+ ],
+ };
+
+ it('keeps chart-level intent and same-name bindings, re-pointing the dataset', () => {
+ const config: BuilderConfig = {
+ datasetName: 'Old',
+ mark: 'bar',
+ title: 'Revenue by region',
+ subtitle: 'FY26',
+ width: 400,
+ height: 200,
+ sort: 'descending',
+ stack: 'normalize',
+ encodings: { x: { field: 'category', type: 'nominal' } },
+ };
+ const out = rebaseBuilderConfig(config, 'New', newColumns);
+ expect(out.datasetName).toBe('New');
+ expect(out.mark).toBe('bar');
+ expect(out.title).toBe('Revenue by region');
+ expect(out.subtitle).toBe('FY26');
+ expect(out.width).toBe(400);
+ expect(out.height).toBe(200);
+ expect(out.sort).toBe('descending');
+ expect(out.stack).toBe('normalize');
+ expect(out.encodings.x).toEqual({ field: 'category', type: 'nominal' });
+ });
+
+ it('sheds encodings and predicate filters bound to columns the new dataset lacks', () => {
+ const config: BuilderConfig = {
+ datasetName: 'Old',
+ mark: 'bar',
+ encodings: {
+ x: { field: 'category', type: 'nominal' },
+ y: { field: 'value', type: 'quantitative' },
+ },
+ filters: [
+ filter({ id: 'f1', field: 'value', fieldType: 'quantitative', op: 'gt', value: '0' }),
+ filter({ id: 'f2', field: 'category', fieldType: 'nominal', op: 'equal', value: 'A' }),
+ ],
+ };
+ const out = rebaseBuilderConfig(config, 'New', newColumns);
+ expect(out.encodings.x).toEqual({ field: 'category', type: 'nominal' });
+ expect(out.encodings.y).toBeNull();
+ expect(out.filters?.map((f) => f.id)).toEqual(['f2']);
+ });
+
+ it('keeps expression filters, constants, count mappings, and calculated-field bindings', () => {
+ const config: BuilderConfig = {
+ datasetName: 'Old',
+ mark: 'bar',
+ encodings: {
+ x: { field: 'ratio', type: 'quantitative' }, // a calculated field travels along
+ y: { type: 'quantitative', aggregate: 'count' }, // field-less count
+ color: { value: '#ff0000', type: 'nominal' }, // constant — no column binding
+ },
+ calculates: [calc({ as: 'ratio', expr: 'datum.profit * 2' })],
+ filters: [filter({ id: 'fx', mode: 'expression', expr: 'datum.value > 0' })],
+ };
+ const out = rebaseBuilderConfig(config, 'New', newColumns);
+ expect(out.encodings.x).toEqual({ field: 'ratio', type: 'quantitative' });
+ expect(out.encodings.y).toEqual({ type: 'quantitative', aggregate: 'count' });
+ expect(out.encodings.color).toEqual({ value: '#ff0000', type: 'nominal' });
+ expect(out.calculates).toEqual(config.calculates);
+ expect(out.filters).toEqual(config.filters);
+ });
+
+ it('a same-schema dataset keeps the whole config (only the name changes)', () => {
+ const config: BuilderConfig = {
+ datasetName: 'Old',
+ mark: 'line',
+ encodings: {
+ x: { field: 'when', type: 'temporal' },
+ y: { field: 'value', type: 'quantitative', aggregate: 'sum' },
+ },
+ filters: [filter({ field: 'category', fieldType: 'nominal', op: 'equal', value: 'A' })],
+ };
+ const out = rebaseBuilderConfig(config, 'New', columns);
+ expect(out).toEqual({ ...config, datasetName: 'New' });
+ });
+});
diff --git a/src/core/chart-builder.ts b/src/core/chart-builder.ts
index 8e091f2..b572af2 100644
--- a/src/core/chart-builder.ts
+++ b/src/core/chart-builder.ts
@@ -1034,6 +1034,30 @@ export function pruneEncodings(config: BuilderConfig, base: BuilderColumns): Bui
return changed ? { ...config, encodings } : config;
}
+/**
+ * Re-point an in-progress config at a different dataset (spec §06 → Dataset
+ * picker). Chart-level intent survives the switch — mark, title/subtitle,
+ * explicit size, sort/stack, calculated fields, and expression filters (their
+ * `datum` references are surfaced by the unknown-field feedback, not dropped) —
+ * while anything bound to a column the new dataset doesn't have is shed:
+ * encodings via `pruneEncodings`, predicate filters by field lookup. With a
+ * same-schema dataset (the common switch: a fresher version of the same data)
+ * everything survives verbatim.
+ */
+export function rebaseBuilderConfig(
+ config: BuilderConfig,
+ datasetName: string,
+ columns: BuilderColumns,
+): BuilderConfig {
+ const available = new Set(effectiveColumns(columns, config.calculates).columns);
+ const filters = (config.filters ?? []).filter(
+ (f) => f.mode === 'expression' || (f.field !== undefined && available.has(f.field)),
+ );
+ const rebased: BuilderConfig = { ...config, datasetName };
+ if (config.filters !== undefined) rebased.filters = filters;
+ return pruneEncodings(rebased, columns);
+}
+
/** A built Vega-Lite spec, as a plain object (serialize with `buildSnippetSpecText`). */
export type ChartSpec = Record;