diff --git a/docs/spec/02-snippet-library.md b/docs/spec/02-snippet-library.md index fe24c51..4041c72 100644 --- a/docs/spec/02-snippet-library.md +++ b/docs/spec/02-snippet-library.md @@ -24,7 +24,9 @@ When the library is empty — on first run, or after the user deletes their last - 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. +- **Paste a spec you already have** — the bring-your-own door for users arriving with existing Vega-Lite JSON (a notebook, the Vega editor, an AI chat). A disclosure button (not a modal) reveals a labelled paste area in place; **Add to library** creates the snippet from the pasted text and opens it in the editor, **Cancel** collapses the panel and returns focus to the button. The panel stays mounted while collapsed, so a draft paste survives. Pasted text is accepted as-is — the editor's live validation is where an almost-right spec gets fixed — and the snippet's name derives from the spec (its `title`, else a "Mark chart of y by x" phrase), falling back to "Pasted spec". +- Below the doors, two quiet secondary links: **Import your workspace** (for users restoring a workspace export — same file-picker import as the header control, see _Import & Export_) and **Read the deep dives** (the `/learn/` section, opened in a new tab). +- 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, an interactive brushed scatter, a stacked area, a donut, a binned histogram). Each example shows a **live preview** of the chart and a one-line description. - **Add** on an example creates it as an ordinary snippet and makes it active (opening it in the editor). - **Add all** creates the whole set at once and makes one of them active. - Added examples are **ordinary snippets**: meaningfully named (not auto-generated timestamps), and thereafter editable, duplicable, and deletable like any other — they are the user's, not a special class (_own your data_). diff --git a/docs/ux-second-pass.md b/docs/ux-second-pass.md index fdc3cb4..8113b12 100644 --- a/docs/ux-second-pass.md +++ b/docs/ux-second-pass.md @@ -35,6 +35,13 @@ record the resolution into the contract (`docs/architecture/09`+`10` and the rel the gesture-specific framing. In `components/CompositionWireframe.tsx` (`pullLabel`, the `'row'`/`'column'` ternaries in `resolveDrop`/`commitDrop`). +- **Post-first-snippet feature discoverability** — onboarding ends the instant one snippet + exists; draft/publish, extract-to-dataset, and theming are then discovered only by + accident (the CodeLens scaffolds are the exception — discoverable inline). Tours are + against the app's grain; decide what light-touch surface (if any) carries discovery: a + richer About/shortcuts panel, first-visit hints, or nothing. Context in + `docs/exploration/landing-onboarding-scope.md` (§ Parked). + ## Deferred (not design debts, revisit on demand) - **Drag-and-drop field assignment** — chips are click/keyboard-first by design; drag would diff --git a/src/app/components/AboutModal.tsx b/src/app/components/AboutModal.tsx index 6e57c74..1d8811e 100644 --- a/src/app/components/AboutModal.tsx +++ b/src/app/components/AboutModal.tsx @@ -50,6 +50,13 @@ export function AboutModal() { A local-first workspace for authoring and organizing Vega-Lite charts. Edit the JSON, watch it render live, and keep a personal library of snippets.

+

+ New to Vega-Lite, or want to go deeper? Read the{' '} + + deep dives + + . +

