mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Landing: add marketing page at /, move the app to /app/
Multi-page Vite build: index.html serves a new standalone landing (src/landing), app/index.html serves the app at /app/. The PWA service worker and manifest are scoped to /app/, so the landing stays uncontrolled and always-fresh; hash view-state routing is unchanged. The landing reuses src/core and the chart-renderer service only (never stores/orchestration/modals/components) and lazy-loads Vega. Its demos drive the real core: a hero snippet switcher over CHART_EXAMPLES, an interactive Chart Builder over a new profiled core fixture (sample-dataset), and a Theme Builder gallery with three from-scratch custom themes plus built-in presets.
This commit is contained in:
@@ -38,6 +38,12 @@ the spec_; do not port legacy code.
|
||||
- **Modals** go through a registry + coordinator + shell, not ad-hoc rendering.
|
||||
- **CSS Modules + design tokens** (`styles/tokens.css`); themes flip `[data-theme]`.
|
||||
- **No shared library with Syto** — patterns are adapted, never imported.
|
||||
- **The app is served at `/app/`; `/` is a standalone marketing landing** (`src/landing/`).
|
||||
Multi-page Vite build — `index.html` → the landing, `app/index.html` → the app (hash
|
||||
view-state routing is unchanged by the base path). The landing reuses `src/core` and the
|
||||
`chart-renderer` service only — never stores, orchestration, modals, or components — and
|
||||
lazy-loads Vega, so `/` stays light. The PWA service worker and manifest are scoped to
|
||||
`/app/`, leaving the landing uncontrolled and always-fresh.
|
||||
|
||||
See [`docs/architecture/`](docs/architecture/00-overview.md) for the patterns behind each
|
||||
layer (state, persistence, modals, routing, rendering, inference, relationships) and
|
||||
@@ -49,8 +55,11 @@ external repo is needed to work from them.
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
index.html # Landing entry (served at /)
|
||||
app/index.html # App entry (served at /app/)
|
||||
src/
|
||||
├── main.tsx # App entry (font wiring, startup, render)
|
||||
├── main.tsx # App bootstrap (font wiring, startup, render)
|
||||
├── landing/ # Marketing landing at / — standalone page; reuses core + chart-renderer
|
||||
├── core/ # Portable spec engine (no browser/React/Monaco)
|
||||
├── app/
|
||||
│ ├── components/ # React UI (CSS Modules co-located)
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="en" data-theme="light">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="mask-icon" href="/icon-mono.svg" color="#0e7490" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Astrolabe</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
+3
-3
@@ -6,10 +6,10 @@
|
||||
<link rel="mask-icon" href="/icon-mono.svg" color="#0e7490" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Astrolabe</title>
|
||||
<title>Astrolabe — a local Vega-Lite studio</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/landing/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { inferColumnType } from '@core/type-inference';
|
||||
import {
|
||||
CHART_INTENTS,
|
||||
defaultBuilderConfig,
|
||||
intentApplicable,
|
||||
isBuilderConfigValid,
|
||||
} from '@core/chart-builder';
|
||||
import {
|
||||
SAMPLE_DATASET_COLUMNS,
|
||||
SAMPLE_DATASET_NAME,
|
||||
SAMPLE_DATASET_ROWS,
|
||||
} from '@core/sample-dataset';
|
||||
|
||||
describe('sample-dataset fixture', () => {
|
||||
it('declares column types that match what inference derives from the rows', () => {
|
||||
for (const { name, type } of SAMPLE_DATASET_COLUMNS.columnTypes) {
|
||||
const values = SAMPLE_DATASET_ROWS.map((row) => row[name]);
|
||||
expect(inferColumnType(values), name).toBe(type);
|
||||
}
|
||||
});
|
||||
|
||||
it('declares column stats that match the rows', () => {
|
||||
for (const stat of SAMPLE_DATASET_COLUMNS.columnStats ?? []) {
|
||||
const values = SAMPLE_DATASET_ROWS.map((row) => row[stat.name]);
|
||||
expect(stat.distinct, stat.name).toBe(new Set(values).size);
|
||||
const numbers = values.filter((v): v is number => typeof v === 'number');
|
||||
if (numbers.length === values.length) {
|
||||
expect(stat.numericExtent, stat.name).toEqual({
|
||||
min: Math.min(...numbers),
|
||||
max: Math.max(...numbers),
|
||||
});
|
||||
} else {
|
||||
expect(stat.numericExtent, stat.name).toBeNull();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('opens the builder on a valid default chart', () => {
|
||||
const config = defaultBuilderConfig(SAMPLE_DATASET_NAME, SAMPLE_DATASET_COLUMNS);
|
||||
expect(isBuilderConfigValid(config)).toBe(true);
|
||||
});
|
||||
|
||||
it('makes every intent applicable, so the demo strip is fully live', () => {
|
||||
for (const intent of CHART_INTENTS) {
|
||||
expect(intentApplicable(intent, SAMPLE_DATASET_COLUMNS), intent).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* A small, profiled sample dataset for the landing page's Chart Builder demo.
|
||||
*
|
||||
* Portable core (no browser APIs, no React), the same neighborhood as
|
||||
* `examples.ts` and `theme-preview-specs.ts`. It exists so the landing's builder
|
||||
* widget can drive the *real* `chart-builder` core — `defaultBuilderConfig`,
|
||||
* `applyIntent`, `buildChartSpec` — over a column-typed dataset, the one piece of
|
||||
* sample data the existing fixtures don't provide (they are inline-data specs, not
|
||||
* tabular columns). Scoped to that single consumer; not a general API.
|
||||
*
|
||||
* The column roles are chosen so every chart intent applies (two categories, a
|
||||
* temporal, two measures), keeping the demo's intent strip fully live. The
|
||||
* declared `columnTypes`/`columnStats` are asserted against the rows in
|
||||
* `sample-dataset.test.ts`, so they can't drift from the data.
|
||||
*/
|
||||
|
||||
import type { BuilderColumns } from './chart-builder';
|
||||
|
||||
export const SAMPLE_DATASET_NAME = 'sales-2024';
|
||||
|
||||
export const SAMPLE_DATASET_ROWS: ReadonlyArray<Record<string, string | number>> = [
|
||||
{ month: '2024-01-01', region: 'North', channel: 'Web', revenue: 120, units: 34 },
|
||||
{ month: '2024-01-01', region: 'South', channel: 'Retail', revenue: 95, units: 28 },
|
||||
{ month: '2024-01-01', region: 'East', channel: 'Partner', revenue: 60, units: 15 },
|
||||
{ month: '2024-02-01', region: 'West', channel: 'Web', revenue: 135, units: 39 },
|
||||
{ month: '2024-02-01', region: 'North', channel: 'Retail', revenue: 110, units: 31 },
|
||||
{ month: '2024-02-01', region: 'South', channel: 'Partner', revenue: 72, units: 18 },
|
||||
{ month: '2024-03-01', region: 'East', channel: 'Web', revenue: 128, units: 36 },
|
||||
{ month: '2024-03-01', region: 'West', channel: 'Retail', revenue: 101, units: 30 },
|
||||
{ month: '2024-03-01', region: 'North', channel: 'Partner', revenue: 80, units: 21 },
|
||||
{ month: '2024-04-01', region: 'South', channel: 'Web', revenue: 156, units: 44 },
|
||||
{ month: '2024-04-01', region: 'East', channel: 'Retail', revenue: 118, units: 33 },
|
||||
{ month: '2024-04-01', region: 'West', channel: 'Partner', revenue: 90, units: 24 },
|
||||
{ month: '2024-05-01', region: 'North', channel: 'Web', revenue: 172, units: 48 },
|
||||
{ month: '2024-05-01', region: 'South', channel: 'Retail', revenue: 130, units: 37 },
|
||||
{ month: '2024-05-01', region: 'East', channel: 'Partner', revenue: 99, units: 26 },
|
||||
{ month: '2024-06-01', region: 'West', channel: 'Web', revenue: 165, units: 46 },
|
||||
{ month: '2024-06-01', region: 'North', channel: 'Retail', revenue: 142, units: 40 },
|
||||
{ month: '2024-06-01', region: 'South', channel: 'Partner', revenue: 88, units: 23 },
|
||||
];
|
||||
|
||||
export const SAMPLE_DATASET_COLUMNS: BuilderColumns = {
|
||||
columns: ['month', 'region', 'channel', 'revenue', 'units'],
|
||||
columnTypes: [
|
||||
{ name: 'month', type: 'date' },
|
||||
{ name: 'region', type: 'string' },
|
||||
{ name: 'channel', type: 'string' },
|
||||
{ name: 'revenue', type: 'number' },
|
||||
{ name: 'units', type: 'number' },
|
||||
],
|
||||
columnStats: [
|
||||
{ name: 'month', distinct: 6, distinctCapped: false, numericExtent: null },
|
||||
{ name: 'region', distinct: 4, distinctCapped: false, numericExtent: null },
|
||||
{ name: 'channel', distinct: 3, distinctCapped: false, numericExtent: null },
|
||||
{ name: 'revenue', distinct: 18, distinctCapped: false, numericExtent: { min: 60, max: 172 } },
|
||||
{ name: 'units', distinct: 18, distinctCapped: false, numericExtent: { min: 15, max: 48 } },
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,623 @@
|
||||
/*
|
||||
* Landing page — the marketing/onboarding surface at `/`. Shares the app's
|
||||
* design tokens and IBM Plex (imported via base.css in the entry), but is a
|
||||
* standalone page bundle: no Zustand, no IndexedDB, no service worker.
|
||||
*
|
||||
* Syntax-highlight colours for the read-only spec view aren't app tokens (the
|
||||
* app uses Monaco's own theme), so they're defined here, on the landing root,
|
||||
* with a dark-theme override.
|
||||
*/
|
||||
.landing {
|
||||
--code-key: #6929c4;
|
||||
--code-str: #0e7490;
|
||||
--code-num: #1192e8;
|
||||
}
|
||||
:global(html[data-theme='dark']) .landing {
|
||||
--code-key: #be95ff;
|
||||
--code-str: #3ddbd9;
|
||||
--code-num: #82cfff;
|
||||
}
|
||||
|
||||
.wrap {
|
||||
max-width: 1080px;
|
||||
margin: 0 auto;
|
||||
padding: 0 var(--space-6);
|
||||
}
|
||||
|
||||
/* ---- nav ---- */
|
||||
.nav {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 50;
|
||||
background: color-mix(in srgb, var(--bg) 88%, transparent);
|
||||
backdrop-filter: blur(8px);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.navIn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 56px;
|
||||
gap: var(--space-5);
|
||||
}
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.brandMark {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: block;
|
||||
}
|
||||
.navSpacer {
|
||||
margin-left: auto;
|
||||
}
|
||||
.navLink {
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
font-size: 13px;
|
||||
margin-right: var(--space-5);
|
||||
}
|
||||
.navLink:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
/* The primary CTA sits a step apart from the theme toggle beside it. */
|
||||
.navCta {
|
||||
margin-left: var(--space-4);
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
height: var(--control-height);
|
||||
padding: 0 var(--space-5);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
border: 1px solid var(--border-strong);
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
.btn:hover {
|
||||
background: var(--layer-01);
|
||||
}
|
||||
.btnPrimary {
|
||||
height: var(--control-height-lg);
|
||||
padding: 0 var(--space-6);
|
||||
font-size: 15px;
|
||||
background: var(--accent);
|
||||
color: var(--accent-contrast);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.btnPrimary:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
/* Compact marketing chrome (nav/footer links, demo affordances). Off the app's
|
||||
32/40px control scale on purpose — these are landing-page buttons, not the app's
|
||||
Button primitive. */
|
||||
.btnSm {
|
||||
height: 28px;
|
||||
padding: 0 var(--space-4);
|
||||
font-size: 12px;
|
||||
}
|
||||
/* Filled-accent compact button — the Export demo's "Download" affordance. */
|
||||
.btnFill {
|
||||
background: var(--accent);
|
||||
color: var(--accent-contrast);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* ---- hero ---- */
|
||||
.hero {
|
||||
text-align: center;
|
||||
padding: var(--space-9) 0 var(--space-7);
|
||||
}
|
||||
.hero h1 {
|
||||
font-weight: 300;
|
||||
font-size: clamp(34px, 6vw, 54px);
|
||||
line-height: 1.08;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.hero h1 b {
|
||||
font-weight: 600;
|
||||
}
|
||||
.hero p {
|
||||
margin: var(--space-5) auto var(--space-7);
|
||||
max-width: 56ch;
|
||||
font-size: 17px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.heroCta {
|
||||
display: flex;
|
||||
gap: var(--space-4);
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
.heroNote {
|
||||
font-size: 13px;
|
||||
color: var(--text-placeholder);
|
||||
}
|
||||
|
||||
/* ---- app-window mock ---- */
|
||||
.stage {
|
||||
padding-bottom: var(--space-9);
|
||||
}
|
||||
.stageHint {
|
||||
text-align: center;
|
||||
margin: var(--space-5) 0 0;
|
||||
font-size: 13px;
|
||||
color: var(--text-placeholder);
|
||||
}
|
||||
.appWin {
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg);
|
||||
box-shadow: 0 24px 64px -24px rgba(0, 0, 0, 0.35);
|
||||
overflow: hidden;
|
||||
}
|
||||
.appWinBar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
height: 40px;
|
||||
padding: 0 var(--space-4);
|
||||
background: var(--layer-01);
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 13px;
|
||||
}
|
||||
.grow {
|
||||
flex: 1;
|
||||
}
|
||||
.dots {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
}
|
||||
.dots i {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
background: var(--border-strong);
|
||||
display: block;
|
||||
}
|
||||
.pill {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border);
|
||||
padding: 2px 8px;
|
||||
}
|
||||
|
||||
.appBody {
|
||||
display: grid;
|
||||
grid-template-columns: 220px 1fr 1fr;
|
||||
/* A fixed height keeps the window on-screen at default scale; each column
|
||||
scrolls its own overflow (a long spec, a long library) within it. */
|
||||
height: 420px;
|
||||
}
|
||||
.appBody > * {
|
||||
min-width: 0;
|
||||
}
|
||||
.col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.col + .col {
|
||||
border-left: 1px solid var(--border);
|
||||
}
|
||||
.colHead {
|
||||
flex: 0 0 auto;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-placeholder);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.libList {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.libSearch {
|
||||
margin: var(--space-3) var(--space-4);
|
||||
height: var(--control-height);
|
||||
background: var(--field);
|
||||
border-bottom: 1px solid var(--border-strong);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 var(--space-3);
|
||||
color: var(--text-placeholder);
|
||||
font-size: 12px;
|
||||
}
|
||||
.row {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: none;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.rowSel {
|
||||
background: var(--accent-soft);
|
||||
box-shadow: inset 2px 0 0 var(--accent);
|
||||
}
|
||||
.rowName {
|
||||
font-size: 13px;
|
||||
}
|
||||
/* Inline meta after a dataset name in the Library mock (format · size). */
|
||||
.rowSub {
|
||||
color: var(--text-placeholder);
|
||||
font-size: 11px;
|
||||
}
|
||||
.rowLast {
|
||||
border-bottom: none;
|
||||
}
|
||||
.rowMeta {
|
||||
font-size: 11px;
|
||||
color: var(--text-placeholder);
|
||||
margin-top: 2px;
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
.chip {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--accent);
|
||||
border: 1px solid var(--border);
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.code {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
margin: 0;
|
||||
padding: var(--space-4) var(--space-5);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
color: var(--text);
|
||||
white-space: pre;
|
||||
overflow: auto;
|
||||
}
|
||||
.tokKey {
|
||||
color: var(--code-key);
|
||||
}
|
||||
.tokStr {
|
||||
color: var(--code-str);
|
||||
}
|
||||
.tokNum {
|
||||
color: var(--code-num);
|
||||
}
|
||||
|
||||
.preview {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: var(--space-5);
|
||||
}
|
||||
.chartHost {
|
||||
min-height: 180px;
|
||||
}
|
||||
/* The node Vega embeds into. It gets `display:inline-block` (`.vega-embed`) at
|
||||
runtime, so a definite width is what makes the responsive width:"container"
|
||||
fit resolve instead of collapsing to zero. */
|
||||
.chartNode {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.chartError {
|
||||
display: block;
|
||||
padding: var(--space-4);
|
||||
font-size: 12px;
|
||||
color: var(--support-error);
|
||||
}
|
||||
|
||||
/* ---- capability sections ---- */
|
||||
.cap {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1.1fr;
|
||||
gap: var(--space-9);
|
||||
align-items: center;
|
||||
padding: var(--space-9) 0;
|
||||
}
|
||||
.capRev .capText {
|
||||
order: 2;
|
||||
}
|
||||
.capEyebrow {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
.capH {
|
||||
font-weight: 300;
|
||||
font-size: 28px;
|
||||
line-height: 1.2;
|
||||
letter-spacing: -0.01em;
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
.capH b {
|
||||
font-weight: 600;
|
||||
}
|
||||
.capP {
|
||||
color: var(--text-secondary);
|
||||
max-width: 44ch;
|
||||
font-size: 15px;
|
||||
}
|
||||
.capP + .capP {
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
|
||||
.shot {
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg);
|
||||
box-shadow: 0 16px 40px -20px rgba(0, 0, 0, 0.3);
|
||||
overflow: hidden;
|
||||
}
|
||||
.shotBar {
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: 0 var(--space-4);
|
||||
background: var(--layer-01);
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.shotBody {
|
||||
padding: var(--space-5);
|
||||
}
|
||||
/* The Library mock fills the panel with flush list rows, so it drops the padding. */
|
||||
.shotBodyFlush {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* builder demo */
|
||||
.seg {
|
||||
display: inline-flex;
|
||||
border: 1px solid var(--border-strong);
|
||||
margin-bottom: var(--space-5);
|
||||
}
|
||||
.seg button {
|
||||
padding: 5px var(--space-4);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
border: none;
|
||||
border-right: 1px solid var(--border);
|
||||
background: none;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
.seg button:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
.seg .on {
|
||||
background: var(--accent);
|
||||
color: var(--accent-contrast);
|
||||
}
|
||||
.shelf {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3) 0;
|
||||
border-top: 1px solid var(--border);
|
||||
font-size: 13px;
|
||||
}
|
||||
.shelf b {
|
||||
width: 52px;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
.select {
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
color: var(--text);
|
||||
background: var(--field);
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--border-strong);
|
||||
border-radius: 0;
|
||||
padding: 3px var(--space-3);
|
||||
cursor: pointer;
|
||||
}
|
||||
.intent {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
flex-wrap: wrap;
|
||||
margin-top: var(--space-5);
|
||||
}
|
||||
.intent button {
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
border: 1px solid var(--border);
|
||||
background: none;
|
||||
color: var(--text-secondary);
|
||||
padding: 3px var(--space-3);
|
||||
cursor: pointer;
|
||||
}
|
||||
.intent .intentOn {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
.intent button:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* theme demo */
|
||||
.gallery {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
margin-top: var(--space-5);
|
||||
}
|
||||
.mini {
|
||||
border: 1px solid var(--border);
|
||||
padding: var(--space-3);
|
||||
overflow: hidden;
|
||||
}
|
||||
.miniHost {
|
||||
min-height: 96px;
|
||||
}
|
||||
|
||||
/* export demo */
|
||||
.exp {
|
||||
max-width: 320px;
|
||||
}
|
||||
.expRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-3) 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
.expLab {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.grp {
|
||||
display: inline-flex;
|
||||
border: 1px solid var(--border-strong);
|
||||
}
|
||||
.grp button {
|
||||
padding: 4px var(--space-3);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
border: none;
|
||||
border-right: 1px solid var(--border);
|
||||
background: none;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
.grp button:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
.grp .on {
|
||||
background: var(--accent);
|
||||
color: var(--accent-contrast);
|
||||
}
|
||||
.expDownload {
|
||||
margin-top: var(--space-5);
|
||||
}
|
||||
|
||||
/* ---- philosophy band ---- */
|
||||
.creed {
|
||||
background: var(--accent-soft);
|
||||
border-top: 1px solid var(--border);
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin-top: var(--space-9);
|
||||
padding: var(--space-9) 0;
|
||||
text-align: center;
|
||||
}
|
||||
.creed h2 {
|
||||
font-weight: 300;
|
||||
font-size: 30px;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.creed h2 b {
|
||||
font-weight: 600;
|
||||
}
|
||||
.creedLede {
|
||||
color: var(--text-secondary);
|
||||
max-width: 56ch;
|
||||
margin: var(--space-5) auto var(--space-8);
|
||||
font-size: 16px;
|
||||
}
|
||||
.creedGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: var(--space-7);
|
||||
text-align: left;
|
||||
}
|
||||
.creedGrid h3 {
|
||||
font-size: 15px;
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
.creedGrid p {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
}
|
||||
.creedKey {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--accent);
|
||||
display: block;
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
/* ---- closing ---- */
|
||||
.close {
|
||||
text-align: center;
|
||||
padding: var(--space-9) 0;
|
||||
}
|
||||
.close h2 {
|
||||
font-weight: 300;
|
||||
font-size: 30px;
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
.close h2 b {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.footer {
|
||||
border-top: 1px solid var(--border);
|
||||
padding: var(--space-7) 0;
|
||||
}
|
||||
.footerIn {
|
||||
display: flex;
|
||||
gap: var(--space-5);
|
||||
align-items: center;
|
||||
font-size: 13px;
|
||||
color: var(--text-placeholder);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.footerIn a {
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
}
|
||||
.footerIn a:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
.footerSp {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.appBody {
|
||||
grid-template-columns: 1fr;
|
||||
height: auto;
|
||||
}
|
||||
.appBody .col:first-child {
|
||||
display: none;
|
||||
}
|
||||
.col {
|
||||
overflow: visible;
|
||||
}
|
||||
.code {
|
||||
max-height: 320px;
|
||||
}
|
||||
.cap,
|
||||
.creedGrid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--space-6);
|
||||
}
|
||||
.capRev .capText {
|
||||
order: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,654 @@
|
||||
import { useMemo, useState, type ReactNode } from 'react';
|
||||
import {
|
||||
applyIntent,
|
||||
activeIntent,
|
||||
buildChartSpec,
|
||||
CHART_INTENTS,
|
||||
defaultBuilderConfig,
|
||||
defaultFieldType,
|
||||
intentApplicable,
|
||||
type BuilderConfig,
|
||||
type ChannelMapping,
|
||||
type ChannelName,
|
||||
type ChartIntent,
|
||||
type MarkType,
|
||||
} from '@core/chart-builder';
|
||||
import { CHART_EXAMPLES } from '@core/examples';
|
||||
import {
|
||||
chartConfigForSelection,
|
||||
customThemeSelection,
|
||||
type ChartThemeSelection,
|
||||
} from '@core/vega-themes';
|
||||
import { THEME_PREVIEW_SPECS } from '@core/theme-preview-specs';
|
||||
import {
|
||||
SAMPLE_DATASET_COLUMNS,
|
||||
SAMPLE_DATASET_NAME,
|
||||
SAMPLE_DATASET_ROWS,
|
||||
} from '@core/sample-dataset';
|
||||
import { DEMO_CUSTOM_THEMES } from './demo-themes';
|
||||
import { LandingChart } from './LandingChart';
|
||||
import styles from './Landing.module.css';
|
||||
|
||||
type UiTheme = 'light' | 'dark';
|
||||
|
||||
// Minimal JSON syntax highlighter for the read-only spec view: keys, strings,
|
||||
// numbers, and literals get a token class; punctuation and whitespace pass
|
||||
// through untouched. A read-only marketing snippet doesn't warrant a highlighter
|
||||
// dependency. `matchAll` over a global regex is stateless per call.
|
||||
const JSON_TOKEN =
|
||||
/("(?:[^"\\]|\\.)*"(?=\s*:))|("(?:[^"\\]|\\.)*")|(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)|\b(?:true|false|null)\b/g;
|
||||
|
||||
function JsonCode({ spec }: { spec: unknown }): ReactNode {
|
||||
const text = JSON.stringify(spec, null, 2);
|
||||
const out: ReactNode[] = [];
|
||||
let last = 0;
|
||||
let key = 0;
|
||||
for (const m of text.matchAll(JSON_TOKEN)) {
|
||||
const idx = m.index ?? 0;
|
||||
if (idx > last) out.push(text.slice(last, idx));
|
||||
const cls = m[1] ? styles.tokKey : m[2] ? styles.tokStr : styles.tokNum;
|
||||
out.push(
|
||||
<span key={key++} className={cls}>
|
||||
{m[0]}
|
||||
</span>,
|
||||
);
|
||||
last = idx + m[0].length;
|
||||
}
|
||||
if (last < text.length) out.push(text.slice(last));
|
||||
return <pre className={styles.code}>{out}</pre>;
|
||||
}
|
||||
|
||||
/** A snippet-style filename from an example name. */
|
||||
function specFilename(name: string): string {
|
||||
return `${name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '')}.vl.json`;
|
||||
}
|
||||
|
||||
/** The mark name of an example spec (string mark or `{ type }` form). */
|
||||
function markLabel(spec: Record<string, unknown>): string {
|
||||
const mark = spec.mark;
|
||||
if (typeof mark === 'string') return mark;
|
||||
if (mark && typeof mark === 'object' && 'type' in mark) return String(mark.type);
|
||||
return 'chart';
|
||||
}
|
||||
|
||||
function Brand(): ReactNode {
|
||||
return (
|
||||
<span className={styles.brand}>
|
||||
<img className={styles.brandMark} src="/favicon.svg" alt="" width={20} height={20} />{' '}
|
||||
Astrolabe
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Hero: switch snippets, the editor + chart follow ────────────────────────────
|
||||
|
||||
function HeroAppWindow({ theme }: { theme: UiTheme }): ReactNode {
|
||||
const [selected, setSelected] = useState(0);
|
||||
const example = CHART_EXAMPLES[selected];
|
||||
const config = useMemo(() => chartConfigForSelection('astrolabe', theme), [theme]);
|
||||
|
||||
return (
|
||||
<div className={styles.appWin}>
|
||||
<div className={styles.appWinBar}>
|
||||
<span className={styles.dots}>
|
||||
<i />
|
||||
<i />
|
||||
<i />
|
||||
</span>
|
||||
<span className={styles.grow} />
|
||||
<span className={styles.pill}>{CHART_EXAMPLES.length} snippets</span>
|
||||
<span className={styles.pill}>live preview</span>
|
||||
</div>
|
||||
<div className={styles.appBody}>
|
||||
<div className={styles.col}>
|
||||
<div className={styles.colHead}>Library</div>
|
||||
<div className={styles.libSearch}>Search snippets…</div>
|
||||
<div className={styles.libList}>
|
||||
{CHART_EXAMPLES.map((ex, i) => (
|
||||
<button
|
||||
key={ex.id}
|
||||
type="button"
|
||||
className={`${styles.row} ${i === selected ? styles.rowSel : ''}`}
|
||||
onClick={() => setSelected(i)}
|
||||
aria-current={i === selected ? 'true' : undefined}
|
||||
>
|
||||
<div className={styles.rowName}>{ex.name}</div>
|
||||
<div className={styles.rowMeta}>
|
||||
<span className={styles.chip}>{markLabel(ex.spec)}</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.col}>
|
||||
<div className={styles.colHead}>{specFilename(example.name)}</div>
|
||||
<JsonCode spec={example.spec} />
|
||||
</div>
|
||||
<div className={styles.col}>
|
||||
<div className={styles.colHead}>Preview</div>
|
||||
<div className={styles.preview}>
|
||||
<LandingChart spec={example.spec} config={config} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Chart Builder demo: pick fields, a mark, or an intent; the chart rebuilds ────
|
||||
|
||||
const MARK_OPTIONS: ReadonlyArray<{ type: MarkType; label: string }> = [
|
||||
{ type: 'bar', label: 'Bar' },
|
||||
{ type: 'line', label: 'Line' },
|
||||
{ type: 'point', label: 'Point' },
|
||||
{ type: 'area', label: 'Area' },
|
||||
];
|
||||
|
||||
const INTENT_LABELS: Record<ChartIntent, string> = {
|
||||
compare: 'Compare',
|
||||
ranking: 'Ranking',
|
||||
time: 'Over time',
|
||||
correlation: 'Correlation',
|
||||
distribution: 'Distribution',
|
||||
partToWhole: 'Part-to-whole',
|
||||
heatmap: 'Heatmap',
|
||||
};
|
||||
|
||||
const BUILDER_TYPES = SAMPLE_DATASET_COLUMNS.columnTypes;
|
||||
const ALL_COLUMNS = SAMPLE_DATASET_COLUMNS.columns;
|
||||
const MEASURE_COLUMNS = BUILDER_TYPES.filter((c) => c.type === 'number').map((c) => c.name);
|
||||
const CATEGORY_COLUMNS = BUILDER_TYPES.filter(
|
||||
(c) => c.type === 'string' || c.type === 'boolean',
|
||||
).map((c) => c.name);
|
||||
const COUNT = '__count__';
|
||||
const NONE = '__none__';
|
||||
|
||||
function columnType(name: string): (typeof BUILDER_TYPES)[number]['type'] {
|
||||
return BUILDER_TYPES.find((c) => c.name === name)?.type ?? 'string';
|
||||
}
|
||||
/** A field mapping with the field type the builder would derive for that column. */
|
||||
function fieldMapping(name: string): ChannelMapping {
|
||||
return { field: name, type: defaultFieldType(columnType(name)) };
|
||||
}
|
||||
|
||||
function BuilderDemo({ theme }: { theme: UiTheme }): ReactNode {
|
||||
const columns = SAMPLE_DATASET_COLUMNS;
|
||||
const [config, setConfig] = useState<BuilderConfig>(() =>
|
||||
defaultBuilderConfig(SAMPLE_DATASET_NAME, columns),
|
||||
);
|
||||
const active = activeIntent(config, columns);
|
||||
const chartConfig = useMemo(() => chartConfigForSelection('astrolabe', theme), [theme]);
|
||||
const spec = useMemo(() => {
|
||||
const built = buildChartSpec(config);
|
||||
// Inline the sample rows so the spec renders standalone (the builder otherwise
|
||||
// emits a by-name dataset reference the app resolves from its library).
|
||||
built.data = { values: SAMPLE_DATASET_ROWS };
|
||||
return built;
|
||||
}, [config]);
|
||||
|
||||
const setEncoding = (channel: ChannelName, mapping: ChannelMapping | null): void =>
|
||||
setConfig({ ...config, encodings: { ...config.encodings, [channel]: mapping } });
|
||||
const yValue =
|
||||
config.encodings.y?.aggregate === 'count' ? COUNT : (config.encodings.y?.field ?? COUNT);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.seg}>
|
||||
{MARK_OPTIONS.map((m) => (
|
||||
<button
|
||||
key={m.type}
|
||||
type="button"
|
||||
aria-pressed={config.mark === m.type}
|
||||
className={config.mark === m.type ? styles.on : undefined}
|
||||
onClick={() => setConfig({ ...config, mark: m.type })}
|
||||
>
|
||||
{m.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<label className={styles.shelf}>
|
||||
<b>X</b>
|
||||
<select
|
||||
className={styles.select}
|
||||
value={config.encodings.x?.field ?? ''}
|
||||
onChange={(e) => setEncoding('x', fieldMapping(e.target.value))}
|
||||
>
|
||||
{ALL_COLUMNS.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{c}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className={styles.shelf}>
|
||||
<b>Y</b>
|
||||
<select
|
||||
className={styles.select}
|
||||
value={yValue}
|
||||
onChange={(e) =>
|
||||
setEncoding(
|
||||
'y',
|
||||
e.target.value === COUNT
|
||||
? { type: 'quantitative', aggregate: 'count' }
|
||||
: fieldMapping(e.target.value),
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value={COUNT}>count of records</option>
|
||||
{MEASURE_COLUMNS.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{c}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className={styles.shelf}>
|
||||
<b>Color</b>
|
||||
<select
|
||||
className={styles.select}
|
||||
value={config.encodings.color?.field ?? NONE}
|
||||
onChange={(e) =>
|
||||
setEncoding('color', e.target.value === NONE ? null : fieldMapping(e.target.value))
|
||||
}
|
||||
>
|
||||
<option value={NONE}>none</option>
|
||||
{CATEGORY_COLUMNS.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{c}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<div className={styles.intent}>
|
||||
{CHART_INTENTS.map((intent) => (
|
||||
<button
|
||||
key={intent}
|
||||
type="button"
|
||||
disabled={!intentApplicable(intent, columns)}
|
||||
aria-pressed={active === intent}
|
||||
className={active === intent ? styles.intentOn : undefined}
|
||||
onClick={() => setConfig(applyIntent(config, intent, columns))}
|
||||
>
|
||||
{INTENT_LABELS[intent]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<LandingChart spec={spec} config={chartConfig} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Theme Builder demo: pick a theme, the gallery re-renders ─────────────────────
|
||||
|
||||
const THEME_CHOICES: ReadonlyArray<{ selection: ChartThemeSelection; label: string }> = [
|
||||
...DEMO_CUSTOM_THEMES.map((t) => ({ selection: customThemeSelection(t.id), label: t.label })),
|
||||
{ selection: 'astrolabe', label: 'Astrolabe' },
|
||||
{ selection: 'fivethirtyeight', label: 'FiveThirtyEight' },
|
||||
{ selection: 'vox', label: 'Vox' },
|
||||
{ selection: 'dark', label: 'Vega Dark' },
|
||||
];
|
||||
const GALLERY = THEME_PREVIEW_SPECS.filter((s) => ['bar', 'line', 'scatter'].includes(s.id));
|
||||
|
||||
function ThemeDemo({ theme }: { theme: UiTheme }): ReactNode {
|
||||
const [selection, setSelection] = useState<ChartThemeSelection>(customThemeSelection(1));
|
||||
const config = useMemo(
|
||||
() => chartConfigForSelection(selection, theme, DEMO_CUSTOM_THEMES),
|
||||
[selection, theme],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.intent}>
|
||||
{THEME_CHOICES.map((choice) => (
|
||||
<button
|
||||
key={choice.selection}
|
||||
type="button"
|
||||
aria-pressed={selection === choice.selection}
|
||||
className={selection === choice.selection ? styles.intentOn : undefined}
|
||||
onClick={() => setSelection(choice.selection)}
|
||||
>
|
||||
{choice.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className={styles.gallery}>
|
||||
{GALLERY.map((ps) => (
|
||||
<div key={ps.id} className={styles.mini}>
|
||||
<LandingChart spec={ps.spec} config={config} className={styles.miniHost} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Page ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function Landing(): ReactNode {
|
||||
const [theme, setTheme] = useState<UiTheme>('light');
|
||||
|
||||
function toggleTheme(): void {
|
||||
const next = theme === 'dark' ? 'light' : 'dark';
|
||||
setTheme(next);
|
||||
document.documentElement.dataset.theme = next;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.landing}>
|
||||
<nav className={styles.nav}>
|
||||
<div className={`${styles.wrap} ${styles.navIn}`}>
|
||||
<Brand />
|
||||
<span className={styles.navSpacer} />
|
||||
<a className={styles.navLink} href="#author">
|
||||
Authoring
|
||||
</a>
|
||||
<a className={styles.navLink} href="#library">
|
||||
Library
|
||||
</a>
|
||||
<a className={styles.navLink} href="#theme">
|
||||
Theming
|
||||
</a>
|
||||
<button className={`${styles.btn} ${styles.btnSm}`} type="button" onClick={toggleTheme}>
|
||||
{theme === 'dark' ? 'Light' : 'Dark'}
|
||||
</button>
|
||||
<a className={`${styles.btn} ${styles.btnSm} ${styles.navCta}`} href="/app/">
|
||||
Open Astrolabe →
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<header className={styles.hero}>
|
||||
<div className={styles.wrap}>
|
||||
<h1>
|
||||
A home for your <b>Vega-Lite charts.</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 library — in the browser,
|
||||
with no account and no server.
|
||||
</p>
|
||||
<div className={styles.heroCta}>
|
||||
<a className={`${styles.btn} ${styles.btnPrimary}`} href="/app/">
|
||||
Open Astrolabe
|
||||
</a>
|
||||
<span className={styles.heroNote}>Free, no account, works offline</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className={styles.stage}>
|
||||
<div className={styles.wrap}>
|
||||
<HeroAppWindow theme={theme} />
|
||||
<p className={styles.stageHint}>
|
||||
Pick a snippet on the left — the editor and chart follow.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className={styles.wrap}>
|
||||
<section className={styles.cap} id="author">
|
||||
<div className={styles.capText}>
|
||||
<div className={styles.capEyebrow}>Two ways in</div>
|
||||
<h2 className={styles.capH}>
|
||||
Write the spec, or <b>build it by clicking.</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 a draft and a
|
||||
published version: edit the draft, publish when it's ready, revert when it isn't.
|
||||
</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:
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.shot}>
|
||||
<div className={styles.shotBar}>
|
||||
<span className={styles.dots}>
|
||||
<i />
|
||||
<i />
|
||||
<i />
|
||||
</span>{' '}
|
||||
Chart Builder · {SAMPLE_DATASET_NAME}
|
||||
</div>
|
||||
<div className={styles.shotBody}>
|
||||
<BuilderDemo theme={theme} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className={`${styles.cap} ${styles.capRev}`} id="library">
|
||||
<div className={styles.capText}>
|
||||
<div className={styles.capEyebrow}>One library</div>
|
||||
<h2 className={styles.capH}>
|
||||
Every chart you make <b>stays</b>, with the data behind it.
|
||||
</h2>
|
||||
<p className={styles.capP}>
|
||||
Store a dataset once and reference it by name from as many charts as you want. Rename
|
||||
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, sort, and duplicate as the collection
|
||||
grows.
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.shot}>
|
||||
<div className={styles.shotBar}>
|
||||
<span className={styles.dots}>
|
||||
<i />
|
||||
<i />
|
||||
<i />
|
||||
</span>{' '}
|
||||
Datasets
|
||||
</div>
|
||||
<div className={`${styles.shotBody} ${styles.shotBodyFlush}`}>
|
||||
<div className={styles.row}>
|
||||
<div className={styles.rowName}>
|
||||
sales-2025 <span className={styles.rowSub}>· CSV · 1,204 rows</span>
|
||||
</div>
|
||||
<div className={styles.rowMeta}>
|
||||
used by <span className={styles.chip}>4 snippets</span> · quarter, region,
|
||||
revenue, units
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.row}>
|
||||
<div className={styles.rowName}>
|
||||
cohorts <span className={styles.rowSub}>· JSON · 96 rows</span>
|
||||
</div>
|
||||
<div className={styles.rowMeta}>
|
||||
used by <span className={styles.chip}>1 snippet</span> · cohort, week, retained
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.row}>
|
||||
<div className={styles.rowName}>
|
||||
events <span className={styles.rowSub}>· URL · refreshed today</span>
|
||||
</div>
|
||||
<div className={styles.rowMeta}>
|
||||
used by <span className={styles.chip}>3 snippets</span> · day, type, count
|
||||
</div>
|
||||
</div>
|
||||
<div className={`${styles.row} ${styles.rowLast}`}>
|
||||
<div className={styles.rowName}>
|
||||
survey <span className={styles.rowSub}>· CSV · 512 rows</span>
|
||||
</div>
|
||||
<div className={styles.rowMeta}>
|
||||
used by <span className={styles.chip}>2 snippets</span> · score, segment
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className={styles.cap} 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:
|
||||
</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.
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.shot}>
|
||||
<div className={styles.shotBar}>
|
||||
<span className={styles.dots}>
|
||||
<i />
|
||||
<i />
|
||||
<i />
|
||||
</span>{' '}
|
||||
Theme Builder
|
||||
</div>
|
||||
<div className={styles.shotBody}>
|
||||
<ThemeDemo theme={theme} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className={`${styles.cap} ${styles.capRev}`} id="export">
|
||||
<div className={styles.capText}>
|
||||
<div className={styles.capEyebrow}>Exporting</div>
|
||||
<h2 className={styles.capH}>
|
||||
Save a chart in the <b>format you need.</b>
|
||||
</h2>
|
||||
<p className={styles.capP}>
|
||||
Export as a PNG at 1–3×, 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.
|
||||
</p>
|
||||
<p className={styles.capP}>
|
||||
Your whole workspace exports and re-imports as a single JSON file.
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.shot}>
|
||||
<div className={styles.shotBar}>
|
||||
<span className={styles.dots}>
|
||||
<i />
|
||||
<i />
|
||||
<i />
|
||||
</span>{' '}
|
||||
Export chart
|
||||
</div>
|
||||
<div className={styles.shotBody}>
|
||||
<div className={styles.exp}>
|
||||
<div className={styles.expRow}>
|
||||
<span className={styles.expLab}>Format</span>
|
||||
<span className={styles.grp}>
|
||||
<button type="button" className={styles.on}>
|
||||
PNG
|
||||
</button>
|
||||
<button type="button">SVG</button>
|
||||
<button type="button">Spec</button>
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.expRow}>
|
||||
<span className={styles.expLab}>Scale</span>
|
||||
<span className={styles.grp}>
|
||||
<button type="button">1×</button>
|
||||
<button type="button" className={styles.on}>
|
||||
2×
|
||||
</button>
|
||||
<button type="button">3×</button>
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.expRow}>
|
||||
<span className={styles.expLab}>Background</span>
|
||||
<span className={styles.grp}>
|
||||
<button type="button" className={styles.on}>
|
||||
Theme
|
||||
</button>
|
||||
<button type="button">White</button>
|
||||
<button type="button">None</button>
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.expRow}>
|
||||
<span className={styles.expLab}>Data</span>
|
||||
<span className={styles.grp}>
|
||||
<button type="button" className={styles.on}>
|
||||
Inline
|
||||
</button>
|
||||
<button type="button">Keep refs</button>
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.expDownload}>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.btn} ${styles.btnSm} ${styles.btnFill}`}
|
||||
>
|
||||
Download .png
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section className={styles.creed}>
|
||||
<div className={styles.wrap}>
|
||||
<h2>
|
||||
Your charts are <b>yours.</b>
|
||||
</h2>
|
||||
<p className={styles.creedLede}>
|
||||
Astrolabe runs in your browser and stores everything locally — no account, no server.
|
||||
What it produces is ordinary Vega-Lite JSON, so you can read it, edit it in other tools,
|
||||
or keep it long after you've stopped using Astrolabe.
|
||||
</p>
|
||||
<div className={styles.creedGrid}>
|
||||
<div>
|
||||
<span className={styles.creedKey}>private</span>
|
||||
<h3>Stays on your machine</h3>
|
||||
<p>
|
||||
Charts, data, and themes are saved in your browser. Install it and it works with no
|
||||
connection.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className={styles.creedKey}>portable</span>
|
||||
<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.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className={styles.creedKey}>yours</span>
|
||||
<h3>Set up your way</h3>
|
||||
<p>
|
||||
Two ways to author, your own fonts and themes, your own library. Arrange it to match
|
||||
how you work.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className={styles.close}>
|
||||
<div className={styles.wrap}>
|
||||
<h2>
|
||||
Open it and start a <b>chart.</b>
|
||||
</h2>
|
||||
<a className={`${styles.btn} ${styles.btnPrimary}`} href="/app/">
|
||||
Open Astrolabe
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer className={styles.footer}>
|
||||
<div className={`${styles.wrap} ${styles.footerIn}`}>
|
||||
<Brand />
|
||||
<span className={styles.footerSp} />
|
||||
<a href="/app/">Open the app</a>
|
||||
<a href="https://vega.github.io/vega-lite/">Built on Vega-Lite</a>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
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 styles from './Landing.module.css';
|
||||
|
||||
/**
|
||||
* Renders a Vega-Lite spec into the page using the app's real `renderSpec`
|
||||
* service. The renderer (and the heavy Vega chunk it pulls) is imported
|
||||
* dynamically inside the effect, so the landing's initial bundle stays light and
|
||||
* Vega loads only once a chart actually needs to draw.
|
||||
*
|
||||
* `prepareSpecForRender` is a pure core function (no Vega), so it's a static
|
||||
* import; only the render service is deferred. The host node is a plain div that
|
||||
* Vega owns — React never gives it children — with any error shown as a sibling.
|
||||
*/
|
||||
export function LandingChart({
|
||||
spec,
|
||||
config,
|
||||
className,
|
||||
}: {
|
||||
spec: Record<string, unknown>;
|
||||
config: Config;
|
||||
className?: string;
|
||||
}): ReactNode {
|
||||
const hostRef = useRef<HTMLDivElement>(null);
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let handle: { destroy(): void } | null = null;
|
||||
void (async () => {
|
||||
try {
|
||||
const { renderSpec } = await import('../app/services/chart-renderer');
|
||||
const node = hostRef.current;
|
||||
if (cancelled || !node) return;
|
||||
// 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' });
|
||||
handle = await renderSpec(node, prepared as VisualizationSpec, config);
|
||||
if (cancelled) {
|
||||
handle.destroy();
|
||||
handle = null;
|
||||
return;
|
||||
}
|
||||
setFailed(false);
|
||||
} catch {
|
||||
// The landing's specs are known-good; a failure here means the renderer
|
||||
// couldn't load (e.g. offline mid-session). Show a quiet note rather than
|
||||
// a blank box — the page is marketing, not the app's fail-loud surface.
|
||||
if (!cancelled) setFailed(true);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
handle?.destroy();
|
||||
handle = null;
|
||||
};
|
||||
}, [spec, config]);
|
||||
|
||||
return (
|
||||
<div className={className ?? styles.chartHost}>
|
||||
{/* Vega brands this node `.vega-embed` (display:inline-block), which
|
||||
shrink-wraps it and collapses width:"container"; an explicit width gives
|
||||
the responsive fit a definite box (see LivePreview.module.css). */}
|
||||
<div ref={hostRef} className={styles.chartNode} />
|
||||
{failed && <span className={styles.chartError}>Couldn't render this chart.</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* Bespoke chart themes for the landing's Theme Builder demo — looks that are
|
||||
* NOT in the built-in preset roster, to show what the Theme Builder produces
|
||||
* beyond the canned presets: custom palettes, custom fonts, and grid/background
|
||||
* styling.
|
||||
*
|
||||
* Each is shaped as the resolver expects (`Pick<CustomTheme, 'id' | 'config'>`),
|
||||
* so the demo applies them through the real
|
||||
* `chartConfigForSelection('custom:<id>', …)` path — exactly how a user's saved
|
||||
* theme is injected. Fonts are drawn from the chart-font roster (chart-fonts.css,
|
||||
* imported by the landing entry); the render path loads the faces before it
|
||||
* measures text. Vega-Lite's top-level `font` sets the default for all text; the
|
||||
* per-component `*Font` slots override it where a different face is wanted.
|
||||
*/
|
||||
|
||||
import type { JsonObject } from '@core/spec-config';
|
||||
|
||||
// Serif display titles over a serif body on warm paper — a magazine look.
|
||||
const EDITORIAL: JsonObject = {
|
||||
background: '#faf7f0',
|
||||
font: '"Spectral", Georgia, serif',
|
||||
title: {
|
||||
font: '"Playfair Display", Georgia, serif',
|
||||
fontSize: 18,
|
||||
fontWeight: 700,
|
||||
color: '#2b2419',
|
||||
subtitleFont: '"Spectral", Georgia, serif',
|
||||
subtitleColor: '#6b5d44',
|
||||
},
|
||||
axis: {
|
||||
labelFont: '"Spectral", Georgia, serif',
|
||||
titleFont: '"Spectral", Georgia, serif',
|
||||
labelColor: '#6b5d44',
|
||||
titleColor: '#3a3326',
|
||||
gridColor: '#e8dec9',
|
||||
domainColor: '#b8ab8a',
|
||||
tickColor: '#b8ab8a',
|
||||
},
|
||||
legend: {
|
||||
labelFont: '"Spectral", Georgia, serif',
|
||||
titleFont: '"Spectral", Georgia, serif',
|
||||
labelColor: '#6b5d44',
|
||||
titleColor: '#3a3326',
|
||||
},
|
||||
view: { stroke: 'transparent' },
|
||||
range: {
|
||||
category: ['#7a5c3e', '#b07d3e', '#c9a14a', '#5e6b3f', '#9a5a44', '#46544c'],
|
||||
ramp: ['#f0e6d2', '#b07d3e', '#5e3a1e'],
|
||||
},
|
||||
};
|
||||
|
||||
// Monospace, dashed grid, cool blues — a technical drawing.
|
||||
const BLUEPRINT: JsonObject = {
|
||||
background: '#f5f8fc',
|
||||
font: '"Space Mono", ui-monospace, monospace',
|
||||
title: {
|
||||
font: '"Space Mono", ui-monospace, monospace',
|
||||
fontSize: 13,
|
||||
fontWeight: 700,
|
||||
color: '#10314f',
|
||||
},
|
||||
axis: {
|
||||
labelFont: '"Space Mono", ui-monospace, monospace',
|
||||
titleFont: '"Space Mono", ui-monospace, monospace',
|
||||
labelColor: '#3a5a78',
|
||||
titleColor: '#10314f',
|
||||
grid: true,
|
||||
gridColor: '#cdd9e5',
|
||||
gridDash: [2, 2],
|
||||
domainColor: '#10314f',
|
||||
tickColor: '#10314f',
|
||||
},
|
||||
legend: {
|
||||
labelFont: '"Space Mono", ui-monospace, monospace',
|
||||
titleFont: '"Space Mono", ui-monospace, monospace',
|
||||
labelColor: '#3a5a78',
|
||||
titleColor: '#10314f',
|
||||
},
|
||||
view: { stroke: '#cdd9e5' },
|
||||
range: {
|
||||
category: ['#0d6fb8', '#3aa0d1', '#7cc4e0', '#0d3b66', '#5a8fb3', '#9ec9e0'],
|
||||
ramp: ['#e3eef7', '#3aa0d1', '#0d3b66'],
|
||||
},
|
||||
};
|
||||
|
||||
// A hot, saturated palette over warm white — vivid and modern.
|
||||
const SUNSET: JsonObject = {
|
||||
background: '#fff8f3',
|
||||
font: '"Inter", system-ui, sans-serif',
|
||||
title: {
|
||||
font: '"Inter", system-ui, sans-serif',
|
||||
fontSize: 15,
|
||||
fontWeight: 600,
|
||||
color: '#3a2233',
|
||||
},
|
||||
axis: {
|
||||
labelFont: '"Inter", system-ui, sans-serif',
|
||||
titleFont: '"Inter", system-ui, sans-serif',
|
||||
labelColor: '#7a5c4f',
|
||||
titleColor: '#3a2233',
|
||||
gridColor: '#f3e2d6',
|
||||
domainColor: '#d6a98f',
|
||||
tickColor: '#d6a98f',
|
||||
},
|
||||
legend: {
|
||||
labelFont: '"Inter", system-ui, sans-serif',
|
||||
titleFont: '"Inter", system-ui, sans-serif',
|
||||
labelColor: '#7a5c4f',
|
||||
titleColor: '#3a2233',
|
||||
},
|
||||
view: { stroke: 'transparent' },
|
||||
range: {
|
||||
category: ['#ff6b6b', '#f06595', '#cc5de8', '#845ef7', '#ff922b', '#fcc419'],
|
||||
ramp: ['#ffe3c9', '#ff922b', '#cc5de8'],
|
||||
},
|
||||
};
|
||||
|
||||
export interface DemoTheme {
|
||||
/** Selection id used with `chartConfigForSelection` (resolved as `custom:<id>`). */
|
||||
id: number;
|
||||
/** Picker label. */
|
||||
label: string;
|
||||
/** The Vega-Lite config injected when this theme is selected. */
|
||||
config: JsonObject;
|
||||
}
|
||||
|
||||
export const DEMO_CUSTOM_THEMES: ReadonlyArray<DemoTheme> = [
|
||||
{ id: 1, label: 'Editorial', config: EDITORIAL },
|
||||
{ id: 2, label: 'Blueprint', config: BLUEPRINT },
|
||||
{ id: 3, label: 'Sunset', config: SUNSET },
|
||||
];
|
||||
@@ -0,0 +1,13 @@
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { Landing } from './Landing';
|
||||
// Base styles pull in the design tokens and self-hosted IBM Plex, so the landing
|
||||
// shares the app's visual language. This entry deliberately wires nothing else:
|
||||
// no store hydration, no persistence, and — crucially — no service worker, so the
|
||||
// landing stays an uncontrolled, always-fresh page (the SW is scoped to /app/).
|
||||
import '../../styles/base.css';
|
||||
// The Theme Builder demo's custom themes use roster fonts (Playfair Display,
|
||||
// Spectral, Space Mono, Inter); their @font-face rules live here. unicode-range
|
||||
// means a woff2 downloads only when a glyph needs it, so this stays cheap.
|
||||
import '../../styles/chart-fonts.css';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(<Landing />);
|
||||
@@ -20,6 +20,13 @@ export default defineConfig({
|
||||
},
|
||||
build: {
|
||||
rollupOptions: {
|
||||
// Multi-page: the marketing landing is served at `/` (index.html → the
|
||||
// lightweight `src/landing` entry); the app at `/app/` (app/index.html →
|
||||
// src/main.tsx). The app is hash-routed, so it runs unchanged under /app/.
|
||||
input: {
|
||||
main: fileURLToPath(new URL('./index.html', import.meta.url)),
|
||||
app: fileURLToPath(new URL('./app/index.html', import.meta.url)),
|
||||
},
|
||||
output: {
|
||||
// Split the heavy vendors so the chunk graph stays legible and the PWA
|
||||
// can precache/update them independently of app code. Monaco and the
|
||||
@@ -35,10 +42,18 @@ export default defineConfig({
|
||||
react(),
|
||||
VitePWA({
|
||||
registerType: 'prompt',
|
||||
// The app lives at /app/; scope the service worker there so it never
|
||||
// controls the marketing landing at / (which stays uncontrolled and
|
||||
// always-fresh). A /sw.js narrowing its scope to /app/ needs no
|
||||
// Service-Worker-Allowed header — narrowing is always permitted.
|
||||
scope: '/app/',
|
||||
includeAssets: ['favicon.svg', 'icon-maskable.svg', 'icon-mono.svg', 'apple-touch-icon.png'],
|
||||
manifest: {
|
||||
id: '/app/',
|
||||
name: 'Astrolabe',
|
||||
short_name: 'Astrolabe',
|
||||
scope: '/app/',
|
||||
start_url: '/app/',
|
||||
description: 'A browser-based snippet manager for Vega-Lite visualizations.',
|
||||
// theme_color tints the OS/browser chrome; background_color is the splash
|
||||
// behind the icon. theme_color is the app accent (`--accent`, light theme);
|
||||
@@ -62,6 +77,9 @@ export default defineConfig({
|
||||
],
|
||||
},
|
||||
workbox: {
|
||||
// Client-side navigations under /app/ fall back to the app shell when
|
||||
// offline; the SW's /app/ scope means / (the landing) is never matched.
|
||||
navigateFallback: '/app/index.html',
|
||||
// Precache the app, plus the font woff2 the offline-first UI and the
|
||||
// common chart path need:
|
||||
// - the bare `latin` subset of every family (`*-latin-[0-9]*` — the
|
||||
|
||||
Reference in New Issue
Block a user