mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Onboarding: paste-a-spec door, workspace-import and learn links
This commit is contained in:
@@ -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.
|
- 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_).
|
- **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.
|
- **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** 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.
|
- **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_).
|
- 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_).
|
||||||
|
|||||||
@@ -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
|
the gesture-specific framing. In `components/CompositionWireframe.tsx` (`pullLabel`, the
|
||||||
`'row'`/`'column'` ternaries in `resolveDrop`/`commitDrop`).
|
`'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)
|
## Deferred (not design debts, revisit on demand)
|
||||||
|
|
||||||
- **Drag-and-drop field assignment** — chips are click/keyboard-first by design; drag would
|
- **Drag-and-drop field assignment** — chips are click/keyboard-first by design; drag would
|
||||||
|
|||||||
@@ -50,6 +50,13 @@ export function AboutModal() {
|
|||||||
A local-first workspace for authoring and organizing Vega-Lite charts. Edit the JSON,
|
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.
|
watch it render live, and keep a personal library of snippets.
|
||||||
</p>
|
</p>
|
||||||
|
<p className={styles.body}>
|
||||||
|
New to Vega-Lite, or want to go deeper? Read the{' '}
|
||||||
|
<a className={styles.link} href="/learn/" target="_blank" rel="noopener noreferrer">
|
||||||
|
deep dives
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Keyboard shortcuts */}
|
{/* Keyboard shortcuts */}
|
||||||
|
|||||||
@@ -37,6 +37,79 @@
|
|||||||
|
|
||||||
/* The two CTAs and the per-example Adds are shared Buttons (arch 09 §4). */
|
/* 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. */
|
/* The "or start from an example" header row, with Add all pushed to the end. */
|
||||||
.galleryHead {
|
.galleryHead {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -75,6 +75,41 @@ describe('Onboarding', () => {
|
|||||||
expect(activeSnippetId).toBe(snippets[0].id);
|
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<HTMLTextAreaElement>('#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<HTMLElement>('#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', () => {
|
test('"Add all" adds every example and makes the bar chart active', () => {
|
||||||
click('Add all');
|
click('Add all');
|
||||||
const { snippets, activeSnippetId } = useSnippetStore.getState();
|
const { snippets, activeSnippetId } = useSnippetStore.getState();
|
||||||
|
|||||||
@@ -14,13 +14,14 @@
|
|||||||
* independent of `LivePreview`'s single-host render serialization.
|
* 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 type { VisualizationSpec } from 'vega-embed';
|
||||||
import { CHART_EXAMPLES, exampleSpecText, type ChartExample } from '@core/examples';
|
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 { chartConfigFor } from '@core/vega-themes';
|
||||||
import { openModal } from '../modals/ModalCoordinator';
|
import { openModal } from '../modals/ModalCoordinator';
|
||||||
import { renderSpec, type RenderHandle } from '../services/chart-renderer';
|
import { renderSpec, type RenderHandle } from '../services/chart-renderer';
|
||||||
|
import { importWorkspace } from '../services/transfer';
|
||||||
import { useAppStore } from '../stores/AppStore';
|
import { useAppStore } from '../stores/AppStore';
|
||||||
import { usePanesStore } from '../stores/PanesStore';
|
import { usePanesStore } from '../stores/PanesStore';
|
||||||
import { useSnippetStore } from '../stores/SnippetStore';
|
import { useSnippetStore } from '../stores/SnippetStore';
|
||||||
@@ -91,6 +92,23 @@ export function Onboarding() {
|
|||||||
const addSnippets = useSnippetStore((s) => s.addSnippets);
|
const addSnippets = useSnippetStore((s) => s.addSnippets);
|
||||||
const applyOnboardingSplit = usePanesStore((s) => s.applyOnboardingSplit);
|
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<HTMLButtonElement>(null);
|
||||||
|
const pasteAreaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
const importInputRef = useRef<HTMLInputElement>(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
|
// 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 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.
|
// the window here, so its width is a good proxy for the panes container.
|
||||||
@@ -116,6 +134,36 @@ export function Onboarding() {
|
|||||||
openModal('chartBuilder');
|
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<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
e.target.value = '';
|
||||||
|
if (file) void importWorkspace(file);
|
||||||
|
};
|
||||||
|
|
||||||
const handleAddAll = () => {
|
const handleAddAll = () => {
|
||||||
// Stagger the timestamps so the first example (the bar chart) is the newest:
|
// 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
|
// it then sorts to the top of the library and `addSnippets` makes it active
|
||||||
@@ -148,8 +196,74 @@ export function Onboarding() {
|
|||||||
<Button size="lg" onClick={handleBuild}>
|
<Button size="lg" onClick={handleBuild}>
|
||||||
<Icon name="chart" /> Build a chart from your data
|
<Icon name="chart" /> Build a chart from your data
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="lg"
|
||||||
|
ref={pasteTriggerRef}
|
||||||
|
aria-expanded={pasteOpen}
|
||||||
|
aria-controls="onboarding-paste-panel"
|
||||||
|
onClick={() => setPasteOpen((open) => !open)}
|
||||||
|
>
|
||||||
|
<Icon name="import" /> Paste a spec you already have
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="onboarding-paste-panel" className={styles.pastePanel} hidden={!pasteOpen}>
|
||||||
|
{/* Visible label above the field (GOV.UK textarea — placeholder text is
|
||||||
|
not a substitute for a label). */}
|
||||||
|
<label className={styles.pasteLabel} htmlFor="onboarding-paste-input">
|
||||||
|
Paste a Vega-Lite spec
|
||||||
|
</label>
|
||||||
|
<p className={styles.pasteHint} id="onboarding-paste-hint">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
<textarea
|
||||||
|
ref={pasteAreaRef}
|
||||||
|
id="onboarding-paste-input"
|
||||||
|
className={styles.pasteInput}
|
||||||
|
aria-describedby="onboarding-paste-hint"
|
||||||
|
rows={8}
|
||||||
|
spellCheck={false}
|
||||||
|
value={pasteText}
|
||||||
|
onChange={(e) => setPasteText(e.target.value)}
|
||||||
|
/>
|
||||||
|
<div className={styles.pasteActions}>
|
||||||
|
<Button onClick={handlePasteAdd} disabled={pasteText.trim() === ''}>
|
||||||
|
Add to library
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" onClick={closePaste}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Secondary paths as quiet links below the doors (Carbon empty-states —
|
||||||
|
secondary calls to action are links, not more buttons). */}
|
||||||
|
<p className={styles.altPaths}>
|
||||||
|
Restoring from an export?{' '}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.linkButton}
|
||||||
|
onClick={() => importInputRef.current?.click()}
|
||||||
|
>
|
||||||
|
Import your workspace
|
||||||
|
</button>
|
||||||
|
<span aria-hidden="true"> · </span>
|
||||||
|
New to Vega-Lite?{' '}
|
||||||
|
<a className={styles.linkButton} href="/learn/" target="_blank" rel="noopener noreferrer">
|
||||||
|
Read the deep dives
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
ref={importInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="application/json,.json"
|
||||||
|
className={styles.hiddenInput}
|
||||||
|
onChange={handleImportFile}
|
||||||
|
aria-hidden="true"
|
||||||
|
tabIndex={-1}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className={styles.galleryHead}>
|
<div className={styles.galleryHead}>
|
||||||
<h3 className={styles.galleryTitle}>Or start from an example</h3>
|
<h3 className={styles.galleryTitle}>Or start from an example</h3>
|
||||||
<Button className={styles.addAll} onClick={handleAddAll}>
|
<Button className={styles.addAll} onClick={handleAddAll}>
|
||||||
|
|||||||
Reference in New Issue
Block a user