{/* Keyboard shortcuts */} diff --git a/src/app/components/Onboarding.module.css b/src/app/components/Onboarding.module.css index ab414ca..bbcf493 100644 --- a/src/app/components/Onboarding.module.css +++ b/src/app/components/Onboarding.module.css @@ -37,6 +37,79 @@ /* The two CTAs and the per-example Adds are shared Buttons (arch 09 §4). */ +/* Paste-a-spec disclosure panel: revealed below the doors, form-shaped + (label above field, hint between — GOV.UK textarea). */ +.pastePanel { + margin-top: var(--space-4); + padding: var(--space-4); + border: var(--border-width) solid var(--border); + border-radius: var(--radius); + background: var(--layer-01); + /* Elevated surface: the textarea's field fill steps off --layer-01 (arch 09 §4). */ + --field: var(--field-02); + --field-hover: var(--field-hover-02); +} + +.pasteLabel { + display: block; + margin-bottom: var(--space-1); + font-size: 14px; + font-weight: 600; +} + +.pasteHint { + margin: 0 0 var(--space-3); + font-size: 13px; + color: var(--text-secondary); + line-height: 1.4; +} + +/* The field look (fill, underline, focus ring) comes from the base.css element + baseline; the module adds only sizing idiosyncrasies (arch 09 §4). */ +.pasteInput { + display: block; + width: 100%; + resize: vertical; + padding: var(--space-2) var(--space-3); + font-family: var(--font-mono); + font-size: 12px; + line-height: 1.5; +} + +.pasteActions { + display: flex; + gap: var(--space-3); + margin-top: var(--space-3); +} + +/* Secondary paths (import / learn) as one quiet line of links below the doors. */ +.altPaths { + margin: var(--space-4) 0 0; + font-size: 13px; + color: var(--text-secondary); +} + +/* Inline link styling shared by the import action (a button semantically — + it triggers the file picker) and the learn anchor. Focus ring comes from the + base.css baseline. + TODO: this accent-link recipe now exists in four modules (AboutModal .link, + DonateModal .email, DatasetsModal .linkButton, here) — past the "third site" + threshold AboutModal.module.css records for promoting it to a shared + primitive; consolidate into one home (arch 09 §4's four mechanisms). */ +.linkButton { + padding: 0; + border: none; + background: none; + font: inherit; + color: var(--accent); + text-decoration: underline; + cursor: pointer; +} + +.hiddenInput { + display: none; +} + /* 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.test.tsx b/src/app/components/Onboarding.test.tsx index 00d51c3..6964a8c 100644 --- a/src/app/components/Onboarding.test.tsx +++ b/src/app/components/Onboarding.test.tsx @@ -75,6 +75,41 @@ describe('Onboarding', () => { expect(activeSnippetId).toBe(snippets[0].id); }); + test('the paste door creates a snippet from pasted text with a derived name', () => { + click('Paste a spec you already have'); + const textarea = container.querySelector('#onboarding-paste-input')!; + const pasted = JSON.stringify({ title: 'My chart', mark: 'bar' }); + act(() => { + // React reads the value through the native setter; assign then dispatch. + Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')!.set!.call( + textarea, + pasted, + ); + textarea.dispatchEvent(new Event('input', { bubbles: true })); + }); + click('Add to library'); + const { snippets, activeSnippetId } = useSnippetStore.getState(); + expect(snippets).toHaveLength(1); + expect(snippets[0].spec).toBe(pasted); + expect(snippets[0].name).toBe('My chart'); // deriveSnippetName: title wins + expect(activeSnippetId).toBe(snippets[0].id); + }); + + test('the paste door is a disclosure: hidden until toggled, empty paste disabled', () => { + const panel = container.querySelector('#onboarding-paste-panel')!; + expect(panel.hidden).toBe(true); + click('Paste a spec you already have'); + expect(panel.hidden).toBe(false); + // Add is disabled while the paste area is empty — nothing is created. + const add = Array.from(container.querySelectorAll('button')).find((b) => + b.textContent?.includes('Add to library'), + )!; + expect(add.disabled).toBe(true); + click('Cancel'); + expect(panel.hidden).toBe(true); + expect(useSnippetStore.getState().snippets).toHaveLength(0); + }); + test('"Add all" adds every example and makes the bar chart active', () => { click('Add all'); const { snippets, activeSnippetId } = useSnippetStore.getState(); diff --git a/src/app/components/Onboarding.tsx b/src/app/components/Onboarding.tsx index 8fe4cfa..ee2fbca 100644 --- a/src/app/components/Onboarding.tsx +++ b/src/app/components/Onboarding.tsx @@ -14,13 +14,14 @@ * independent of `LivePreview`'s single-host render serialization. */ -import { useEffect, useRef } from 'react'; +import { useEffect, useRef, useState } from 'react'; import type { VisualizationSpec } from 'vega-embed'; import { CHART_EXAMPLES, exampleSpecText, type ChartExample } from '@core/examples'; -import { createSnippet as createSnippetRecord } from '@core/snippet'; +import { createSnippet as createSnippetRecord, deriveSnippetName } from '@core/snippet'; import { chartConfigFor } from '@core/vega-themes'; import { openModal } from '../modals/ModalCoordinator'; import { renderSpec, type RenderHandle } from '../services/chart-renderer'; +import { importWorkspace } from '../services/transfer'; import { useAppStore } from '../stores/AppStore'; import { usePanesStore } from '../stores/PanesStore'; import { useSnippetStore } from '../stores/SnippetStore'; @@ -91,6 +92,23 @@ export function Onboarding() { const addSnippets = useSnippetStore((s) => s.addSnippets); const applyOnboardingSplit = usePanesStore((s) => s.applyOnboardingSplit); + // The paste door (spec §02): a disclosure (WAI-ARIA APG disclosure pattern — + // button + aria-expanded/aria-controls, no focus trap), not a modal: the canvas + // has the whole workspace to itself, so revealing the paste surface in place + // costs no context. The panel stays mounted (`hidden`) so a draft paste + // survives a collapse. + const [pasteOpen, setPasteOpen] = useState(false); + const [pasteText, setPasteText] = useState(''); + const pasteTriggerRef = useRef(null); + const pasteAreaRef = useRef(null); + const importInputRef = useRef(null); + + // The revealed panel exists only for immediate input, so focus follows the + // expand; Cancel returns it to the trigger (NN/g #3 — a clearly marked exit). + useEffect(() => { + if (pasteOpen) pasteAreaRef.current?.focus(); + }, [pasteOpen]); + // Leaving onboarding lays the workspace out at the default 25·25·50 split, so // the first chart opens with a generous preview (spec §02). The canvas fills // the window here, so its width is a good proxy for the panes container. @@ -116,6 +134,36 @@ export function Onboarding() { openModal('chartBuilder'); }; + // Pasted text is accepted as-is: the editor is the product's validator, and an + // almost-right spec opening with live schema errors is the feature working. + // The name derives from the spec (title → "Mark chart of y by x"), with + // `nameSource: 'auto'` so it keeps tracking the spec until the user renames. + const handlePasteAdd = () => { + const text = pasteText.trim(); + if (!text) return; + createSnippet({ + name: deriveSnippetName(text) ?? 'Pasted spec', + nameSource: 'auto', + spec: text, + }); + layoutWorkspace(); + }; + const closePaste = () => { + setPasteOpen(false); + pasteTriggerRef.current?.focus(); + }; + + // Same hidden-picker pattern as the header's Import (spec §08 — the browser + // file dialog is the only chrome); reset so re-picking the same file re-fires. + // TODO: third hidden-file-picker site (App.tsx header, TypeControls.tsx) — past + // the shared-piece threshold; eng-council proposed a useFilePicker/HiddenFileInput + // shape. Consult before building (new hook/component kind). + const handleImportFile = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + e.target.value = ''; + if (file) void importWorkspace(file); + }; + 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 @@ -148,8 +196,74 @@ export function Onboarding() { + +