diff --git a/AGENTS.md b/AGENTS.md index d4673d8..0923d39 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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) diff --git a/app/index.html b/app/index.html new file mode 100644 index 0000000..2ca84bb --- /dev/null +++ b/app/index.html @@ -0,0 +1,15 @@ + + + + + + + + + Astrolabe + + +
+ + + diff --git a/index.html b/index.html index 2ca84bb..a598cb9 100644 --- a/index.html +++ b/index.html @@ -6,10 +6,10 @@ - Astrolabe + Astrolabe — a local Vega-Lite studio -
- +
+ diff --git a/src/core/sample-dataset.test.ts b/src/core/sample-dataset.test.ts new file mode 100644 index 0000000..526c332 --- /dev/null +++ b/src/core/sample-dataset.test.ts @@ -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); + } + }); +}); diff --git a/src/core/sample-dataset.ts b/src/core/sample-dataset.ts new file mode 100644 index 0000000..77d8f43 --- /dev/null +++ b/src/core/sample-dataset.ts @@ -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> = [ + { 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 } }, + ], +}; diff --git a/src/landing/Landing.module.css b/src/landing/Landing.module.css new file mode 100644 index 0000000..47ae816 --- /dev/null +++ b/src/landing/Landing.module.css @@ -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; + } +} diff --git a/src/landing/Landing.tsx b/src/landing/Landing.tsx new file mode 100644 index 0000000..e695474 --- /dev/null +++ b/src/landing/Landing.tsx @@ -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( + + {m[0]} + , + ); + last = idx + m[0].length; + } + if (last < text.length) out.push(text.slice(last)); + return
{out}
; +} + +/** 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 { + 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 ( + + {' '} + Astrolabe + + ); +} + +// ── 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 ( +
+
+ + + + + + + {CHART_EXAMPLES.length} snippets + live preview +
+
+
+
Library
+
Search snippets…
+
+ {CHART_EXAMPLES.map((ex, i) => ( + + ))} +
+
+
+
{specFilename(example.name)}
+ +
+
+
Preview
+
+ +
+
+
+
+ ); +} + +// ── 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 = { + 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(() => + 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 ( + <> +
+ {MARK_OPTIONS.map((m) => ( + + ))} +
+ + + +
+ {CHART_INTENTS.map((intent) => ( + + ))} +
+ + + ); +} + +// ── 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(customThemeSelection(1)); + const config = useMemo( + () => chartConfigForSelection(selection, theme, DEMO_CUSTOM_THEMES), + [selection, theme], + ); + + return ( + <> +
+ {THEME_CHOICES.map((choice) => ( + + ))} +
+
+ {GALLERY.map((ps) => ( +
+ +
+ ))} +
+ + ); +} + +// ── Page ───────────────────────────────────────────────────────────────────── + +export function Landing(): ReactNode { + const [theme, setTheme] = useState('light'); + + function toggleTheme(): void { + const next = theme === 'dark' ? 'light' : 'dark'; + setTheme(next); + document.documentElement.dataset.theme = next; + } + + return ( +
+ + +
+
+

+ A home for your Vega-Lite charts. +

+

+ 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. +

+
+ + Open Astrolabe + + Free, no account, works offline +
+
+
+ +
+
+ +

+ Pick a snippet on the left — the editor and chart follow. +

+
+
+ +
+
+
+
Two ways in
+

+ Write the spec, or build it by clicking. +

+

+ 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. +

+

+ 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: +

+
+
+
+ + + + + {' '} + Chart Builder · {SAMPLE_DATASET_NAME} +
+
+ +
+
+
+ +
+
+
One library
+

+ Every chart you make stays, with the data behind it. +

+

+ 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. +

+

+ 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. +

+
+
+
+ + + + + {' '} + Datasets +
+
+
+
+ sales-2025 · CSV · 1,204 rows +
+
+ used by 4 snippets · quarter, region, + revenue, units +
+
+
+
+ cohorts · JSON · 96 rows +
+
+ used by 1 snippet · cohort, week, retained +
+
+
+
+ events · URL · refreshed today +
+
+ used by 3 snippets · day, type, count +
+
+
+
+ survey · CSV · 512 rows +
+
+ used by 2 snippets · score, segment +
+
+
+
+
+ +
+
+
Make it yours
+

+ Give your charts a look of their own. +

+

+ 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. Save the theme and use it on any chart. +

+
+
+
+ + + + + {' '} + Theme Builder +
+
+ +
+
+
+ +
+
+
Exporting
+

+ Save a chart in the format you need. +

+

+ 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. +

+

+ Your whole workspace exports and re-imports as a single JSON file. +

+
+
+
+ + + + + {' '} + Export chart +
+
+
+
+ Format + + + + + +
+
+ Scale + + + + + +
+
+ Background + + + + + +
+
+ Data + + + + +
+
+ +
+
+
+
+
+
+ +
+
+

+ Your charts are yours. +

+

+ 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. +

+
+
+ private +

Stays on your machine

+

+ Charts, data, and themes are saved in your browser. Install it and it works with no + connection. +

+
+
+ portable +

Ordinary Vega-Lite

+

+ Export a single chart or the whole library as standard JSON and open it in any other + Vega tool. +

+
+
+ yours +

Set up your way

+

+ Two ways to author, your own fonts and themes, your own library. Arrange it to match + how you work. +

+
+
+
+
+ +
+
+

+ Open it and start a chart. +

+ + Open Astrolabe + +
+
+ + +
+ ); +} diff --git a/src/landing/LandingChart.tsx b/src/landing/LandingChart.tsx new file mode 100644 index 0000000..07d8ec5 --- /dev/null +++ b/src/landing/LandingChart.tsx @@ -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; + config: Config; + className?: string; +}): ReactNode { + const hostRef = useRef(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 ( +
+ {/* 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). */} +
+ {failed && Couldn't render this chart.} +
+ ); +} diff --git a/src/landing/demo-themes.ts b/src/landing/demo-themes.ts new file mode 100644 index 0000000..e7053fd --- /dev/null +++ b/src/landing/demo-themes.ts @@ -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`), + * so the demo applies them through the real + * `chartConfigForSelection('custom:', …)` 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: number; + /** Picker label. */ + label: string; + /** The Vega-Lite config injected when this theme is selected. */ + config: JsonObject; +} + +export const DEMO_CUSTOM_THEMES: ReadonlyArray = [ + { id: 1, label: 'Editorial', config: EDITORIAL }, + { id: 2, label: 'Blueprint', config: BLUEPRINT }, + { id: 3, label: 'Sunset', config: SUNSET }, +]; diff --git a/src/landing/main.tsx b/src/landing/main.tsx new file mode 100644 index 0000000..3ab500e --- /dev/null +++ b/src/landing/main.tsx @@ -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(); diff --git a/vite.config.ts b/vite.config.ts index d742af1..7227cda 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -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