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 ( +
+ Dataset + ({ 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} + /> +
+ ); +} + +/** + * 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. +

+ +
+ ); +} + 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

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 ( +
+

Choose a dataset

+

Pick the dataset to build a chart from.

+ +
+ ); } return ( @@ -1326,9 +1388,7 @@ export function ChartBuilderModal() {
{fixAnnouncement}
-

- Building from {datasetName} -

+ 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;