Chart builder: discoverable entry points — Build Chart door, dataset picker, no-datasets state

This commit is contained in:
2026-06-12 21:46:25 +03:00
parent dbb4522d78
commit ca70b3e491
22 changed files with 625 additions and 75 deletions
@@ -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
+36 -6
View File
@@ -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
+4 -1
View File
@@ -83,12 +83,15 @@ 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-<id>` |
| Datasets manager (list) | `#datasets` |
| A specific dataset | `#datasets/dataset-<id>` |
| New-dataset form | `#datasets/new` |
| Chart Builder for a dataset | `#datasets/dataset-<id>/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:
+8 -3
View File
@@ -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.
+19 -4
View File
@@ -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)
+25 -10
View File
@@ -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) ──── */
+24 -3
View File
@@ -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(<ChartBuilderModal />);
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(<ChartBuilderModal />);
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 () => {
+66 -6
View File
@@ -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 (
<div className={styles.datasetRow}>
<span className={styles.fieldLabel}>Dataset</span>
<SelectControl
id="cb-dataset"
label="Dataset to build from"
heading="Dataset"
options={datasets.map((d) => ({ value: String(d.id), label: d.name }))}
value={datasetId !== null ? String(datasetId) : undefined}
onSelect={(v) => switchDataset(Number(v))}
triggerContent={datasetId === null ? 'Choose a dataset…' : undefined}
/>
</div>
);
}
/**
* 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 (
<div className={styles.emptyState}>
<h3 className={styles.emptyTitle}>No datasets yet</h3>
<p className={styles.emptyBody}>
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.
</p>
<button
type="button"
className={`${styles.action} ${styles.primary}`}
onClick={() => {
openModal('datasets');
useDatasetStore.getState().startCreate();
}}
>
Add a dataset
</button>
</div>
);
}
export function ChartBuilderModal() {
const datasetId = useChartBuilderStore((s) => s.datasetId);
const datasetName = useChartBuilderStore((s) => s.config.datasetName);
const datasetCount = useDatasetStore((s) => s.datasets.length);
const mark = useChartBuilderStore((s) => s.config.mark);
const sort = useChartBuilderStore((s) => s.config.sort);
const stack = useChartBuilderStore((s) => s.config.stack);
@@ -1317,7 +1370,16 @@ export function ChartBuilderModal() {
}, [warnings]);
if (datasetId === null) {
return <p className={styles.muted}>No dataset loaded. Open this from a dataset in Datasets.</p>;
if (datasetCount === 0) return <NoDatasets />;
// 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 (
<div className={styles.emptyState}>
<h3 className={styles.emptyTitle}>Choose a dataset</h3>
<p className={styles.emptyBody}>Pick the dataset to build a chart from.</p>
<DatasetPicker datasetId={null} />
</div>
);
}
return (
@@ -1326,9 +1388,7 @@ export function ChartBuilderModal() {
<div className="visually-hidden" role="status" aria-live="polite">
{fixAnnouncement}
</div>
<p className={styles.datasetName}>
Building from <strong>{datasetName}</strong>
</p>
<DatasetPicker datasetId={datasetId} />
<DataSection />
+11
View File
@@ -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<IconName, ReactNode> = {
<polygon points="17.4141 16 24 9.4141 22.5859 8 16 14.5859 9.4143 8 8 9.4141 14.5859 16 8 22.5859 9.4143 24 16 17.4141 22.5859 24 24 22.5859 17.4141 16" />
),
add: <polygon points="17,15 17,8 15,8 15,15 8,15 8,17 15,17 15,24 17,24 17,17 24,17 24,15" />,
// 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: (
<>
<rect x="4" y="24" width="24" height="2" />
<rect x="8" y="16" width="4" height="8" />
<rect x="14" y="10" width="4" height="14" />
<rect x="20" y="6" width="4" height="18" />
</>
),
// Carbon Search — magnifying glass, the active-search affordance (council SEARCH).
search: (
<path d="M29,27.5859l-7.5521-7.5521a11.0177,11.0177,0,1,0-1.4141,1.4141L27.5859,29ZM4,13a9,9,0,1,1,9,9A9.01,9.01,0,0,1,4,13Z" />
+31
View File
@@ -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;
+15
View File
@@ -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.
</p>
<div className={styles.ctaRow}>
<button type="button" className={styles.primary} onClick={handleCreate}>
<Icon name="add" /> Create your first snippet
</button>
<button type="button" className={styles.buildCta} onClick={handleBuild}>
<Icon name="chart" /> Build a chart from your data
</button>
</div>
<div className={styles.galleryHead}>
<h3 className={styles.galleryTitle}>Or start from an example</h3>
+47 -7
View File
@@ -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;
+22 -9
View File
@@ -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() {
<LibrarySettings />
</div>
{/* 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. */}
<div className={styles.createRow}>
<button
className={styles.createNew}
onClick={() => openModal('chartBuilder')}
aria-label="Build Chart"
title="Build Chart"
>
<Icon name="chart" />
<span className={styles.createNewLabel}>Build Chart</span>
</button>
<button
className={styles.createGhost}
onClick={() => createSnippet()}
aria-label="Create New Snippet"
title="Create New Snippet"
aria-label="New JSON snippet"
title="New JSON snippet"
>
<Icon name="add" />
<span className={styles.createNewLabel}>Create New Snippet</span>
<span className={styles.createNewLabel}>New JSON snippet</span>
</button>
</div>
{/* Search + Sort controls, pinned above the list (spec §02; council
SEARCH + SORT). Search is an unlabelled Carbon active-search: a search
+3
View File
@@ -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 "#"', () => {
+7 -1
View File
@@ -18,6 +18,7 @@
* dataset #datasets/dataset-<id> (id is a decimal number)
* dataset-new #datasets/new
* dataset-build #datasets/dataset-<id>/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-<id>
| { kind: 'dataset-new' } // #datasets/new
| { kind: 'dataset-build'; datasetId: number }; // #datasets/dataset-<id>/build
| { kind: 'dataset-build'; datasetId: number } // #datasets/dataset-<id>/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;
+6
View File
@@ -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);
+8 -1
View File
@@ -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;
+4 -2
View File
@@ -71,8 +71,10 @@ const MODAL_REGISTRY: Partial<Record<ModalName, ModalConfig>> = {
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).
+77
View File
@@ -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);
});
});
+67 -7
View File
@@ -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<ChartBuilderState>((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<ChartBuilderState>((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<ChartBuilderState>((set, get) => ({
columns: EMPTY_COLUMNS,
rowCount: null,
config: EMPTY_CONFIG,
initialConfig: EMPTY_CONFIG,
activeChannel: null,
}),
}));
+89
View File
@@ -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' });
});
});
+24
View File
@@ -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<string, unknown>;