Landing: dual-pitch overhaul — showcase, editor proof, objections, trust creed

This commit is contained in:
2026-07-04 16:42:40 +03:00
parent 65090da72c
commit f5b2620458
6 changed files with 893 additions and 87 deletions
@@ -576,6 +576,14 @@ It does two deterministic things, on a **deep copy** of the spec:
and **removes** `width`; Full sets both — see spec §04 → Fit-mode sizing),
recursing the same way.
The fit recursion has one Vega-Lite limit: **`"container"` sizing only works on
single and layered views** — facet children fall back with a warning (panel
widths become timing-dependent) and concat children fall back to pad-autosize
(axes overflow a fixed card). A surface that renders a composed spec at a fixed
size must ask for `fitMode: 'default'` and let the spec's declared per-view
`width`/`height` stand — the landing's `LandingChart` exposes this as a prop
for its composed demos.
This is _content_ preparation, not embedding, and it is fully covered by the
_Live Preview_ spec. The only invariant this doc cares about:
+275 -4
View File
@@ -220,6 +220,20 @@
align-items: center;
gap: var(--space-3);
}
/* "Open in Astrolabe →" beside the Preview column head — the hand-off from
playing with the demo to holding the same chart in the app. */
.colHeadLink {
margin-left: auto;
color: var(--accent);
text-decoration: none;
text-transform: none;
letter-spacing: normal;
font-size: 12px;
}
.colHeadLink:hover {
text-decoration: underline;
}
.libList {
flex: 1 1 auto;
min-height: 0;
@@ -461,9 +475,11 @@
}
/* theme demo */
/* Compact grid — the theme *switch* is the demo; three charts in one glance,
not a page of scroll. */
.gallery {
display: flex;
flex-direction: column;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--space-4);
margin-top: var(--space-5);
}
@@ -598,6 +614,248 @@
margin-left: auto;
}
/* ── editor proof: staged still of the editor mid-thought (no Monaco) ─────── */
.edBody {
position: relative;
padding: var(--space-4) var(--space-5);
font-family: var(--font-mono);
font-size: 12px;
overflow-x: auto;
}
.edLines {
margin: 0;
line-height: 1.7;
white-space: pre;
color: var(--text);
}
.edLens {
display: block;
font-size: 11px;
color: var(--accent);
margin-bottom: 2px;
}
.edCursor {
display: inline-block;
width: 2px;
height: 13px;
vertical-align: text-bottom;
background: var(--accent);
}
.edPopup {
display: flex;
margin: var(--space-2) 0 var(--space-3) var(--space-8);
max-width: 420px;
border: 1px solid var(--border);
background: var(--layer-01);
box-shadow: 0 8px 24px -12px rgba(0, 0, 0, 0.4);
font-size: 12px;
}
.edOptions {
list-style: none;
margin: 0;
padding: var(--space-1) 0;
min-width: 110px;
border-right: 1px solid var(--border);
}
.edOptions li {
padding: 2px var(--space-3);
}
.edOptionSel {
background: var(--accent-soft);
color: var(--accent);
}
.edDoc {
padding: var(--space-2) var(--space-3);
color: var(--text-secondary);
line-height: 1.5;
}
.edSquiggle {
text-decoration: underline wavy var(--support-error);
text-underline-offset: 3px;
}
/* Marker hover, anchored under the squiggled token like Monaco's. */
.edHover {
margin: var(--space-2) 0 0 var(--space-8);
max-width: 420px;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--border);
border-left: 3px solid var(--support-error);
background: var(--layer-01);
box-shadow: 0 8px 24px -12px rgba(0, 0, 0, 0.4);
font-size: 12px;
color: var(--text-secondary);
line-height: 1.5;
}
/* ── objections: the habit cluster, answered as on-ramps ──────────────────── */
.objections {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--space-7);
padding: 0 0 var(--space-9);
}
.objections h3 {
font-size: 15px;
font-weight: 600;
margin: 0 0 var(--space-2);
}
.objections p {
margin: 0;
font-size: 14px;
line-height: 1.55;
color: var(--text-secondary);
}
/* Inline accent link inside body copy (builder CTA, close note). */
.inlineLink {
color: var(--accent);
text-decoration: none;
}
.inlineLink:hover {
text-decoration: underline;
}
/* Store-once-reference-many diagram: a dataset with its snippets as a tree. */
.refDiagram {
padding: var(--space-4);
border-bottom: 1px solid var(--border);
}
.refDataset {
display: flex;
align-items: center;
gap: var(--space-2);
font-size: 13px;
font-weight: 600;
}
.refDot {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--accent);
flex: 0 0 auto;
}
.refMeta {
font-weight: 400;
color: var(--text-secondary);
}
.refTree {
list-style: none;
margin: var(--space-1) 0 0 4px;
padding: 0;
}
.refTree li {
position: relative;
padding: 3px 0 3px 22px;
font-size: 13px;
}
/* Tree connectors: a vertical rail plus an elbow per row. */
.refTree li::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
border-left: 1px solid var(--border-strong);
}
.refTree li:last-child::before {
bottom: auto;
height: 14px;
}
.refTree li::after {
content: '';
position: absolute;
left: 0;
top: 14px;
width: 14px;
border-top: 1px solid var(--border-strong);
}
.refMore {
color: var(--text-secondary);
font-style: italic;
}
/* Stacked capability section: text intro above, demo at full column width. */
.capStack {
padding: var(--space-9) 0;
}
.capStack .shot {
margin-top: var(--space-6);
}
.themeNote {
margin: var(--space-3) 0 0;
font-size: 13px;
color: var(--text-secondary);
}
.closeNote {
margin-top: var(--space-4);
font-size: 14px;
color: var(--text-secondary);
}
/* ── showcase: live spec ↔ chart demos ("What Vega-Lite can do") ─────────── */
.showcase {
padding: var(--space-9) 0 var(--space-5);
}
.showcaseIntro {
max-width: 62ch;
margin-bottom: var(--space-8);
}
.demo {
margin-bottom: var(--space-9);
}
.demoTitle {
font-size: 18px;
font-weight: 600;
margin: 0 0 var(--space-2);
}
.demoBlurb {
color: var(--text-secondary);
font-size: 14px;
line-height: 1.55;
max-width: 66ch;
margin: 0 0 var(--space-5);
}
.demoRow {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1.15fr);
gap: var(--space-6);
align-items: stretch;
}
/* The spec pane: same chrome as the hero window's code column; the inner .code
pre scrolls when the spec outgrows the cap. */
.demoCode {
display: flex;
min-width: 0;
border: 1px solid var(--border);
background: var(--layer-01);
max-height: 400px;
overflow: hidden;
}
.demoCode .code {
min-width: 0;
}
.demoChartCard {
min-width: 0;
border: 1px solid var(--border);
background: var(--bg);
box-shadow: 0 16px 40px -20px rgba(0, 0, 0, 0.3);
padding: var(--space-5);
display: flex;
flex-direction: column;
justify-content: center;
/* Fixed-size composed charts (the facet demo) scroll inside the card rather
than widening the page on small screens. */
overflow-x: auto;
}
.demoHint {
margin: var(--space-4) 0 0;
font-family: var(--font-mono);
font-size: 12px;
color: var(--accent);
}
@media (max-width: 760px) {
.appBody {
grid-template-columns: 1fr;
@@ -612,11 +870,24 @@
.code {
max-height: 320px;
}
/* minmax(0, …): a plain 1fr track lets pre-formatted code set the column's
min-content width and push the page wider than the viewport. */
.cap,
.creedGrid {
grid-template-columns: 1fr;
.creedGrid,
.demoRow,
.objections,
.gallery {
grid-template-columns: minmax(0, 1fr);
gap: var(--space-6);
}
.demoCode {
max-height: 300px;
}
/* Section anchors don't fit a phone-width nav row; the page is one scroll
anyway — keep brand, theme toggle, and the CTA. */
.navLink {
display: none;
}
.capRev .capText {
order: 0;
}
+353 -79
View File
@@ -27,10 +27,18 @@ import {
} from '@core/sample-dataset';
import { DEMO_CUSTOM_THEMES } from './demo-themes';
import { LandingChart } from './LandingChart';
import {
SHOWCASE_DEMOS,
showcaseDisplaySpec,
showcaseFitMode,
type ShowcaseDemo,
} from './showcase-specs';
import styles from './Landing.module.css';
// TODO: import { UiTheme } from '@core/theme' instead of redeclaring it — the
// learn entry already uses the canonical one.
// TODO: this file is the repo's second-largest; on the next added section, split
// into per-section files under src/landing/ (eng-council 2026-07).
type UiTheme = 'light' | 'dark';
// Minimal JSON syntax highlighter for the read-only spec view: keys, strings,
@@ -85,12 +93,41 @@ function Brand(): ReactNode {
);
}
/** The framed app-window chrome (traffic-dot title bar) every section shot sits in. */
function Shot({ bar, children }: { bar: ReactNode; children: ReactNode }): ReactNode {
return (
<div className={styles.shot}>
<div className={styles.shotBar}>
<span className={styles.dots}>
<i />
<i />
<i />
</span>{' '}
{bar}
</div>
{children}
</div>
);
}
// ── Hero: switch snippets, the editor + chart follow ────────────────────────────
function HeroAppWindow({ theme }: { theme: UiTheme }): ReactNode {
const [selected, setSelected] = useState(0);
// Open on the brushed scatter: the first thing a visitor sees is a chart that
// responds to them — the "this is live" argument made without words.
const [selected, setSelected] = useState(() =>
Math.max(
0,
CHART_EXAMPLES.findIndex((e) => e.id === 'brush'),
),
);
const example = CHART_EXAMPLES[selected];
const config = useMemo(() => chartConfigForSelection('astrolabe', theme), [theme]);
// A demo custom theme (not stock blue): the window is also the first proof
// that charts here look designed out of the box.
const config = useMemo(
() => chartConfigForSelection(customThemeSelection(1), theme, DEMO_CUSTOM_THEMES),
[theme],
);
return (
<div className={styles.appWin}>
@@ -130,7 +167,12 @@ function HeroAppWindow({ theme }: { theme: UiTheme }): ReactNode {
<JsonCode spec={example.spec} />
</div>
<div className={styles.col}>
<div className={styles.colHead}>Preview</div>
<div className={styles.colHead}>
Preview
<a className={styles.colHeadLink} href={`/app/#example-${example.id}`}>
Open in Astrolabe
</a>
</div>
<div className={styles.preview}>
<LandingChart spec={example.spec} config={config} />
</div>
@@ -140,6 +182,136 @@ function HeroAppWindow({ theme }: { theme: UiTheme }): ReactNode {
);
}
// ── Showcase: live spec ↔ chart pairs — "a page of JSON is an interactive chart" ─
function ShowcaseDemoRow({ demo, theme }: { demo: ShowcaseDemo; theme: UiTheme }): ReactNode {
const config = useMemo(
() => chartConfigForSelection(customThemeSelection(demo.themeId), theme, DEMO_CUSTOM_THEMES),
[demo.themeId, theme],
);
return (
<div className={styles.demo}>
<h3 className={styles.demoTitle}>{demo.title}</h3>
<p className={styles.demoBlurb}>{demo.blurb}</p>
<div className={styles.demoRow}>
{/* The displayed spec references data by name the app's idiom so the
text stays the length of the idea, not of the rows (showcase-specs.ts). */}
<div className={styles.demoCode}>
<JsonCode spec={showcaseDisplaySpec(demo)} />
</div>
<div className={styles.demoChartCard}>
<LandingChart spec={demo.spec} config={config} fitMode={showcaseFitMode(demo)} />
<p className={styles.demoHint}>{demo.hint}</p>
</div>
</div>
</div>
);
}
function ShowcaseSection({ theme }: { theme: UiTheme }): ReactNode {
return (
<section className={styles.showcase} id="capabilities">
<div className={styles.showcaseIntro}>
<div className={styles.capEyebrow}>Charts as text</div>
<h2 className={styles.capH}>
A page of JSON is <b>an interactive chart.</b>
</h2>
<p className={styles.capP}>
Vega-Lite is a grammar: declare what the data is and how it maps to marks, and the
rendering axes, legends, interaction follows. The three charts below are live, and the
text beside each one is its whole program the data referenced by name, the way a snippet
in Astrolabe reads.
</p>
</div>
{SHOWCASE_DEMOS.map((demo) => (
<ShowcaseDemoRow key={demo.id} demo={demo} theme={theme} />
))}
</section>
);
}
// ── Editor proof: a staged (no-Monaco) still of the editor mid-thought ──────────
// The landing must not import Monaco (post-build gate keeps `/` light), so the
// editor's intelligence is shown as a hand-built still: schema autocomplete with
// inline docs, a CodeLens scaffold row, and a validation catch. Purely
// presentational; the real behaviors live in the app (arch 08).
function EditorProof(): ReactNode {
return (
<Shot bar="revenue-by-quarter.vl.json · draft">
<div className={styles.edBody} aria-hidden="true">
<pre className={styles.edLines}>
{/* Real CodeLens labels (spec-param-scaffold / spec-transform-scaffold),
separated the way Monaco separates lenses. */}
<span className={styles.edLens}>
slider | dropdown | point | interval | Add transform
</span>
{'{\n'}
{' '}
<span className={styles.tokKey}>"data"</span>
{': { '}
<span className={styles.tokKey}>"name"</span>
{': '}
<span className={styles.tokStr}>"sales-2025"</span>
{' },\n'}
{' '}
<span className={styles.tokKey}>"mark"</span>
{': { '}
<span className={styles.tokKey}>"type"</span>
{': '}
<span className={styles.tokStr}>"area"</span>
{', '}
<span className={styles.tokKey}>"inter</span>
<span className={styles.edCursor} />
</pre>
<div className={styles.edPopup}>
<ul className={styles.edOptions}>
<li className={styles.edOptionSel}>interpolate</li>
<li>invalid</li>
<li>innerRadius</li>
</ul>
<div className={styles.edDoc}>
<b>interpolate</b> · string
<br />
The line interpolation method for line and area marks: "linear", "monotone",
"step-after"
</div>
</div>
<pre className={styles.edLines}>
{' '}
<span className={styles.tokKey}>"encoding"</span>
{': {\n'}
{' '}
<span className={styles.tokKey}>"x"</span>
{': { '}
<span className={styles.tokKey}>"field"</span>
{': '}
<span className={styles.tokStr}>"quarter"</span>
{' },\n'}
{' '}
<span className={styles.tokKey}>"color"</span>
{': { '}
<span className={styles.tokKey}>"field"</span>
{': '}
<span className={styles.tokStr}>"region"</span>
{', '}
<span className={styles.tokKey}>"type"</span>
{': '}
<span className={`${styles.tokStr} ${styles.edSquiggle}`}>"nomnal"</span>
{' }\n'}
{' }\n'}
{'}'}
</pre>
{/* Monaco's marker hover, with the JSON language service's actual message
shape the editor has no separate problems strip. */}
<div className={styles.edHover}>
Value is not accepted. Valid values: "nominal", "ordinal", "quantitative", "temporal".
</div>
</div>
</Shot>
);
}
// ── Chart Builder demo: pick fields, a mark, or an intent; the chart rebuilds ────
const MARK_OPTIONS: ReadonlyArray<{ type: MarkType; label: string }> = [
@@ -175,12 +347,34 @@ function columnType(name: string): (typeof BUILDER_TYPES)[number]['type'] {
function fieldMapping(name: string): ChannelMapping {
return { field: name, type: defaultFieldType(columnType(name)) };
}
/** A summed measure rows repeat per (month, region, channel), so an
unaggregated y reads as noise on every mark but the scatter. */
function summed(name: string): ChannelMapping {
return { ...fieldMapping(name), aggregate: 'sum' };
}
// Each mark tab shows an archetype that makes sense for sales-2024 — switching
// the mark alone would leave mappings that read as noise (a temporal bar stack,
// an unaggregated line). The demo must never show a meaningless chart; the
// selects below stay free for exploration.
const MARK_PRESET_ENCODINGS: Partial<Record<MarkType, Record<string, ChannelMapping>>> = {
bar: { x: fieldMapping('region'), y: summed('revenue'), color: fieldMapping('region') },
line: { x: fieldMapping('month'), y: summed('revenue'), color: fieldMapping('region') },
point: { x: fieldMapping('units'), y: fieldMapping('revenue'), color: fieldMapping('region') },
area: { x: fieldMapping('month'), y: summed('revenue'), color: fieldMapping('region') },
};
function markPreset(mark: MarkType): BuilderConfig {
const base = defaultBuilderConfig(SAMPLE_DATASET_NAME, SAMPLE_DATASET_COLUMNS);
const preset = MARK_PRESET_ENCODINGS[mark];
return { ...base, mark, encodings: preset ? { ...base.encodings, ...preset } : base.encodings };
}
function BuilderDemo({ theme }: { theme: UiTheme }): ReactNode {
const columns = SAMPLE_DATASET_COLUMNS;
const [config, setConfig] = useState<BuilderConfig>(() =>
defaultBuilderConfig(SAMPLE_DATASET_NAME, columns),
);
// First frame: the line archetype (revenue over time by region) — the demo's
// opening state is its argument.
const [config, setConfig] = useState<BuilderConfig>(() => markPreset('line'));
const active = activeIntent(config, columns);
const chartConfig = useMemo(() => chartConfigForSelection('astrolabe', theme), [theme]);
const spec = useMemo(() => {
@@ -205,7 +399,7 @@ function BuilderDemo({ theme }: { theme: UiTheme }): ReactNode {
type="button"
aria-pressed={config.mark === m.type}
className={config.mark === m.type ? styles.on : undefined}
onClick={() => setConfig({ ...config, mark: m.type })}
onClick={() => setConfig(markPreset(m.type))}
>
{m.label}
</button>
@@ -235,7 +429,11 @@ function BuilderDemo({ theme }: { theme: UiTheme }): ReactNode {
'y',
e.target.value === COUNT
? { type: 'quantitative', aggregate: 'count' }
: fieldMapping(e.target.value),
: // Rows repeat per (month, region, channel): keep measures summed
// everywhere except the scatter, which shows the raw rows.
config.mark === 'point'
? fieldMapping(e.target.value)
: summed(e.target.value),
)
}
>
@@ -292,7 +490,25 @@ const THEME_CHOICES: ReadonlyArray<{ selection: ChartThemeSelection; label: stri
{ selection: 'vox', label: 'Vox' },
{ selection: 'dark', label: 'Vega Dark' },
];
const GALLERY = THEME_PREVIEW_SPECS.filter((s) => ['bar', 'line', 'scatter'].includes(s.id));
// The core bar preview deliberately carries no colour encoding (it exercises
// title/axes/grid, so bars take the mark default) — on a marketing page that
// reads as "default blue". Give the landing's copy a colour channel so every
// gallery chart shows the selected theme's palette.
const GALLERY = THEME_PREVIEW_SPECS.filter((s) => ['bar', 'line', 'scatter'].includes(s.id)).map(
(s) =>
s.id === 'bar'
? {
...s,
spec: {
...s.spec,
encoding: {
...(s.spec.encoding as Record<string, unknown>),
color: { field: 'region', type: 'nominal', legend: null },
},
},
}
: s,
);
function ThemeDemo({ theme }: { theme: UiTheme }): ReactNode {
const [selection, setSelection] = useState<ChartThemeSelection>(customThemeSelection(1));
@@ -316,6 +532,10 @@ function ThemeDemo({ theme }: { theme: UiTheme }): ReactNode {
</button>
))}
</div>
<p className={styles.themeNote}>
Editorial, Blueprint, and Sunset are custom themes made in the Theme Builder; the rest ship
as presets.
</p>
<div className={styles.gallery}>
{GALLERY.map((ps) => (
<div key={ps.id} className={styles.mini}>
@@ -344,6 +564,9 @@ export function Landing(): ReactNode {
<div className={`${styles.wrap} ${styles.navIn}`}>
<Brand />
<span className={styles.navSpacer} />
<a className={styles.navLink} href="#capabilities">
What it can do
</a>
<a className={styles.navLink} href="#author">
Authoring
</a>
@@ -368,11 +591,12 @@ export function Landing(): ReactNode {
<header className={styles.hero}>
<div className={styles.wrap}>
<h1>
A home for your <b>Vega-Lite charts.</b>
Charts written as text. <b>A home to keep them.</b>
</h1>
<p>
Astrolabe is a local studio for Vega-Lite. Write a spec by hand or build one by
clicking, give it a theme, and keep all your charts in one searchable library.
Astrolabe is a local studio for Vega-Lite the grammar that turns a short JSON spec
into an interactive chart. Write specs by hand or build them by clicking, give them a
theme, and keep every chart in one searchable library.
</p>
<div className={styles.heroCta}>
<a className={`${styles.btn} ${styles.btnPrimary}`} href="/app/">
@@ -387,44 +611,89 @@ export function Landing(): ReactNode {
<div className={styles.wrap}>
<HeroAppWindow theme={theme} />
<p className={styles.stageHint}>
Pick a snippet on the left the editor and chart follow.
This is the real thing, not a screenshot: pick a snippet on the left, drag across the
brushed scatter it's Vega rendering live.
</p>
</div>
</section>
<div className={styles.wrap}>
<ShowcaseSection theme={theme} />
<section className={styles.cap} id="author">
<div className={styles.capText}>
<div className={styles.capEyebrow}>Two ways in</div>
<div className={styles.capEyebrow}>For the spec author</div>
<h2 className={styles.capH}>
Write the spec, or <b>build it by clicking.</b>
An editor that <b>knows Vega-Lite.</b>
</h2>
<p className={styles.capP}>
The editor is Monaco with the Vega-Lite schema loaded, so you get validation,
autocompletion, and inline docs without going online. Each chart keeps an editable
draft alongside a published version you can revert to.
The editor is Monaco with the Vega-Lite schema loaded: autocompletion, inline docs,
and validation as you type, all of it offline. One click scaffolds a working parameter
or data transform to build on.
</p>
<p className={styles.capP}>
If you'd rather not start from JSON, the builder works from the kind of chart you
want. Pick fields, a mark, or a whole intent it writes the spec for you. Try it:
When a chart misbehaves, the data inspector shows the rows going in and the rows the
marks actually draw the fastest answer to "why is my chart empty". And every snippet
keeps an editable draft alongside its published version, so you can always revert to
the last good chart.
</p>
</div>
<div className={styles.shot}>
<div className={styles.shotBar}>
<span className={styles.dots}>
<i />
<i />
<i />
</span>{' '}
Chart Builder · {SAMPLE_DATASET_NAME}
<EditorProof />
</section>
{/* The habit-cluster objections, answered where the skeptic is scrolling
(landing-onboarding scope memo): each answer is an on-ramp, not a rebuttal. */}
<div className={styles.objections}>
<div>
<h3>Already using the Vega editor?</h3>
<p>
It's a fine scratchpad one spec at a time, nothing kept. Astrolabe is the home: a
library of specs with their datasets, themes, and drafts.
</p>
</div>
<div>
<h3>Writing charts in Altair or another wrapper?</h3>
<p>
A wrapper's output already is a Vega-Lite spec. Paste it in, polish the last mile
(label formats, axis tweaks you'd otherwise translate back through the wrapper's API),
and keep the result.
</p>
</div>
<div>
<h3>Asking an AI for specs?</h3>
<p>
Paste what it gave you: live preview, schema validation, and the data inspector are
where an almost-right spec becomes right. There is no AI inside Astrolabe to see your
data.
</p>
</div>
</div>
<section className={`${styles.cap} ${styles.capRev}`} id="builder">
<div className={styles.capText}>
<div className={styles.capEyebrow}>Rather not start from JSON?</div>
<h2 className={styles.capH}>
The builder writes <b>the spec for you.</b>
</h2>
<p className={styles.capP}>
Point it at your data and work from the kind of chart you want: pick fields, a mark,
or a whole intent (compare, over time, distribution), and it writes ordinary Vega-Lite
the same spec you'd have written by hand, ready to keep clicking on or to open in
the editor.
</p>
<p className={styles.capP}>
<a className={styles.inlineLink} href="/app/#build">
Open the Chart Builder
</a>
</p>
</div>
<Shot bar={<>Chart Builder · {SAMPLE_DATASET_NAME}</>}>
<div className={styles.shotBody}>
<BuilderDemo theme={theme} />
</div>
</div>
</Shot>
</section>
<section className={`${styles.cap} ${styles.capRev}`} id="library">
<section className={styles.cap} id="library">
<div className={styles.capText}>
<div className={styles.capEyebrow}>One library</div>
<h2 className={styles.capH}>
@@ -435,21 +704,27 @@ export function Landing(): ReactNode {
it and Astrolabe updates every chart that used it.
</p>
<p className={styles.capP}>
Load data by pasting CSV or JSON, or by fetching a URL. You can also lift inline data
out of a spec into a shared dataset. Search and sort as the collection grows, and
duplicate a snippet to start a variant.
Paste CSV or JSON, fetch a URL, or lift the inline data already sitting in a spec out
into a shared dataset. Search and sort as the collection grows, and duplicate a
snippet to start a variant.
</p>
</div>
<div className={styles.shot}>
<div className={styles.shotBar}>
<span className={styles.dots}>
<i />
<i />
<i />
</span>{' '}
Datasets
</div>
<Shot bar="Datasets">
<div className={`${styles.shotBody} ${styles.shotBodyFlush}`}>
{/* Store-once-reference-many, as a picture: one dataset, the
snippets that read it hanging off it. */}
<div className={styles.refDiagram} aria-hidden="true">
<div className={styles.refDataset}>
<span className={styles.refDot} /> sales-2025
<span className={styles.refMeta}> one dataset, stored once</span>
</div>
<ul className={styles.refTree}>
<li>Revenue by quarter</li>
<li>Growth trend</li>
<li>Regional split</li>
<li className={styles.refMore}>rename it, and every chart follows</li>
</ul>
</div>
<div className={styles.row}>
<div className={styles.rowName}>
sales-2025 <span className={styles.rowSub}>· CSV · 1,204 rows</span>
@@ -484,62 +759,52 @@ export function Landing(): ReactNode {
</div>
</div>
</div>
</div>
</Shot>
</section>
<section className={styles.cap} id="theme">
{/* Stacked (text above, demo full width): the gallery needs the whole
column for three charts to breathe. */}
<section className={styles.capStack} id="theme">
<div className={styles.capText}>
<div className={styles.capEyebrow}>Make it yours</div>
<h2 className={styles.capH}>
Give your charts a <b>look of their own.</b>
</h2>
<p className={styles.capP}>
Sixteen presets to start from, or build your own in the Theme Builder colour, type,
axes, legend, and layout. The first three here are built from scratch. Try a few:
Upload your own fonts (variable fonts included) and apply one across a whole theme at
once. Sixteen presets to start from, or build your own in the Theme Builder: colour,
type, axes, legends, marks, titles, number formats.
</p>
<p className={styles.capP}>
Upload your own fonts, variable fonts included, and apply one across a whole theme at
once. Save the theme and use it on any chart.
Save a theme and use it on any chart. The first three here are built from scratch. Try
a few:
</p>
</div>
<div className={styles.shot}>
<div className={styles.shotBar}>
<span className={styles.dots}>
<i />
<i />
<i />
</span>{' '}
Theme Builder
</div>
<Shot bar="Theme Builder">
<div className={styles.shotBody}>
<ThemeDemo theme={theme} />
</div>
</div>
</Shot>
</section>
<section className={`${styles.cap} ${styles.capRev}`} id="export">
<section className={styles.cap} id="export">
<div className={styles.capText}>
<div className={styles.capEyebrow}>Exporting</div>
<div className={styles.capEyebrow}>Getting it out</div>
<h2 className={styles.capH}>
Save a chart in the <b>format you need.</b>
Exports with <b>the settings you reach for.</b>
</h2>
<p className={styles.capP}>
Export as a PNG at 13×, as an SVG, or as a Vega-Lite spec. The spec can carry its
data inline, so the file renders on its own wherever it lands.
Every embedded Vega chart ships the same tiny menu (save PNG, save SVG). Astrolabe's
export adds the settings that menu is missing: 13× scale for slides and print,
background control, and the spec itself as a file.
</p>
<p className={styles.capP}>
Your whole workspace exports and re-imports as a single JSON file.
The spec export can inline its data: one self-contained .vl.json that renders anywhere
Vega-Lite runs. That file is how you share a chart from a tool with no server behind
it.
</p>
</div>
<div className={styles.shot}>
<div className={styles.shotBar}>
<span className={styles.dots}>
<i />
<i />
<i />
</span>{' '}
Export chart
</div>
<Shot bar="Export chart">
<div className={styles.shotBody}>
<div className={styles.exp}>
<div className={styles.expRow}>
@@ -591,7 +856,7 @@ export function Landing(): ReactNode {
</div>
</div>
</div>
</div>
</Shot>
</section>
</div>
@@ -612,7 +877,8 @@ export function Landing(): ReactNode {
<h3>Local to your browser</h3>
<p>
Charts, data, and themes are saved in this browser and nowhere else there is no
server behind Astrolabe to receive them. It keeps working offline once installed.
server behind Astrolabe to receive them. Install it and switch off the network: it
keeps working. That's the privacy claim in a form you can test.
</p>
</div>
<div>
@@ -620,15 +886,16 @@ export function Landing(): ReactNode {
<h3>Ordinary Vega-Lite</h3>
<p>
Export a single chart or the whole library as standard JSON and open it in any other
Vega tool.
Vega tool. If Astrolabe vanished tomorrow, your charts wouldn't notice.
</p>
</div>
<div>
<span className={styles.creedKey}>yours</span>
<h3>Set up your way</h3>
<h3>One file, yours to keep</h3>
<p>
Author by hand or by clicking, with your own fonts and themes. Arrange the library
to match how you work.
The whole workspace snippets, datasets, themes, fonts exports as a single JSON
file. Back it up like any file you own and import it on any machine. No sync service
to trust.
</p>
</div>
</div>
@@ -643,6 +910,13 @@ export function Landing(): ReactNode {
<a className={`${styles.btn} ${styles.btnPrimary}`} href="/app/">
Open Astrolabe
</a>
<p className={styles.closeNote}>
New to Vega-Lite?{' '}
<a className={styles.inlineLink} href="/learn/">
Start with the deep dives
</a>
.
</p>
</div>
</section>
+13 -3
View File
@@ -1,7 +1,7 @@
import { useEffect, useRef, useState, type ReactNode } from 'react';
import type { Config } from 'vega-lite';
import type { VisualizationSpec } from 'vega-embed';
import { prepareSpecForRender } from '@core/rendering';
import { prepareSpecForRender, type FitMode } from '@core/rendering';
import styles from './Landing.module.css';
/**
@@ -18,10 +18,20 @@ export function LandingChart({
spec,
config,
className,
fitMode = 'width',
}: {
spec: Record<string, unknown>;
config: Config;
className?: string;
/**
* Sizing contract passed to `prepareSpecForRender`. The `'width'` default
* fits every single-view surface here; a *composed* spec (facet/concat) must
* pass `'default'` and declare its own sizes the fit recursion would set
* `width: 'container'` on the children, which Vega-Lite only supports on
* single and layered views (facet panels go timing-dependent, concat children
* fall back to pad and overflow).
*/
fitMode?: FitMode;
}): ReactNode {
const hostRef = useRef<HTMLDivElement>(null);
const [failed, setFailed] = useState(false);
@@ -37,7 +47,7 @@ export function LandingChart({
// Width-responsive: the chart fills its host (a definite-width `.chartNode`)
// and keeps a natural height — right for every landing surface (hero pane,
// builder, theme gallery).
const prepared: unknown = prepareSpecForRender(spec, { fitMode: 'width' });
const prepared: unknown = prepareSpecForRender(spec, { fitMode });
handle = await renderSpec(node, prepared as VisualizationSpec, config);
if (cancelled) {
handle.destroy();
@@ -57,7 +67,7 @@ export function LandingChart({
handle?.destroy();
handle = null;
};
}, [spec, config]);
}, [spec, config, fitMode]);
return (
<div className={className ?? styles.chartHost}>
+36
View File
@@ -0,0 +1,36 @@
import { describe, expect, test } from 'vitest';
import { compile } from 'vega-lite';
import { SHOWCASE_DEMOS, showcaseDisplaySpec, showcaseFitMode } from './showcase-specs';
// Same promise as the example gallery (examples.test.ts): every showcase chart
// must render — each spec is valid, self-contained Vega-Lite.
describe('landing showcase demos', () => {
test('ids are unique and demos are non-empty', () => {
const ids = SHOWCASE_DEMOS.map((d) => d.id);
expect(ids.length).toBeGreaterThan(0);
expect(new Set(ids).size).toBe(ids.length);
});
test('composed demos render at their declared sizes, single views fit the card', () => {
const byId = (id: string) => SHOWCASE_DEMOS.find((d) => d.id === id)!;
expect(showcaseFitMode(byId('tooltip'))).toBe('width'); // single view
expect(showcaseFitMode(byId('brush'))).toBe('default'); // vconcat
expect(showcaseFitMode(byId('facet'))).toBe('default'); // facet
});
describe.each(SHOWCASE_DEMOS.map((d) => [d.id, d] as const))('demo: %s', (_id, demo) => {
test('carries inline data and compiles as valid Vega-Lite', () => {
const data = demo.spec.data as { values?: unknown[] };
expect(Array.isArray(data?.values)).toBe(true);
expect(() => compile(demo.spec as unknown as Parameters<typeof compile>[0])).not.toThrow();
});
test('the displayed spec swaps inline data for a name reference, all else equal', () => {
const display = showcaseDisplaySpec(demo);
expect(display.data).toEqual({ name: demo.datasetName });
const { data: _a, ...restDisplay } = display;
const { data: _b, ...restReal } = demo.spec;
expect(restDisplay).toEqual(restReal);
});
});
});
+207
View File
@@ -0,0 +1,207 @@
/**
* The "What Vega-Lite can do" showcase the landing's popularization
* centerpiece. Three live, *interactive* charts, each beside the spec that
* produces it: the argument is that a page of JSON is a working chart.
*
* Same constraints as the app's example gallery (`@core/examples`): inline
* `data.values` (self-contained, renders offline), valid Vega-Lite (asserted in
* showcase-specs.test.ts). Rendered specs carry the data inline; the *displayed*
* spec shows `data: { name: … }` instead the app's own dataset-by-reference
* idiom so the text beside each chart stays the length of the idea, not of
* the data, and reads exactly like a snippet in Astrolabe.
*/
import type { FitMode } from '@core/rendering';
import { VEGA_LITE_SCHEMA_URL } from '@core/snippet';
export interface ShowcaseDemo {
/** Stable key — React list key and test identity. */
id: string;
/** Short heading over the demo row. */
title: string;
/** One or two sentences: the capability this demo argues. */
blurb: string;
/** The try-it nudge shown under the live chart. */
hint: string;
/** Dataset name shown in the displayed spec's `data: { name: … }`. */
datasetName: string;
/** The full spec (inline data) that actually renders. */
spec: Record<string, unknown>;
/** Demo custom-theme id (see demo-themes.ts) this chart renders in. */
themeId: number;
}
const TEMPS = [
{ month: '2025-01-01', city: 'Kyiv', temp: -3.1 },
{ month: '2025-02-01', city: 'Kyiv', temp: -1.4 },
{ month: '2025-03-01', city: 'Kyiv', temp: 4.2 },
{ month: '2025-04-01', city: 'Kyiv', temp: 11.3 },
{ month: '2025-05-01', city: 'Kyiv', temp: 17.6 },
{ month: '2025-06-01', city: 'Kyiv', temp: 20.8 },
{ month: '2025-07-01', city: 'Kyiv', temp: 22.9 },
{ month: '2025-08-01', city: 'Kyiv', temp: 22.1 },
{ month: '2025-01-01', city: 'Lisbon', temp: 11.6 },
{ month: '2025-02-01', city: 'Lisbon', temp: 12.4 },
{ month: '2025-03-01', city: 'Lisbon', temp: 14.5 },
{ month: '2025-04-01', city: 'Lisbon', temp: 15.9 },
{ month: '2025-05-01', city: 'Lisbon', temp: 18.2 },
{ month: '2025-06-01', city: 'Lisbon', temp: 21.4 },
{ month: '2025-07-01', city: 'Lisbon', temp: 23.6 },
{ month: '2025-08-01', city: 'Lisbon', temp: 24.0 },
{ month: '2025-01-01', city: 'Oslo', temp: -4.8 },
{ month: '2025-02-01', city: 'Oslo', temp: -4.1 },
{ month: '2025-03-01', city: 'Oslo', temp: 0.4 },
{ month: '2025-04-01', city: 'Oslo', temp: 5.9 },
{ month: '2025-05-01', city: 'Oslo', temp: 11.8 },
{ month: '2025-06-01', city: 'Oslo', temp: 15.7 },
{ month: '2025-07-01', city: 'Oslo', temp: 17.4 },
{ month: '2025-08-01', city: 'Oslo', temp: 16.3 },
];
const ENGINES = [
{ power: 68, efficiency: 22.4, origin: 'Japan' },
{ power: 75, efficiency: 20.8, origin: 'Japan' },
{ power: 88, efficiency: 19.1, origin: 'Japan' },
{ power: 97, efficiency: 17.5, origin: 'Japan' },
{ power: 110, efficiency: 15.9, origin: 'Japan' },
{ power: 130, efficiency: 13.6, origin: 'Japan' },
{ power: 118, efficiency: 14.8, origin: 'Japan' },
{ power: 72, efficiency: 18.9, origin: 'Europe' },
{ power: 85, efficiency: 17.8, origin: 'Europe' },
{ power: 100, efficiency: 16.2, origin: 'Europe' },
{ power: 115, efficiency: 14.7, origin: 'Europe' },
{ power: 140, efficiency: 12.1, origin: 'Europe' },
{ power: 165, efficiency: 10.4, origin: 'Europe' },
{ power: 90, efficiency: 15.3, origin: 'USA' },
{ power: 105, efficiency: 14.1, origin: 'USA' },
{ power: 125, efficiency: 12.8, origin: 'USA' },
{ power: 150, efficiency: 11.2, origin: 'USA' },
{ power: 200, efficiency: 8.6, origin: 'USA' },
];
const SIGNUPS = [
{ week: '2025-05-05', plan: 'Free', signups: 84 },
{ week: '2025-05-12', plan: 'Free', signups: 96 },
{ week: '2025-05-19', plan: 'Free', signups: 110 },
{ week: '2025-05-26', plan: 'Free', signups: 103 },
{ week: '2025-06-02', plan: 'Free', signups: 121 },
{ week: '2025-06-09', plan: 'Free', signups: 137 },
{ week: '2025-05-05', plan: 'Pro', signups: 22 },
{ week: '2025-05-12', plan: 'Pro', signups: 27 },
{ week: '2025-05-19', plan: 'Pro', signups: 25 },
{ week: '2025-05-26', plan: 'Pro', signups: 34 },
{ week: '2025-06-02', plan: 'Pro', signups: 39 },
{ week: '2025-06-09', plan: 'Pro', signups: 45 },
{ week: '2025-05-05', plan: 'Team', signups: 6 },
{ week: '2025-05-12', plan: 'Team', signups: 9 },
{ week: '2025-05-19', plan: 'Team', signups: 11 },
{ week: '2025-05-26', plan: 'Team', signups: 10 },
{ week: '2025-06-02', plan: 'Team', signups: 14 },
{ week: '2025-06-09', plan: 'Team', signups: 18 },
];
export const SHOWCASE_DEMOS: ReadonlyArray<ShowcaseDemo> = [
{
id: 'tooltip',
title: 'Tooltips are one word',
blurb:
'Interaction is declared like any other property. The tooltips on this chart cost a single one: "tooltip": true.',
hint: 'Hover the points.',
datasetName: 'city-temperatures',
themeId: 1, // Editorial
spec: {
$schema: VEGA_LITE_SCHEMA_URL,
description: 'Monthly average temperature by city.',
data: { values: TEMPS },
mark: { type: 'line', point: true, tooltip: true },
encoding: {
x: { field: 'month', type: 'temporal', title: 'Month' },
y: { field: 'temp', type: 'quantitative', title: '°C' },
color: { field: 'city', type: 'nominal' },
},
},
},
{
id: 'brush',
title: 'Selections drive other views',
blurb:
'A named selection in one view becomes a filter in another. Declare a param and a filter, and the views are linked — there is no event-handler code to write.',
hint: 'Drag a rectangle on the scatter — the bars recount.',
datasetName: 'engines',
themeId: 2, // Blueprint
spec: {
$schema: VEGA_LITE_SCHEMA_URL,
description: 'Engine power vs efficiency; brushing filters the count below.',
data: { values: ENGINES },
// Declared sizes, not container fit: Vega-Lite can't fit-size concat
// children (autosize falls back to pad and the axes overflow the card).
vconcat: [
{
width: 380,
height: 210,
params: [{ name: 'brush', select: 'interval' }],
mark: 'point',
encoding: {
x: { field: 'power', type: 'quantitative', title: 'Power (hp)' },
y: { field: 'efficiency', type: 'quantitative', title: 'km / l' },
color: {
condition: { param: 'brush', field: 'origin', type: 'nominal' },
value: 'lightgray',
},
},
},
{
width: 380,
height: 80,
transform: [{ filter: { param: 'brush' } }],
mark: 'bar',
encoding: {
y: { field: 'origin', type: 'nominal', title: null },
x: { aggregate: 'count', title: 'Engines in brush' },
color: { field: 'origin', type: 'nominal', legend: null },
},
},
],
},
},
{
id: 'facet',
title: 'One spec, a chart per group',
blurb:
'Add a facet and the grammar repeats the chart for every value of a field — same axes, same scales, honest comparison for free.',
hint: 'One declaration made three charts.',
datasetName: 'weekly-signups',
themeId: 3, // Sunset
spec: {
$schema: VEGA_LITE_SCHEMA_URL,
description: 'Weekly signups, one panel per plan.',
data: { values: SIGNUPS },
facet: { column: { field: 'plan', title: null } },
spec: {
width: 130,
height: 150,
mark: { type: 'area', line: true, tooltip: true },
encoding: {
x: { field: 'week', type: 'temporal', title: null },
y: { field: 'signups', type: 'quantitative', title: 'Signups' },
color: { field: 'plan', type: 'nominal', legend: null },
},
},
},
},
];
/** The spec as shown beside the chart: data by name, the app's own idiom. */
export function showcaseDisplaySpec(demo: ShowcaseDemo): Record<string, unknown> {
return { ...demo.spec, data: { name: demo.datasetName } };
}
/**
* Sizing contract for a demo's live render. Composed specs (facet/concat/repeat)
* keep their declared per-view sizes the width-fit recursion can't size their
* children (see LandingChart) while single and layered views fit the card.
*/
export function showcaseFitMode(demo: ShowcaseDemo): FitMode {
const composed = ['facet', 'vconcat', 'hconcat', 'concat', 'repeat'];
return composed.some((k) => k in demo.spec) ? 'default' : 'width';
}