From 65090da72c9ef8511f483ac1c0a05524b99d187f Mon Sep 17 00:00:00 2001
From: Oleh Omelchenko
Date: Sat, 4 Jul 2026 16:42:00 +0300
Subject: [PATCH] Onboarding: paste-a-spec door, workspace-import and learn
links
---
docs/spec/02-snippet-library.md | 4 +-
docs/ux-second-pass.md | 7 ++
src/app/components/AboutModal.tsx | 7 ++
src/app/components/Onboarding.module.css | 73 ++++++++++++++
src/app/components/Onboarding.test.tsx | 35 +++++++
src/app/components/Onboarding.tsx | 118 ++++++++++++++++++++++-
6 files changed, 241 insertions(+), 3 deletions(-)
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() {
+
+
+ {/* Visible label above the field (GOV.UK textarea — placeholder text is
+ not a substitute for a label). */}
+
+
+ Vega-Lite JSON from anywhere — a notebook, the Vega editor, an AI chat. It becomes your
+ first snippet and opens in the editor, live errors and all.
+
+
+
+ {/* Secondary paths as quiet links below the doors (Carbon empty-states —
+ secondary calls to action are links, not more buttons). */}
+