Compare commits

...

13 Commits

41 changed files with 2828 additions and 137 deletions
+10
View File
@@ -54,6 +54,16 @@ Review all changes in scope. If changes span multiple patterns below, apply all
only within their module (including `as const` arrays that exist to derive a type) stay
unexported — `export type` the type, not its source array. Verify with Grep before
exporting "for future use"; the future caller can add the export.
- **Smell baseline** (Fowler, _Refactoring_ ch. 3 — judgement calls, never hard
violations; a documented project rule overrides, and skip anything eslint/Prettier
already enforces): mysterious name (rename — if no honest name comes, the design is
murky); data clumps (the same few params traveling together → one type); primitive
obsession (a string/number standing in for a domain concept); feature envy (a function
reaching into another module's data more than its own); repeated switches (the same
discriminant cascade at multiple sites → one shared map); message chains
(`a.b().c().d()` → hide the walk behind the first object); middle man (a layer that
only delegates → call the target directly). Duplication and speculative generality are
covered by the cleanup rules above.
5. **Styles and UI**: When altering CSS or layout, follow or generalize existing patterns
(CSS Modules + design tokens in `styles/tokens.css`) rather than writing from scratch. Don't
+3 -1
View File
@@ -126,7 +126,9 @@ that prevents data loss, or accessibility.
**If it must be built — what shape?** Line up the existing instances of the kind (modal,
store, service, hook, persistence path), name the canonical shape, list what to reuse, and
flag what the new work might make deletable. An abstraction is earned only by ≥ 2 call
sites that would use it today (deletion rule 3).
sites that would use it today (deletion rule 3). The deletion test settles suspected
pass-throughs: imagine the module deleted — if the complexity just vanishes, it was a
shallow wrapper; only if it reappears across its callers was it earning its keep.
No report scaffolding — these two answers are the output.
+19 -1
View File
@@ -46,7 +46,9 @@ port legacy code.
lazy-loads Vega, so `/` stays light. The PWA service worker and manifest are scoped to
`/app/`, leaving the landing uncontrolled and always-fresh. **`/learn/` is a second such
entry** (`src/learn/`) — the markdown-authored deep-dive section, under the same rules
(see architecture 11).
(see architecture 11). The landing depicts only shipped behavior: live demos run the
real core, and staged visuals (the editor still) mirror the app's actual strings —
CodeLens labels, validation messages — never invented UI.
See [`docs/architecture/`](docs/architecture/00-overview.md) for the patterns behind each
layer (state, persistence, modals, routing, rendering, inference, relationships) and
@@ -121,6 +123,12 @@ npm run format # Prettier
leads. When in doubt about existing behavior, read the spec. When you ship user-facing
behavior, update the matching spec section in the same session; if spec and app
disagree, the spec is stale — rewrite it deliberately, never drift silently.
- **Reproduce before theorizing** — on a nontrivial bug, first build a command that goes
red on the exact symptom (failing test, script, headless-browser driver) and is fast,
deterministic, and runnable unattended; minimize the repro, then hypothesize against it.
Reading code to build a theory before that command exists is the failure mode. Write the
regression test before the fix; tag temporary debug logs with a unique prefix
(e.g. `[DEBUG-x7]`) so cleanup is one grep.
- **Core-first** — for each feature, build the pure `src/core/` logic with tests before UI.
- **Session wrap-up** — when the user signals the session is wrapping, run the review
pass before any commit: `/doc-update` in-session first (flush unrecorded rationale),
@@ -147,6 +155,11 @@ Invoke with `/<name>` (defined in `.claude/skills/`):
Recurring findings become `docs/architecture/` rules and new `/alignment` checks.
- **`/release`** — bump version, update the changelog, prepare a git tag.
### Deployment
Push to `main` auto-deploys to **astrolabe-viz.com** (Cloudflare Pages, Git-connected).
Hosting, analytics posture, and distribution facts: **[docs/deployment.md](docs/deployment.md)**.
### Versioning
Simplified semver `0.x.y` (pre-1.0): minor for features/behavior, patch for fixes. Single
@@ -165,6 +178,11 @@ carries — platform branches, state transitions, config-path writes, render ser
not the strings it renders; if that logic is worth guarding, lift it into core/stores and
test it there.
Expected values come from an independent source of truth — a known-good literal, a worked
example, the spec — never recomputed the way the implementation computes them. A
tautological assertion (`expect(add(a, b)).toBe(a + b)`) passes by construction and can
never disagree with the code.
Component tests (happy-dom) share a harness shape: `createRoot` + `act` with
`IS_REACT_ACT_ENVIRONMENT = true` set at module level, stores reset in `beforeEach`, and
`vi.mock('../services/chart-renderer', …)` for anything that embeds a chart (vega-embed is
@@ -50,6 +50,18 @@ serializes the builder's no-datasets state only: an un-targeted builder open
picks a dataset itself when any exist, so the derived view immediately
self-corrects to the `dataset-build` form.
**One-shot action links are not view states.** A hash form that _requests an
action_ — `#example-<id>` (add that gallery example) and `#spec-<payload>` (add
the spec carried in the payload; `@core/spec-link` owns the base64url encoding,
shared with the learn pages that build such links) — stays out of the
`ViewState` union: each is parsed by its own function in `url-hash.ts`,
consumed once at startup (`orchestration/startup.ts`, after persistence wiring
so the created record write-throughs, before `startRouting`), and then
routing's settle step replaces the hash with the resulting view. Action links
never serialize back and never participate in Back/Forward; `parseHash`
degrades them like any unknown hash. Any future action link follows the same
shape rather than growing the view-state union.
### 1.2 The adapter: `infrastructure/url-hash.ts`
This is the only file that reads or writes `window.location` / `history`. It
@@ -576,6 +576,14 @@ It does two deterministic things, on a **deep copy** of the spec:
and **removes** `width`; Full sets both — see spec §04 → Fit-mode sizing),
recursing the same way.
The fit recursion has one Vega-Lite limit: **`"container"` sizing only works on
single and layered views** — facet children fall back with a warning (panel
widths become timing-dependent) and concat children fall back to pad-autosize
(axes overflow a fixed card). A surface that renders a composed spec at a fixed
size must ask for `fitMode: 'default'` and let the spec's declared per-view
`width`/`height` stand — the landing's `LandingChart` exposes this as a prop
for its composed demos.
This is _content_ preparation, not embedding, and it is fully covered by the
_Live Preview_ spec. The only invariant this doc cares about:
+5 -1
View File
@@ -42,7 +42,11 @@ Inside a `:::progression`, each `##` heading is a stage: heading → tab label,
the following fenced `vega-lite` block → spec. A `:::data` block names datasets once for the
whole lesson; specs reference them with `{ "data": { "name": … } }` and `injectDatasets`
merges the rows in at render time — so a shared dataset isn't repeated per stage, and the
source pane keeps a stage's grammar legible instead of burying it under data.
source pane keeps a stage's grammar legible instead of burying it under data. Every lesson
uses the `:::data` form (never per-stage inline rows), targets a misconception rather than
a chart type, and closes with a "take it further" prose beat that leans on the per-stage
"Open in Astrolabe" links — the roster and per-lesson briefs live in
`docs/exploration/lessons-roadmap.md`.
## The pipeline
+24
View File
@@ -0,0 +1,24 @@
# Deployment & Distribution
Operational facts that live nowhere in the code.
## Hosting
- **astrolabe-viz.com** runs on **Cloudflare Pages**, project `astrolabe-viz`,
Git-connected to the private GitHub repo `olehomelchenko/astrolabe`.
- **Push to `main` auto-builds and deploys** (`npm run build`; the post-build
`check-light-entries` gate runs as part of it). There are no GitHub Actions.
- Live since 2026-06-20.
- **No analytics.** The app ships no beacon or tracking of any kind; Cloudflare keeps
standard aggregate access logs as any host does. The About modal's privacy copy states
exactly this — keep them in agreement.
## Distribution posture
- **Free to use; the code is private.** Astrolabe is not open source. User-facing copy
(landing, About, learn) must never claim or imply otherwise — the open thing is the
_format_ (Vega-Lite JSON), and copy attributes openness to it deliberately.
- **Feedback channel** is the branded address `feedback@astrolabe-viz.com`
(`src/app/feedback.ts`), not a public issue tracker.
- **Release cadence**: unreleased pre-1.0; the first public release is `1.0.0`, cut on
the maintainer's signal (see `docs/IMPLEMENTATION-PLAN.md` and the `/release` skill).
@@ -0,0 +1,411 @@
# Engineering Review — 2026-07-02
> Point-in-time record. Full-project engineering review requested by the maintainer, with a
> specific question attached: _"as we work session to session, we may forget to look back
> and see the bigger picture — the end-of-session skill checks aim to mitigate it, but I'm
> not sure to what extent it is successful."_
>
> **Method:** five independent clean-context reviewers (code quality, test suite,
> documentation, build/tooling/delivery, cross-session coherence), each required to cite
> file/line/commit evidence, followed by an adversarial verification wave that reproduced
> every load-bearing claim (rebuilt the bundle, re-ran greps and git archaeology, hand-walked
> the flagged logic, re-checked the live site). Findings below are only those that survived
> verification; where a reviewer's number was wrong, the corrected number is used.
> Reviewed state: working tree at `c5e4c4c` plus the uncommitted spec-params/editor WIP.
## Verdict
The codebase itself is in excellent shape — the architecture contract holds under grep, not
just in prose, and the per-session review machinery demonstrably works at the session scale.
The problems are concentrated at two horizons the per-session view cannot see: **delivery**
(one measurable production defect: the marketing landing executes 1.3 MB gzipped of Monaco +
Vega it never uses) and **look-back**. The maintainer's fear is confirmed, mechanically: no
instrument — on-demand or scheduled — owns the whole-project view, so coherence work happens
only when a session happens to collide with it. (Maintainer clarification after the first
draft: the eng-council sweep and the ux-second-pass batch were designed as on-demand
session guardrails, not periodic instruments — which sharpens the finding rather than
softening it: the on-demand instruments have each fired once, and nothing at all runs on a
cadence.)
## Scorecard
| Dimension | Grade | One-line summary |
| ----------------------- | ----- | ------------------------------------------------------------------------------------- |
| Code quality | A | Zero `any`/suppressions, layering verifies by grep, error discipline is real |
| Test suite | A | 1,366 tests, uniform harness, deterministic; one committed coverage hole that matters |
| Documentation | A | Symbol-level accuracy at scale; drift is localized fossils plus one governance gap |
| Build / delivery | B | Strong local gates; landing bundle defect shipped because no gate measures output |
| Cross-session coherence | B | A for per-session machinery, D for look-back machinery |
## Critical findings (all independently verified)
### C1. The landing and /learn/ eagerly load and execute Monaco (940 kB gz) and Vega (285 kB gz)
Verified on the live site and reproduced from a clean build. `dist/index.html` modulepreloads
both chunks and the landing entry's static import graph executes them
(`main-*.js` ends with bare `import"./vega-*.js";import"./monaco-*.js"`). `/learn/` is worse:
both arrive as direct `<script type="module">` tags. Landing eager JS today: ~4.7 MB raw /
~1.30 MB gzip; the intended payload is ~255 kB raw / ~81 kB gzip — a 16× reduction available.
The source-level lazy-loading discipline is correct (`LandingChart.tsx` dynamic-imports
chart-renderer; nothing in `src/landing`/`src/learn` mentions Monaco). Two bundling-level
causes defeat it:
- **Preload-helper placement.** The object-form `manualChunks` in `vite.config.ts` leads
Rollup to emit Vite's `__vitePreload` helper _inside_ the `monaco` chunk;
`LandingChart-*.js` begins `import{_ as h}from"./monaco-*.js"`, so every chunk that uses
`import()` statically depends on all of Monaco. (Attribution to the object form
specifically is plausible-but-untested; the observed mechanism is fully reproduced.)
- **`vega-scale` edge.** `Landing.tsx``@core/vega-themes``theme-controls.ts:27`
`import { scheme } from 'vega-scale'` merges into the `vega` manual chunk, dragging all of
Vega into the landing's static graph. The comment at `theme-controls.ts:21-22` ("keeps the
umbrella vega out of core") is true at source level and defeated by chunking. The
`examples` chunk has a second, independent edge into the vega chunk.
**Fix:** break the `vega-scale` edge (lazy-import or move scheme resolution off the
landing-reachable path); switch to function-form `manualChunks` and confirm the helper lands
in a shared micro-chunk; and — the durable part — add a ~10-line post-build assertion that
`dist/index.html` and `dist/learn/index.html` reference neither `monaco-*` nor `vega-*`.
That assertion is the missing gate for this entire regression class.
### C2. The project's operating regime is recorded nowhere the machinery reads
The 2026-06-10 phase shift — spec follows code; docs/spec is no longer the authoritative
contract — appears in exactly one repo location: `docs/exploration/chart-builder-enhancement-scope.md:673`,
inside the directory whose charter says nothing there is kept current. Meanwhile CLAUDE.md
("authoritative behavioral specification… This is the contract"), AGENTS.md ("Spec is the
contract"), SOUL.md, arch 00 ("the spec wins"), and `.claude/skills/alignment/SKILL.md`
rules #3/#17 all still assert the old regime. Two reviewers independently converged on this,
and the verification wave reproduced the greps.
This is the worst coherence defect because it corrupts the corrective machinery itself: the
wrap-up protocol's whole design is clean-context subagents judging against the recorded
contract — and the recorded contract is wrong. **Fix:** one paragraph in CLAUDE.md/AGENTS.md
stating the regime (spec is descriptive, kept current with code; on conflict fix the spec),
softened phrasing in `docs/spec/README.md` and arch 00, and an update to the alignment skill.
### C3. The spec stopped absorbing new behavior at a verifiable cutover (2026-06-21 → 06-25)
Adjudicated timeline, commit-dated: through `d76a7a5` (06-21) every feature landed with its
spec section in the same commit (Theme Builder, FontAsset, data inspector, onboarding, URL
datasets — all specified at full fidelity). From `c19857b` (06-25) onward the discipline
inverted: 12 of 13 feature commits through 06-30 updated `docs/architecture/` in-commit and
**zero** touched `docs/spec/`. Unspecified user-facing surfaces at HEAD:
- the entire **/learn/** section (`grep -ril learn docs/spec/` → nothing);
- the **composition wireframe** (drag reorder / pull-out / stack / Simplify — zero matches);
- **editor transform actions and CodeLens scaffolds** (wrap/simplify/add-view, add-transform);
- the **multi-view Data Inspector view picker** (`docs/spec/04-live-preview.md:84` references
"the chosen view's table" — the chooser it refers to is specified nowhere);
- the current uncommitted params-scaffolding work continues the pattern (arch 08 only).
Even under the spec-follows-code regime this is debt: the "then amend the spec to match"
half of the bargain has not happened for ten days of features. Root cause is structural, not
negligence: `/doc-update` flushes what a session remembers and `/alignment` reviews diffs —
no instrument owns "the spec describes the product." Alignment rule #17 covers removed/moved
surfaces only, not never-specified additions.
## Significant findings
### S1. No instrument owns the big picture on any cadence
- **Eng-council sweep:** once, 2026-06-12 (`0e225d7`). Since then non-test source grew
~**+76%** (16.8k → 29.7k ts/tsx LOC; ~21.3k → ~37k all-source). `docs/codebase-metrics.md`,
whose stated purpose is the trend, holds two same-day rows. Specific unswept accretion: the
six-module `spec-*` service family plus `editor-snippet`/`editor-cursor-lens` (~1,900 LOC,
mostly post-sweep) — which alignment rule #16 already notes "has grown by copy-paste twice."
- **Batched council pass over `docs/ux-second-pass.md`:** once, 2026-06-13 (`92bfe88`,
76→25 lines — the design worked). The file has regrown 25→48; oldest open item is from
06-16.
- **TODO gardening:** never. Lifecycle is bimodal — 56 added / 43 removed over history
(~7177% resolution, usually within 03 days, at least 10 by dedicated commits), but every
TODO that survived its first week is still alive. Oldest two are from 06-12:
`src/app/modals/ModalCoordinator.ts:36` (a **latent init-ordering bug**, not polish) and
`ChartBuilderModal.tsx:524`. The open set is increasingly the hard/ambiguous residue no
session claims.
**Fix direction (maintainer to ratify):** give each instrument a trigger — re-sweep when
source LOC grows ~25% past the last metrics row; batch council pass when ux-second-pass open
items exceed a count or age past two weeks; a spec-reconciliation clause in `/doc-update`
("did this session add user-facing behavior? name the spec section or write it"); TODO
gardening as a standing sweep agenda item.
### S2. The scaffold insertion math is untested, duplicated, and validity-critical
Verified: `runAddTransform` (`spec-transform-scaffold.ts:130-146`, committed `c5e4c4c`) and
`runAddParam` (`spec-param-scaffold.ts:109-142`, WIP) compute insert offsets, indentation,
and the trailing-comma decision; a bug here writes **invalid JSON into the user's editor**.
No test exercises the composition — no `SpecEditor.test.tsx` exists, so there is no indirect
path either. The create-property block is a verbatim 7-line duplicate between the two files;
the completion providers share ~35 structurally identical lines. Hand-walking the current
logic found **no live bug** — this is a coverage hole, not a defect — but a future inversion
of the ternary or offset drift ships silently. The repo's own TODOs
(`spec-param-scaffold.ts:33`, `spec-params.test.ts:6`) already point at the fix: extract a
neutral core module (working name `spec-snippet`) computing
`(text, host offset, existing-array?) → {insertOffset, snippetText}` and table-test it over
empty-host / host-with-following-key / compact-one-line / host-as-last-property.
Related: `spec-transform-actions.ts` (663 lines, largest service, no test file) carries pure
unexported helpers with real branching — `reindent`, `defaultFacet`, `defaultRepeat` (:106,
:123, :136) — one layer short of tested core, mitigated by `buildNext` being a thin
dispatcher over tested core wrappers.
### S3. Documentation drift cluster (6/6 claims verified)
All in otherwise highly accurate docs; each is a fossil a maintainer would act on:
1. **5 MB budget fossil.** `docs/spec/09-data-model.md` §E and `08-import-export.md` describe
a budget-with-fill-warnings storage monitor that spec 02/10, arch 02 §6, and the code all
contradict (the monitor is a composition breakdown; the only 5 MB logic is the import
pre-check in `transfer.ts:42`). Arch 02:445's "80% threshold" Do-line contradicts the same
doc's line 425 and matches nothing in code.
2. **"Not installable" is false.** Arch 10:625 claims the manifest ships no icons; four icons
ship (`vite.config.ts:85-89`, files in `public/`), and manual-verification actively tests
install.
3. **The ajv layer was never built.** Arch 08 presents it as planned-M2 and claims "we
deliberately do better: map ajv errors to editor positions" — no such module exists; ajv
isn't even in `package.json`. The current WIP on arch 08 does not touch this.
4. **Wrong store name in three docs.** Arch 01/03/04 name `useSettingsPopoverStore` /
`openSettingsPopover`; the code is `usePopoverStore` / `openPopover`
(`PopoverStore.ts:22,30`, called from `EventRouter.ts:96`). Grep by the documented name
finds nothing.
5. **Arch 03's "closed union" is stale.** Five modals listed; `modals/types.ts` has six
(`themeBuilder`). Same doc's add-a-modal checklist instructs `hasError`/`getError` on
`ModalConfig` — fields the real type doesn't have; following it fails typecheck.
6. **Spec 09's UserSettings omits `ui.dataInspectorOpen`/`ui.dataInspectorHeight`**, which
spec 04 mandates and `settings-store.ts:158-183` persists (they're also absent from
`core/settings.ts`'s type — the code half of the same gap).
### S4. Delivery gates never observe build output, and caching is misconfigured
- All quality gates live in the (excellent) pre-commit hook — lint-staged + full typecheck +
the whole test suite — but nothing anywhere runs or inspects `vite build`. Cloudflare's
build fails safe on compile errors (previous deploy stays live) but silently; the escape
class is green-but-wrong output, which is exactly how C1 shipped. Cheapest durable fix: the
C1 post-build assertion in the `build` script; optionally a minimal CI workflow
(typecheck + lint + test + build + assertion) to cover `--no-verify` and web edits.
- Hashed `/assets/*` are served `cache-control: max-age=14400, must-revalidate` (Cloudflare
Pages default; no `public/_headers` exists). Content-hashed files are the textbook
`max-age=31536000, immutable` case; today every returning visitor revalidates a 3.6 MB
chunk every 4 hours. HTML cache headers are correct.
### S5. Module-size outliers in the chart builder
`src/core/chart-builder.ts` (1,851 lines: catalog + rules + smart defaults + assembly/parse)
and `ChartBuilderModal.tsx` (1,653 lines, ~25 private subcomponents; 2.1× the next-largest
component). Internally cohesive, well-documented — maintainability risk, not defect. Split on
next substantive touch (`chart-builder/{catalog,rules,defaults,assemble}.ts`; hoist the
modal's sections into sibling files).
### S6. Smaller verified items
- `noUncheckedIndexedAccess` is off in a parser-heavy codebase (jsonc-parser node walking in
`spec-params.ts`, `spec-cursor.ts`, `spec-insert.ts`) that is exactly its target class.
Enable and sweep; if parser noise proves disproportionate, record the rejection.
- `ajv` is imported by `spec-params.test.ts:1` but undeclared — a phantom transitive
dependency; add it to devDependencies (found during verification).
- `npm audit`: monaco 0.54.0 bundles a vulnerable DOMPurify (moderate; fix is 0.55.1, flagged
breaking — plan deliberately); esbuild advisory is dev-only/Windows-only.
- Landing/learn light-dark toggle duplicated since 06-25, TODO'd in both copies and reworded
06-28 instead of extracted — a breadcrumb treated as the deliverable.
## Minor findings
- Per-editor CodeLens providers register on the global `'json'` language
(`editor-cursor-lens.ts:83`) while closing over one editor's cursor — safe with today's
single editor (verified: one `monaco.editor.create`), latent cross-wiring if a second JSON
editor appears. One guard line: `if (model !== editor.getModel()) return`.
- One avoidable non-null assertion in the WIP: `spec-transform-scaffold.ts:176`.
- `navigator.platform` (deprecated) in `EventRouter.ts:38`.
- Hooks are untested as a class; `useFocusTrap` (a11y-load-bearing, shared by all overlays)
is the one worth a cheap happy-dom test. `ModalCoordinator`'s open/close/replace sequencing
likewise (~5 tests).
- No coverage reporting exists; for a philosophy that is explicitly proportional ("core
hardest"), nothing measures the proportion. `@vitest/coverage-v8` scoped to
`src/core` + `src/app/{stores,services}`, no thresholds, visibility only.
- localStorage write failures are console-only (`settings-store.ts:80`, `ux-prefs.ts:80`) —
judged an acceptable, explicit low-stakes swallow; noted so the asymmetry with the
IndexedDB path stays a decision.
- `/learn/` is reachable only from the landing; nothing in the app links it. Deliberate or
forgotten is unrecorded — which is itself the gap.
- Doc/housekeeping nits: pre-commit hook is stronger than AGENTS.md documents (says eslint;
runs typecheck + tests too — drift in the good direction); PWA precache carries ~150200 kB
of landing/learn assets the `/app/`-scoped SW can never serve; spec 00 still calls settings
a modal; spec 02 overstates the "imported" tag (only foreign/older shapes are tagged — spec
08 has it right); `docs/embedding-vega-lite.md` (447 lines) is orphaned — index it with a
charter line or move it; `core/settings.ts:64` and `HeaderControls.tsx:2` cite the old
pre-move path of the theming scope doc; IMPLEMENTATION-PLAN files a completed item under
"Next"; empty `ops/` directory; `vega-themes` pinned exact with no recorded rationale;
manual-verification.md lacks checks for the shipped theming/export surface (Theme Builder
gallery, font upload incl. variable fonts, PNG/SVG export, clipboard).
## Strengths (verified, and worth keeping deliberate)
1. **The architecture contract verifies by grep, both directions.** No browser APIs, React,
or Monaco in `src/core/`; storage APIs confined to `infrastructure/`; landing/learn import
no stores/orchestration/components.
2. **TypeScript rigor at the top of the distribution.** Zero `any`, zero
`@ts-ignore`/`@ts-expect-error`, exactly one non-null assertion across ~30k lines;
type-aware ESLint (incl. `no-floating-promises`) passes clean; every intentionally
unawaited promise is an explicit `void` with a comment.
3. **Error-handling discipline is real.** Every swallowed catch inspected carries a rationale
naming the sanctioned fallback; real failures route to notifications with actionable copy;
quota errors are normalized and surfaced.
4. **Concurrency/resource engineering above typical app code.** `LivePreview`'s generation
token + render mutex (with reasoning written down), full Monaco disposable cleanup, the
finalize-before-unsubscribe edge handled in chart-renderer.
5. **The test suite is engineered, not accumulated.** 1,366 tests / 8.5s, zero snowflake
harnesses across all 17 component test files, injected clocks and fake timers keyed to
exported constants, fresh `IDBFactory` per test (including interrupted-upgrade
self-healing), tests that assert contracts with the why inline
(`spec-refs.test.ts`, `chart-renderer.test.ts`).
6. **Session-scale coherence is solved.** All 12 stores share one canonical shape and cite
their canonical sibling in doc comments; modals go through one registry/coordinator/shell;
subtraction happens (`PreviewStore` deleted when obsoleted). The "written by strangers"
failure mode is absent.
7. **The breadcrumb→fix pipeline works.** At least 10 TODOs resolved by dedicated commits
(quota rollback, hash routing, splitter ARIA, field-name escaping…); the eng-council→law
loop is real (sweep findings became alignment rules #15#17; skills keep evolving).
8. **Docs are accurate at symbol level at scale** — of ~35 spot-checked claims (constants,
record shapes, hash grammar, keyboard maps, API surfaces), nearly all exact — and the
"shipped divergence" fencing discipline keeps pedagogical sketches from lying.
9. **PWA configuration is best-practice**: `registerType: 'prompt'` properly consumed with a
durable update toast, SW scope narrowed to `/app/`, sophisticated font precache strategy
with reasoning in the config.
## Recommended sequence
1. **Record the regime** (C2): CLAUDE.md/AGENTS.md paragraph + alignment-skill update +
soften spec README/arch 00. Smallest fix, unblocks every future clean-context review.
_Done same day: CLAUDE.md, AGENTS.md, SOUL.md, spec README, and alignment rules #3/#17
now state the spec-follows-code regime; #17 extended to cover never-specified additions._
2. **Fix the landing bundle and add the post-build assertion** (C1): the only
production-visible defect, and the assertion closes the gate gap (S4) for free. Add
`public/_headers` for immutable assets while in there.
_Done same day: `schemeColors` split into `core/scheme-colors.ts`; function-form
`manualChunks` with explicit homes for the preload helper and the shared light packages
(vega-themes, vega-expression, vega-util, json-stringify-pretty-compact — Rollup was
absorbing each into the nearest vendor chunk); `scripts/check-light-entries.mjs` gates
every build; `public/_headers` ships immutable caching. Landing eager JS measured
~98 kB gzipped, down from ~1,300 kB; all three entries smoke-tested on the production
build (charts + Monaco render, zero console errors)._
3. **Spec back-fill sprint** (C3): specify /learn/, the wireframe, editor actions/scaffolds,
and the view picker; add the spec-reconciliation clause to `/doc-update`.
4. **Give the periodic instruments triggers** (S1) and run the overdue ones: an eng-council
re-sweep (which naturally absorbs S5's splits and the `spec-*` family consolidation), a
ux-second-pass batch pass, and TODO gardening — starting with `ModalCoordinator.ts:36`.
5. **Extract and test the scaffold insertion core** (S2) before the params WIP is committed —
the TODOs already name the module.
6. **Doc drift batch** (S3 + minors): one session, mostly deletions of fossils.
---
## Addendum: subtraction audit (same day)
Follow-up requested by the maintainer: how much of the codebase is duplication, boilerplate,
or unnecessary wrapping — with the explicit instruction that **deliberate architecture layers
are not exempt**; "less LoC = less surface for bugs" outranks ceremony. Two auditors (a
jscpd-quantified duplication pass and a wrapper/indirection pass with the contract layers on
trial); the largest verbatim-copy claims were independently re-diffed before inclusion.
### The numbers
- **Production duplication is low: ~0.9%** by jscpd at min-tokens 50 (268 duplicated lines
over ~29.5k production TS/TSX; roughly double counting renamed semantic twins). Test-file
duplication is ~5.4% but is the documented harness convention — benign. CSS Modules are
the highest-duplication format at 5.4% and deserve one deliberate pass.
- **Pure ceremony is ~1.5% of non-test LoC.** The plumbing layers (infrastructure +
orchestration + modals + hooks, ~2.8k lines, ~9% of src) are overwhelmingly load-bearing:
`db.ts`'s promise wrapping/self-healing/quota normalization, `url-hash.ts`'s total-parse
grammar, `remote-data.ts`'s size-cap and error classification, the modal coordinator's
snapshot/confirm/URL lifecycle all earn their lines on inspection. No generic machinery
with a single instantiation was found — `editor-cursor-lens` has three real consumers,
`wireEntityWriteThrough` four, and every modal-registry field varies across entries.
- **Confidently deletable today, behavior identical: ~450500 LoC**, plus ~100150 more
behind design decisions.
The diagnosis, verbatim from the audit because it generalizes: **the abstractions are
right; the residue is photocopied instantiation files and hand-unrolled adapter pairs that
the abstractions should have absorbed.** Nothing calls for architectural change — only
tidying inside the architecture. Notably, one suspicion from the main review was stale: the
orchestration wirers were already consolidated behind `wireEntityWriteThrough`; the leftover
per-entity files are the residue that consolidation should have deleted.
### The cut list (verified; ranked by value)
Do right away — all mechanical, most protected by existing tests:
1. **Per-preference persistence chain** (~90110 LoC; the compounding win). Five
near-identical load/save pairs in `settings-store.ts:116-184`, four identical 10-line
init/wire pairs in `orchestration/preferences.ts`, same shape again in
`orchestration/settings.ts`/`snippet-sort.ts`, ten sequential calls in `main.tsx`.
Collapse: a `uiSlicePref(key, validate, fallback)` adapter factory + a
`wireStorePref(store, selector, save)` orchestration helper. Every future preference then
costs ~8 lines instead of ~30 across four files. `theme.ts` (FOUC ordering) and
`panes.ts` (debounce) stay bespoke — they carry real variation.
2. **Entity-adapter photocopies** (~5560 LoC). `snippet-store.ts` / `dataset-store.ts` /
`theme-store.ts` / `font-store.ts` are the identical 28-line load/save/delete triple
differing only in store name, migrate fn, and version constant (re-diffed: confirmed).
Collapse: `makeEntityAdapter<T>(storeName, version, migrate)` in `db.ts` + four 3-line
instantiations. Migration files stay — real per-entity logic.
3. **Wirer residue files** (~55 LoC). `dataset-persistence.ts` / `theme-persistence.ts` /
`font-persistence.ts` are 25-line modules whose body is one 6-line
`wireEntityWriteThrough` call. Move the calls into `entity-persistence.ts` or
`startup.ts` (their only caller). `snippet-persistence.ts` stays — the debounced
autosave is real logic.
4. **`editor.addAction` ceremony** (~55 LoC). Thirteen copies of the same 6-line
registration object across `spec-transform-actions.ts:524-585` and
`spec-config-actions.ts:210-235` → one table + `.map(addAction)`.
5. **Scaffold-twin merge** (~4050 LoC; overlaps main-review S2 and shares its fix). The
completion prologue, suggestion mapping, and create-property block are verbatim between
`spec-transform-scaffold.ts` and `spec-param-scaffold.ts`. Collapse into
`editor-snippet.ts` helpers; the pure indent math moves to the TODO'd core module, where
it also becomes testable.
6. **Core jsonc-helper copies** (~2530 LoC). `objectKeys` byte-identical between
`spec-params.ts:85-93` and `spec-data-transforms.ts:103-111` (re-diffed: confirmed);
`paramsArrayNode`/`transformArrayNode` identical modulo key string; two more shared
shapes. Extract/share — both sides have strong tests.
7. **localStorage adapter shell** (~28 LoC). `readRaw`/`writeRaw`/`available` identical
between `settings-store.ts` and `ux-prefs.ts` modulo type name and log tag (re-diffed:
confirmed) → `jsonLocalRecord<T>(key, tag)` in the same layer.
8. **Batch of micro-cuts** (~90 LoC): `UrlStateSync`'s argument-ignoring
`syncModalToUrl`/`clearModalFromUrl` wrappers (delete when fixing the
`ModalCoordinator.ts:36` ordering TODO they paper over); `DatasetStore`'s
thrice-repeated save epilogue; `readTextFile`/`readBinaryFile` 1:1 renames of `File`
methods; `initPersistentStorage` pass-through; `modals/types.ts` folded into the
registry; `SpecEditor`'s seven subscribe/dispose pairs → array; the landing's four-times
repeated `shot` block + TODO'd `UiTheme` redeclaration; `Icon.tsx`'s four-times copied
panel frame; one dead CSS rule (`ChartBuilderModal.module.css:213`).
Needs design thought (~100150 LoC more): the splitter triple (`ResizeHandle` /
`PaneSplitHandle` / `InspectorSplitHandle` share a near-verbatim `role="separator"` block —
worthwhile but untested a11y surface, do with manual keyboard verification); the theme-panel
Color+Size+Weight row groups (60100 LoC vs greppability of accessible names); the modal
master-detail CSS block; the landing/learn theme-toggle hook (blocked on where a shared
React hook may live under the entry-isolation rule — needs a maintainer call).
Adjudicated and acquitted (challenged per the widened mandate, earn their lines): the modal
registry/coordinator/shell trio (every registry field varies; flattest honest version saves
only ~35 lines); the Zustand store shapes (a generic collection-slice helper would cost more
in typing than the ~60 lines it saves and obscure real per-store invariants); core purity
shims (minimal — the altitude problem runs the other way, app services holding pure logic);
the non-photocopy infrastructure adapters (real normalization, migration, error mapping).
### Wrap-up follow-ups (recorded from the params-feature review passes, same day)
The params WIP was closed through the full wrap-up protocol (alignment + eng-council, both
clean-context). Fixed in that pass: an `isUnitNode` bug (nested layer containers offered
schema-invalid selection params), the S2 extraction (`core/spec-snippet.ts`, insertion math
now table-tested applied-and-reparsed), spec §03 scaffolding coverage, the transforms
catalog upgraded to real-schema validation, `ajv` declared. Deferred with recorded homes:
- `src/core/lesson-parse.ts` exports five unused types (knip) — verify and unexport. Same
check for `LensFactory` in `editor-cursor-lens.ts:24` (exported, consumed only in-module).
- `spec-transform-actions.ts` has an internal ~8-line clone (~:269-276 vs ~:346-353) —
fold into the existing 663-LOC consolidation item.
- The Ajv compile boilerplate now lives in two test files; a third schema-validating
catalog test should extract a shared helper — and at that point promote the rule
"seeded editor catalogs validate against the bundled VL schema, not just JSON.parse"
to an `/alignment` check.
- A small knip config (ignore `@fontsource/*`, `marked` false positives) would make
future dead-export sweeps one command.
@@ -0,0 +1,177 @@
# Landing & Onboarding Scope
_Point-in-time scope memo, 2026-07-04. Consolidates the onboarding/landing review: audience
model, claims audit, objection map, target landing architecture, and the implementation
phases. Supersedes nothing; feeds the next landing/onboarding sessions._
_Status (2026-07-04, same session): **Phases 1 and 2 shipped** — deep links, paste door,
learn links, brushed-scatter example, and the full landing overhaul (showcase block,
editor proof, objection beats, reweighted blocks, trust creed, voice pass). Phase 3
(learn growth) remains. Gotcha recorded at the code site: faceted/concat specs need
`fitMode: 'default'` in `LandingChart` — the width-fit contract can't size their
children._
## Audience & thesis
The goal of the public surfaces is to **popularize Vega-Lite's capabilities**, not to serve
a niche. Two audiences, one page, two pitches:
- **Beginners / the declarative-curious** — sold on **Vega-Lite itself**: charts written as
text, interactive by declaration, beautiful out of the box (something users rarely have
time to achieve themselves). Their doors: the Chart Builder, examples, `/learn/`.
- **Practitioners** (already write specs, often via wrappers) — sold on **the editor**: a
home for specs (vs. the Vega editor's scratchpad), schema-aware Monaco, dataset library,
themes/fonts, export parameters the vega-embed kebab menu never offers.
The surface stays **general-purpose**: learners are welcome underneath, but nothing reads
as a teaching tool or classroom product. Blocks alternate between the two pitches; the
strongest moves serve both at once (a themed, interactive chart sells VL capability to the
beginner and the theming machinery to the practitioner in the same pixels).
Hero-copy consequence: "A home for your Vega-Lite charts" addresses only people who
already have Vega-Lite charts. The headline must admit the beginner too — positive case
first (charts as text: interactive, themeable, durable), "home for them" as the second
beat.
## Claims audit (2026-07-04)
Every claim on the current landing verifies against the code — no overclaims. The page
**underclaims**: shipped capabilities absent from it, in order of missed leverage:
1. **Interactivity** — the page contains zero interactive charts (live-rendered, yes;
interactive, no), while interactivity is VL's headline capability for popularization.
2. **Composition wireframe** — drag-editable multi-view editing; unique in the Vega
ecosystem; unmentioned.
3. **Data inspector** — input vs. resolved rows per view; the answer to "why is my chart
empty"; unmentioned.
4. **CodeLens scaffolds** — one-click working params/transforms/view blocks; the bridge
feature (beginners get working code to study, practitioners get speed); unmentioned.
5. **Theme Builder breadth** — landing lists colour/type/axes/legend/layout; it also does
marks, titles, number formats.
6. **`/learn/`** — a capability, currently only a nav link.
Nitpick: "Sixteen presets" counts "Stock Vega-Lite (no theme)" as a preset.
Page-wide visual finding: nearly every chart renders in default blue. The page's imagery
_is_ its charts; they must carry themed variety — the page itself is the proof of
"out-of-the-box beauty without the time investment".
Pacing findings (desktop 1440, light): theme block ≈ a quarter of total scroll (three
tall charts stacked); builder demo's default state is the most boring chart it can produce
(count-by-channel, plain blue); datasets — the core "home, not scratchpad" argument — gets
the weakest visual (small static mock); export gets a full peer block for what is partly
table-stakes; the hero app window reads as a screenshot (nothing signals it is live).
## Objection map
Objections cluster two ways; each gets **one compact moment** on the page, not a FAQ
sprawl. The best answers are either **on-ramps** or **stances stated before suspicion
forms**.
**Habit cluster** — "I already have a way" — one positioning block near the editor
section:
- _Vega editor?_ A scratchpad, not a home. (One-slot, no library, no fonts/themes.)
- _Altair / wrappers?_ Their output **is** a Vega-Lite spec — paste it in, polish, keep.
Most real-world VL usage is via Altair; this is the largest single objection. Nobody
hand-writes specs from a blank buffer, and Astrolabe never asks them to (examples,
builder, paste + autocomplete/scaffolds/inspector). Last-mile polish (label exprs, axis
formats) is often faster in the spec than translated back through a wrapper API.
- _LLMs write specs?_ Yes — paste it here; this is where an almost-right spec gets
diagnosed (preview, validation, inspector). AI-free is a privacy feature: the model
never sees the real data.
**Trust cluster** — generated by the local-only stance itself — lives at/near the creed:
- _Local = fragile?_ The library exports as one JSON file; back it up like any file you
own. Copy must stay on export/import — never imply sync (none exists).
- _Closed source, so why believe "no data leaves"?_ Falsifiable claim instead: static
site, no backend, works fully offline once installed — airplane mode is the audit.
(Never claim open source.)
- _Solo project longevity?_ Lock-in-free is the honest answer: everything is ordinary
Vega-Lite JSON; specs outlive the tool; the PWA keeps working offline.
- _Sharing?_ Exports are the sharing story; spec-with-data-inlined is quietly the share
feature and should be framed as one.
Beginner-adjacent beat (one line, no comparison table): vs. Datawrapper/Flourish — no
account, no hosting dependency, real interactivity, a growing library you own.
**Deliberately not addressed on the landing**: storage limits / huge datasets (real
constraint, edge concern; the in-app storage monitor is the right surface — raising it in
marketing plants a worry most visitors never had).
## Target landing architecture
1. **Hero + app window** — dual-audience headline; default example themed and
interactive; explicit "this is the real app — try it" affordance; window's snippets
deep-link into the app.
2. **"What Vega-Lite can do"** — NEW, the popularization centerpiece: 23 interactive
charts (tooltip; brush → linked filter; a facet) in distinct themes, each beside its
short spec. Argument: _this is a text file._
3. **"A serious editor"** — practitioner proof: autocomplete/validation/inline docs
shown (staged, NOT real Monaco — the post-build gate keeps Monaco off the landing),
CodeLens scaffold shown, data inspector named. Positioning block (habit cluster)
attaches here.
4. **"Don't want to start from JSON?"** — builder, explicitly the second door; demo
defaults to a colourful non-trivial state (an intent applied, colour encoding on).
5. **"One library"** — datasets promoted and made vivid (extract-inline-data
before/after is the honest demo).
6. **"Make it yours"** — half current height: one chart + theme switcher, gallery as a
compact grid, fonts in the first sentence.
7. **"Get it out the way you need it"** — export reframed as practitioner pain relief
(parameters the vega-embed kebab menu lacks), compact; inline-data export framed as
sharing.
8. **Creed** — plus the trust cluster (backup/export, offline-as-proof, portability-as-
longevity) → learn pointer → close.
Verify on mobile and dark before committing layout.
## App-side changes
- **Deep links in**: `#build` already exists in the hash grammar — the landing builder
demo can link today. Add `#example-<id>` (consumed once at startup: create that
example as a snippet, open it, clear the param) so landing demos hand off momentum
instead of resetting at the onboarding canvas. Spec §01 hash table updates with it.
- **Onboarding canvas third door — "Paste a spec"**: serves Altair users, LLM users,
Vega-editor migrants; today they must Create → select-all → delete → paste. Also a
quiet "Restoring from an export? Import your workspace" line (Import is header-icon-only
during onboarding). Spec §02 updates.
- **Example gallery showpieces**: add 12 interactive/composed examples (brushable
scatter + linked histogram; ties to the existing linked-views lesson). Single source of
truth pays twice: the landing hero window shows them automatically.
- **Learn wiring**: the app currently has zero links to `/learn/`. Add: About modal, and
a low-key onboarding-canvas line.
## Learn direction
- Lessons gain "Open in Astrolabe" (mechanism: example ids where possible; arbitrary
stage specs need a spec-payload deep link — decide at implementation, watch hash size).
- Content growth (later): lessons keyed to examples ("what to try next with the
scatter"), draft/publish workflow, theming walkthrough. Until grown, keep nav billing
consistent with a two-lesson section.
## Implementation phases
**Phase 1 — connective tissue (app-side, one session):**
`#example-<id>` deep link (core parse + startup consumption + tests) · paste-a-spec door
(+ import line) on the onboarding canvas · learn links (About + canvas) · showpiece
example(s) in `CHART_EXAMPLES` with compile test · spec §01/§02 updates in-session.
**Phase 2 — landing overhaul (design-involved):**
New showcase block (#2) first — it is the thesis · editor-proof block (#3, staged
visuals, no Monaco import) · resequence/reweight (#4#7) · hero copy + live-affordance ·
objection + trust beats · themed-chart pass across every chart on the page · mobile/dark
pass.
**Phase 3 — learn growth (ongoing content):**
"Open in Astrolabe" from lessons · new lessons per the direction above.
## Parked / deferred
- Post-first-snippet discoverability (draft/publish, extract, theming are invisible until
stumbled on; no tours — against the app's grain) → parked in `docs/ux-second-pass.md`
for the batched council pass.
- Multi-device sync → separate exploration (see monetization/sync memo); landing copy
must not imply it.
- URL-encoded spec sharing (à la Vega editor) → plausible future feature if the sharing
objection keeps surfacing; product, not copy.
+272
View File
@@ -0,0 +1,272 @@
# Learn Section — Lesson Roadmap
_Point-in-time planning memo, 2026-07-04. Detailed briefs for the next lessons, written to
be authorable one at a time (draft → maintainer edit, like the why-astrolabe workflow).
Format for every lesson: the existing progression machinery — hook, `:::data`, staged
specs with diff highlighting, `:::sharp-edge`, a "take it further" closer that uses the
per-stage "Open in Astrolabe" links._
## Positioning
The Vega-Lite docs are an example gallery plus a property reference — hundreds of
_finished_ specs. Lessons that showcase charts compete with that and lose. What the docs
don't have, and where middle+ users plateau, is the **invisible machinery and its failure
modes**: where data actually flows, how selections resolve, why scales unify or don't.
Every lesson below is organized around a _misconception_, not a chart type. The
progression format (the path from almost-right to right) and the sharp edges (documented
failure modes) are the moat; keep both in every lesson.
## Tracks & ordering
Two informal tracks once the roster grows past ~4 lessons (add a `level` field to lesson
frontmatter; the index groups by it — "foundations" and "deeper", not course-like
numbering):
- **Foundations**: binning, labels-on-bars, long-vs-wide, data-flow.
- **Deeper**: linked-views, highlight-vs-filter, faceting, resolution, time,
interactive-binning.
Cross-link map (a lesson references another only once it exists): linked-views' "gap
math" stage → long-vs-wide; long-vs-wide → data-flow; highlight-vs-filter ← linked-views'
closer; interactive-binning ← binning's sharp edge; resolution ← faceting's scale caveats.
The sharp-edge blocks are accumulating into a corpus nothing else on the Vega-Lite
internet has; once there are ~8, an "edges" index page (auto-collected from lesson
frontmatter or the parsed callouts) becomes a destination of its own.
---
## 1. `highlight-vs-filter` — the two answers to a selection
**Thesis / misconception**: a selection can drive a view two fundamentally different ways
_highlight_ (conditional encoding: context preserved, scales still) or _filter_ (rows
removed: everything recomputes, scales included). Most people know one and reach for it
everywhere; the choice is the actual design decision.
**Scenario**: six product lines' weekly revenue — a spaghetti chart where the reader
cares about one line at a time.
**Stages**:
1. _spaghetti_ — six lines, one color scale; unreadable but honest baseline.
2. _+ point selection on the legend_ — `params: [{select: {type: "point", fields:
["product"], on: legend-binding}}]`; nothing reads it yet.
3. _+ highlight_ — `opacity: {condition: {param, value: 1}, value: 0.2}`: the chosen line
pops, the rest stay as context. Note: the y-scale did not move.
4. _+ the filter variant_ — same selection, second view (or swapped response) with
`transform: [{filter: {param}}]`: the y-axis re-fits the chosen line. Note the trade
explicitly: filtering _loses the comparison_ but gains resolution.
5. _polished_ — both responses side by side over one selection; titles that name the
difference ("in context" / "re-scaled").
**Sharp edges**: `empty` default makes condition + filter behave differently before any
click (highlight: everything full-opacity; filter: everything shown) — set `empty:
"none"` deliberately. Point selections toggle on re-click (shift-click accumulates);
that's `toggle` and it surprises people.
**Take it further**: change the condition channel from opacity to color/size; try
`select: {type: "point", on: "pointerover"}` for hover-highlight.
## 2. `long-vs-wide` — reshaping rows for the chart you want
**Thesis / misconception**: "my data is in columns" is a data-_shape_ problem, not a
chart problem. Vega-Lite wants long rows for encoding channels (`fold` gets you there);
row-wise arithmetic wants columns (`pivot` gets you back). SQL framing for the
analyst audience: fold ≈ UNPIVOT, pivot ≈ crosstab/GROUP BY columns.
**Scenario**: a spreadsheet-shaped budget — one row per team, columns `jan feb mar` —
then a two-step signup funnel where the metric is a _ratio between rows_.
**Stages**:
1. _the spreadsheet wall_ — wide data charted naively: one bar per team, months
inaccessible to color/facet. The failure is the hook.
2. _+ fold_ — `fold: ["jan","feb","mar"]` → key/value rows; suddenly month is an
encoding channel like any other.
3. _tidy names_ — fold's `as: ["month","spend"]`; real field names, temporal parse.
4. _+ pivot for row math_ — the funnel: long rows (step, count) pivoted to columns so
`calculate: datum.purchase / datum.visit` can produce a conversion rate per cohort.
5. _polished_ — both charts labelled; the note states the rule of thumb: _encode long,
compute wide_.
**Sharp edges**: pivot drops rows with missing keys silently — the `isValid` patching
dance (exactly what linked-views' gap stage does; link back). Fold keeps _other_ columns
duplicated per folded row — aggregate afterwards or double-count.
**Retro-link**: linked-views' "+ the gap math" note gains a pointer here once shipped.
## 3. `data-flow` — where your data actually flows
**Thesis / misconception**: transforms run in _array order_, and encoding-level
`aggregate`/`bin` run _after_ the transform array — so "why is my filter not working"
is usually "your filter runs at a different point in the pipeline than you think".
**Scenario**: percent-of-total by category — the one chart that needs the pipeline
understood, because it needs a total _alongside_ rows, not instead of them.
**Stages**:
1. _encoding aggregate_ — plain `sum` bar chart; fine, but a dead end for percent-of.
2. _transform aggregate_ — the same chart via `transform: [{aggregate}]`; identical
pixels, different pipeline position — now downstream transforms can read the result.
3. _+ joinaggregate_ — the star of the lesson: totals attached to every row (rows kept,
unlike `aggregate`), then `calculate` percent.
4. _order matters_ — move a `filter` before vs after the joinaggregate; percentages of
the filtered subset vs of the whole. Same transforms, opposite meanings.
5. _polished_ — percent-of-total bars with a `window` rank ordering.
**Sharp edges**: `window` without `sort` is row-order-dependent (works in the example,
breaks on real data); `frame: [null, 0]` means "start through current row" — the
cumulative default everyone copies without reading.
## 4. `labels-on-bars` — layering, taught by the most-searched task
**Thesis**: putting values on bars is the internet's most-asked Vega-Lite question, and
the answer is layer mechanics: a `text` mark sharing the bar's encodings, plus the
handful of properties that make labels sit right.
**Scenario**: a ranked horizontal bar chart (top categories by value) — the chart people
actually want labels on.
**Stages**:
1. _bars, sorted_ — includes the `sort: "-x"` idiom in passing.
2. _+ a text layer_ — same data, `mark: "text"`, x/y duplicated; labels land ON the bar
ends, ugly but working. Note how shared encodings could be hoisted to the layer root.
3. _+ placement_ — `align`, `dx`, `baseline`: labels just past the bar end.
4. _+ formatted_ — `format`/`formatType`, and a `calculate` for compact units (e.g.
"1.2k").
5. _polished_ — de-emphasized axis (the labels now carry the values; drop the x-axis
grid/ticks — say why: double-encoding).
**Sharp edges**: labels don't avoid each other or the bar end — there is no collision
avoidance in Vega-Lite; for inside-vs-outside placement use a `condition` on bar length.
Dual-axis via `resolve: {scale: {y: "independent"}}` looks adjacent but is a trap —
mention, defer detail to `resolution`.
## 5. `time` — the sharpest axis
**Thesis / misconception**: temporal data has two independent honesty problems — _when
is a date_ (timezone parsing: the off-by-one-day bug) and _what is a time bucket_
(`timeUnit` vs binning vs exact timestamps).
**Scenario**: daily signups spanning a year, authored as date-only strings — the exact
shape that triggers the UTC/local trap.
**Stages**:
1. _the off-by-one_ — date-only strings (`"2026-03-01"`) charted naively; for viewers
west of Greenwich every point sits on the previous day. Explain: JS parses date-only
strings as UTC midnight, then Vega-Lite renders in _local_ time.
2. _+ honest parsing_ — the fixes shown together: `utcyearmonthdate` timeUnits (stay in
UTC end-to-end) vs. explicit local parsing; pick one side and stay on it.
3. _+ timeUnit bucketing_ — `yearmonth` collapses days to months _in the chart_, no
transform needed; contrast with `timeUnit` as a transform when the bucket must feed
later steps.
4. _+ axis formatting_ — `axis.format` vs `axis.formatType`, and why tick labels lie
when format and timeUnit disagree.
5. _polished_ — a monthly chart that renders identically in Kyiv and California.
**Sharp edges**: the date-only/datetime parsing asymmetry (date-only → UTC, with-time →
local) is the single most-reported "bug" that isn't one; `timeUnit` without `utc` prefix
re-buckets per-viewer-timezone — dashboards that disagree between offices.
## 6. `resolution` — one legend or two
**Thesis / misconception**: who shares scales by default differs by composition kind —
**layer: shared; facet: shared; concat/repeat: independent** — and `resolve` is the knob.
Most "my colors don't match between panels" bugs are this table, unknown.
**Scenario**: the same two-metric dashboard composed three ways (layer, concat, facet),
watching the color scale and legends merge or split.
**Stages**:
1. _concat, two legends_ — the bug as the baseline: same field, two views, two legends,
possibly two color assignments.
2. _+ resolve shared_ — `resolve: {scale: {color: "shared"}}`: one legend, one truth.
3. _manual pinning_ — `scale: {domain: [...], range: [...]}` per view as the other
route (what linked-views does — call it out); when explicit domains beat resolve.
4. _layer's inverse problem_ — layered dual-metric where _sharing_ is the bug;
`resolve: {scale: {y: "independent"}}`, and the honest warning about dual axes.
5. _polished_ — the corrected dashboard with a note naming the default-by-kind table.
**Sharp edges**: axis resolution is separate from scale resolution (shared scale can
still draw two axes); legends for `condition`-driven encodings don't exist (conditions
have no legend — a recurring surprise after the highlight lesson).
## 7. `interactive-binning` — the promised binning sequel
**Thesis**: which knobs of a spec are _live_ (parameterizable) and which are
compile-time. Binning is the teaching case: `step`/`maxbins` are frozen; `extent` is
live.
**Scenario**: the delivery-times histogram from the binning lesson, upgraded to an
overview-detail pair.
**Stages**:
1. _the slider that does nothing_ — **show the failure live**: a `param` bound to a
slider, referenced where `step` wants a number; compiles, renders, slider moves,
bars don't. The strongest inoculation the format can deliver.
2. _the working knob_ — overview histogram with an interval brush.
3. _+ extent_ — detail histogram whose `bin: {extent: {param: "brush"}, maxbins: 20}`
re-bins inside the brushed range: coarse overview, fine detail.
4. _polished_ — labeled pair, brush styling, tooltips.
**Sharp edges**: recap step/maxbins immutability (now demonstrated, not asserted);
`extent` re-bins but does not _filter_ — pair it with a `filter: {param}` or the detail
view still draws out-of-range rows at the edges.
**Retro-link**: binning's sharp edge gains "see the sequel" once this ships.
## 8. `faceting` — the goldmine: small multiples and their caveats
**Thesis / misconception**: there are _three_ ways to repeat a chart — the `facet`
channel/operator (split by a field's values), `repeat` (split by _different fields_),
and hand-built `concat` — and most frustration comes from using one where another is
meant, then fighting its constraints.
**Scenario**: sales by region — first split by region (facet), then the same chart
repeated across _metrics_ (revenue, units, margin — repeat), showing why facet can't do
the latter.
**Stages**:
1. _the encoding facet_ — `encoding.facet` / `row` / `column`: one extra line, small
multiples with shared scales ("honest comparison for free" — link the landing's
showcase claim).
2. _wrapped + sorted_ — `columns: 3`, and sorting facets by a data value (`sort` on the
facet field def) — the "my panels are alphabetical but I want by total" fix.
3. _the operator form_ — `facet: {...}, spec: {...}`: same output, but now the child can
be a _layered_ spec — the form you need the moment panels contain more than one mark.
4. _repeat, not facet_ — the metrics case: `repeat: {column: [fields]}` +
`{repeat: "column"}` field references; facet cannot do this (it splits by values, not
by fields).
5. _polished_ — headers styled (`header` vs axis titles), spacing, independent y where
metrics differ in unit (`resolve` callback to lesson 6).
**Sharp edges** (the caveat goldmine — candidates, pick 23 for the block and push the
rest into stage notes):
- Facet children can't take `width/height: "container"` — faceted charts size by
per-panel `width`/`height` and ignore fit-to-container (the app's own fit modes fall
back to `pad`; arch 05 records the engine-side truth).
- A facet spec can't be layered _over_ (facet must be outermost; layer inside the child,
never outside).
- Selections in facets resolve per-panel by default — a brush in one panel doesn't
select in the others until `resolve: "union"`/`"global"` on the param.
- `header` vs `axis`: facet labels live on headers; styling them via axis config
silently does nothing.
---
## Authoring workflow
One lesson per session-slice: I draft the full markdown (scenario data included, specs
verified rendering via the dev server before review), the maintainer edits voice and
pedagogy, then it ships with its retro-links applied to earlier lessons. Order proposed:
**highlight-vs-filter → long-vs-wide → faceting → time → labels → data-flow → resolution
→ interactive-binning** (interest-first: interactivity and faceting are the section's
strongest differentiation; data-flow and resolution are load-bearing but drier, better
once the section has gravity).
+73
View File
@@ -0,0 +1,73 @@
# External skills repos review — mattpocock/skills and github/spec-kit
_Point-in-time record, 2026-07-04. Both repos are shallow-cloned under
`/Users/oleh/code/reference/` (`mattpocock-skills/`, `spec-kit/`) for grepping._
## Question
Do the general-purpose engineering-robustness skill sets — mattpocock/skills and
github/spec-kit — contain anything our project skills (`/alignment`, `/eng-council`,
`/council`, `/doc-update`) should adopt? Constraint: fold single additive ideas into
existing skills; never install a parallel framework.
## Verdict
Neither repo is worth adopting wholesale. Our set covers the same ground with more rigor
because it is project-specific and evidence-grounded where theirs is generic. Four
discrete ideas were folded in (all from mattpocock/skills or shared with spec-kit);
everything else was already covered or rejected.
## The three approaches
- **Ours** — a closed-loop system: `/alignment` enforces numbered project-specific checks
per diff; `/eng-council` and `/council` review from altitude under a hard evidence
requirement (file:line, tool output, cited canon); recurring findings promote into
architecture rules and new alignment checks. Built around the code-leads /
descriptive-spec regime and a deletion bias (LOC delta per finding).
- **mattpocock/skills** — small composable per-task disciplines: grilling (relentless
one-question-at-a-time plan interviews), TDD, a bug-diagnosis loop, two-axis code review
(repo standards + Fowler smell baseline vs. originating spec), domain modeling
(`CONTEXT.md` glossary + ADRs), deep-module design vocabulary (Ousterhout/Feathers).
Same anti-framework philosophy as ours; its README positions against spec-kit explicitly.
- **github/spec-kit** — a heavyweight spec-first pipeline (constitution → specify →
clarify → plan → tasks → analyze → implement → converge), seven-plus artifacts per
feature, gates between phases. Its thesis — the spec is the primary artifact, code its
expression — is the inverse of our regime, and it assumes greenfield feature branches,
team role separation, and business-stakeholder specs. Even `converge`, its only
code-vs-artifacts mode, treats undocumented code behavior as scope creep to justify or
remove, where our regime says the code is right and the spec gets rewritten.
## Folded in (2026-07-04)
| Idea | Source | Landed in |
| --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------- | --------------------------------- |
| Reproduce-before-theorizing debugging discipline: red-capable repro command before any hypothesis; minimize; regression test before fix; prefix-tagged debug logs | `diagnosing-bugs` | AGENTS.md → AI Developer Protocol |
| Fowler smell baseline as judgement-call heuristics (mysterious name, data clumps, primitive obsession, feature envy, repeated switches, message chains, middle man) | `code-review` | `/alignment` rule 4 |
| Tautological-test rule: expected values from an independent source of truth, never recomputed the implementation's way | `tdd` | AGENTS.md → Testing Philosophy |
| The deletion test for suspected pass-throughs: delete the module mentally — complexity vanishing means shallow wrapper, reappearing across callers means it earned its keep | `codebase-design` | `/eng-council` consult mode |
## Considered and rejected
- **Grilling as a skill** — sessions here are already interactively driven, and the
harness's question tool plus explore-instead-of-ask covers the discipline. No standing
gap.
- **`CONTEXT.md` glossary + ADRs (domain modeling)** — `docs/architecture/` +
`/doc-update` fill the same role with a stricter altitude bar; a second
decision-record home would split the record.
- **spec-kit's constitution** — SOUL.md + the architecture playbook already are the
constitution, and ours is enforced mechanically (alignment checks), not re-read per
phase.
- **spec-kit's "unit tests for English" checklists** (items test requirement quality:
completeness/clarity/measurability, banned Verify/Test verbs) — the standout idea of
the repo, but it targets prescriptive specs. Our spec is descriptive; its quality bar
is "matches the code", which alignment's spec-tracking check already enforces.
- **spec-kit's bidirectional coverage / gap-type taxonomy** (`missing`/`partial`/
`contradicts`/`unrequested`) — both directions of spec↔code drift are already covered
by alignment's spec-tracking check and the eng-council Documentation seat.
- **spec-kit's clarify mechanics** (fixed ambiguity taxonomy, Impact×Uncertainty question
cap, recommend-before-asking) — recommend-before-asking is already harness convention;
the rest is ceremony sized for teams, not a solo interactive loop.
- **`improve-codebase-architecture` / HTML report** — `/eng-council` sweep covers it with
real evidence tooling (madge/knip/jscpd) and the metrics trend.
- **`research`, `prototype`, `handoff`** — already covered by reference clones, the
one-off HTML showcase habit, and harness context management respectively.
+71
View File
@@ -0,0 +1,71 @@
# VS Code / Positron Extension — Exploration
_Point-in-time memo, 2026-07-04. Option recorded for post-1.0; nothing is being built.
Assesses shipping Astrolabe's authoring intelligence as an editor extension._
## The idea
Port the editor-intelligence layer — scaffolding CodeLenses, spec-aware completions, the
data inspector, live preview — to a VS Code extension (and Positron via OpenVSX), working
on `.vl.json` files in the user's own workspace.
## Why it's cheap: the core/adapter boundary
Everything interesting is already editor-agnostic. `src/core/` (spec analysis, scaffold
construction, site detection over JSON offsets, rendering preparation) has no Monaco
imports; the Monaco layer (`spec-param-scaffold`, `spec-transform-scaffold`,
`editor-cursor-lens`) is thin adapters. VS Code's extension API has one-to-one
counterparts:
| Astrolabe piece | VS Code counterpart |
| ------------------------------- | ---------------------------------------------------- |
| CodeLens scaffolds | `languages.registerCodeLensProvider` + core verbatim |
| Param/transform completions | `registerCompletionItemProvider` / code actions |
| Live preview (`chart-renderer`) | Webview panel running vega-embed (same browser ctx) |
| Data inspector (`inspectData`) | Same webview, message bridge to the render handle |
| Bundled schema (offline) | `jsonValidation` contribution |
| Dataset-by-name (`data.name`) | Resolve against a workspace `datasets/` folder |
Both editors expose offset↔position conversion, so the core's offset-based site
detection transfers without change.
## The marketplace gap
Preview exists; authoring help does not.
- [Vega Viewer](https://marketplace.visualstudio.com/items?itemName=RandomFractalsInc.vscode-vega-viewer)
and [Vega Preview](https://marketplace.visualstudio.com/items?itemName=mdk.vega-preview)
render specs — preview-only.
- The [official vega plugin](https://github.com/vega/vega-vscode) is deprecated; its note
points out VS Code's built-in JSON service already gives `$schema`-driven completion and
validation. **Schema completion/validation are therefore table stakes in VS Code, not
differentiators** — unlike on the web, where Astrolabe had to build them.
- Nothing in the marketplace offers scaffolds, an input-vs-resolved data inspector,
dataset references, or theme configs.
Positron (Posit's VS Code fork, OpenVSX-distributed) concentrates the Altair audience —
people whose wrapper output is already Vega-Lite and who live in an editor. For them an
extension is more native than any website.
## v1 scope, if built
In: bundled-schema validation (offline), scaffold CodeLenses (params/transforms), live
preview panel, data inspector, `data.name` resolution against a workspace folder, theme
as an apply-able config file. Out, deliberately: the library, drafts/publish (git covers
it), the chart builder UI, the theme builder, fonts UI. The extension is "Astrolabe's
authoring intelligence, detached" — the _home_ identity does not port, because in an
editor the workspace already is the home.
## Strategic read
For: real organic discovery (people search "vega" in the marketplace; nobody web-searches
for a snippet manager they don't know exists); every extension user is a lead for the
app; the port validates the core-first architecture.
Against: a second product surface for a single maintainer; it showcases the commodity
part of Astrolabe (editing) rather than the unique part (the home); Positron/OpenVSX
publishing is an extra channel to maintain.
**Decision: defer until after the web app's 1.0.** The only standing cost of keeping the
option open is one we already pay by conviction: keep `src/core/` free of editor and
browser imports.
+47
View File
@@ -0,0 +1,47 @@
# Why Astrolabe exists
I've worked with visualization tools for close to a decade, and for the last few years
I've also taught data work in most of its forms — SQL, Python, spreadsheets, Power BI,
Tableau, the list goes on.
Out of everything I've used in that time, Vega-Lite is one of my favorite ways to make a
chart. It combines things that almost never come together in one tool:
- **It's an open standard.** A chart is a plain JSON file. No registration, no account,
no service that can be discontinued out from under it. Specs I wrote years ago still
render today, and I have every reason to believe they'll render in ten.
- **It's web-native.** Most popular tools have to be installed (Tableau, Power BI) or run
inside a particular environment (Altair, ggplot). For teaching, that raises the floor
twice: technically — students on Linux can't install most BI suites at all — and
conceptually. I want to teach _visualization_, not programming.
- **It's declarative.** This point is really an ode to the Grammar of Graphics that
ggplot popularized: you describe what the chart is, not how to draw it. My favorite
tool for preparing data is SQL, another declarative language, and Vega-Lite scratches
exactly the same itch.
- **It's interactive.** This is what sets it apart from workhorses like matplotlib and
ggplot: cross-filtering, tooltips, and brushing are a few lines of JSON, not an
afternoon of event-handler code. Interactivity multiplies one chart into dozens — a
different view for every filter state a reader can choose.
And yet, when I went looking for a comfortable place to write and keep these charts, I
couldn't settle on anything. Vega-Lite came buried inside heavier BI tools, or in the
official editor — a scratchpad that holds one spec at a time and keeps nothing. And using
the full power of the format was fiddly in practice: to attach a custom font to a chart,
you had to be handy with web development first. The low floor I praise to my students
wasn't there for me either.
So I decided to build my ideal tool for Vega-Lite charts :)
At first it was just a snippet editor: a list of specs, and clicking one opens an editor
with a live preview. Then datasets wanted to be their own thing — stored once, referenced
by name from any chart, instead of pasted into every spec. Then a visual chart builder,
for the days when you'd rather click than type. And then custom themes with your own
fonts — the exact itch that used to require a web developer, now a file-upload away.
Somewhere along the way the tool also acquired a stance. Everything stays in your
browser: no account, no server, and no AI model reading your charts. Part of that is
principle. Most of it is the plain reality that the data I chart at work is confidential,
and I wanted a tool where that question simply never comes up.
If you make charts with Vega-Lite — or you've been looking for a reason to start —
[Astrolabe](/) is where mine live now.
+16
View File
@@ -0,0 +1,16 @@
## Що надихнуло на цей проєкт
Я використовую інструменти візуалізації в роботі вже майже десятиліття, а також вже декілька років викладаю роботу з даними та візуалізацію в багатьох аспектах - SQL, Python, Google SHeets, Power BI Tableau - список не вичерпний.
Маючи доволі широкий досвід як в інструментах, так і в оточеннях візуалізації, можу сказати що Vega-Lite - це один із моїх найулюбленіших інструментів з створення візуалізації. Він поєднує багато речей, які складаються в ідеальний інструмент для багатьох цілей:
- опенсорс. Відсутність необхідності реєструватись є сильним аргументом на користь того, що мої візуалізації будуть доступні довгий час та не поламаються, або для сервісу, в якому я їх створив, буде припинена підтримка.
- крос-платформеність та веб-орієнтованість. Більшість популярних засобів з візуалізації мають бути інстальовані (Tableau, Power BI) або запускатись в певному оточенні (Altair, ggplot) - відповідно, якщо ми говоримо про використання їх для навчання, це підіймає поріг входу як технічно (студенти з Linux не можуть встановити більшість BI систем), так і концептуально (я хочу викладати _візуалізацію_ а не _програмування_).
- декларативність. Цей пункт - скоріше ода до Grammar of Graphics, популяризований пакетами типу ggplot. Також моїм улюбленим інструментом для аналізу/підготовки даних є SQL, що теж є декларативною мовою.
- інтерактивність. Те, що вигідно відрізняє Vega-Lite від популярних мастодонтів типу Matplotlib або ggplot - те, що я можу доволі легко і невимушено створювати інтерактивні графіки з крос-фільтрацією., додавати тултіпи тощо. Це посилює можливості інструменту візуалізації на порядки, адже інтерактивність одразу "помножує" один графік на десятки чи сотні залежно від обраного фільтру.
Разом з тим, коли я шукав зручний інструмент для створення та редагування візуалізацій, було доволі складно зупинитись на чомусь конкретному. Vega-Lite або був частиною якогось більш складного BI-інструменту, або мав надто скорочене застосування (Vega-Editor), або було недостатньо зручно користуватись всіма можливостями інструменту (щоб підключити кастомний шрифт, треба було бути підкованим в веб-розробці).
Отже, я вирішив створити свій ідеальний інструмент для створення графіків в Vega-Lite :)
Спершу це була ідея просто редактор "сніппетів" - список специфікацій, по вибору відкривається редактор і превʼю. Потім зʼявилась ідея додавати/витягувати набори даних в окремі сутності. Згодом виникла і ідея побудувати візуальний редактор, а ще згодом - можливість створення та збереження кастомних тем
+5
View File
@@ -102,6 +102,11 @@ Behavior:
- On load, the app reads the hash and restores the corresponding state (selected snippet, Datasets list, a specific dataset, the new-dataset form, or the Chart Builder).
- An empty/absent hash opens the default snippets view with no modal.
**One-shot action links.** Two hash forms are not view states but requests, consumed on load: the app adds a snippet, opens it, then replaces the hash with the created snippet's view — so reloading does not re-add it, and the link never appears in Back/Forward history. Landing at one with an empty library skips the onboarding canvas and lays the workspace out at the same default split leaving the canvas would. Each visit deliberately creates a new copy.
- `#example-<id>` adds the matching gallery example (see _Snippet Library → First-Run & Empty Workspace_), named as in the gallery (same as pressing its **Add**). An unknown id is ignored and the hash degrades to the default view. The landing uses these links (the hero's "Open in Astrolabe") to hand a visitor into the app carrying the chart they were just looking at.
- `#spec-<payload>` carries a spec's own text (base64url-encoded), so any sender — a lesson stage's "Open in Astrolabe", a shared link — can hand a self-contained spec into the app. The snippet's name derives from the spec (its `title`, else a "Mark chart of y by x" phrase, else "Shared spec"), like a pasted spec. A malformed payload is ignored and the hash degrades to the default view.
## F. Toast Notifications
Transient toast messages appear in a corner of the screen to confirm actions or report problems, without interrupting the workflow.
+3 -1
View File
@@ -24,7 +24,9 @@ When the library is empty — on first run, or after the user deletes their last
- The canvas briefly identifies what Astrolabe is, then offers the ways to begin.
- **Create your first snippet** — the primary action; starts a new snippet from the sample bar-chart template and opens it in the editor (identical to _Create New_ under _Snippet Operations_).
- **Build a chart from your data** — the data-first door beside the primary; opens the **Chart Builder** over the canvas. With no datasets yet, the builder's no-datasets state explains itself and leads to "Add a dataset" (see _Chart Builder → Opening_) — the path never dead-ends. Opening the builder also lays the workspace out at the default split below, since creating from the builder leaves the canvas directly.
- An **example gallery** of a few simple snippets showcasing distinct Vega-Lite capabilities (e.g. a bar chart, a time-series line, a scatter plot, a stacked area, a donut, a binned histogram). Each example shows a **live preview** of the chart and a one-line description.
- **Paste a spec you already have** — the bring-your-own door for users arriving with existing Vega-Lite JSON (a notebook, the Vega editor, an AI chat). A disclosure button (not a modal) reveals a labelled paste area in place; **Add to library** creates the snippet from the pasted text and opens it in the editor, **Cancel** collapses the panel and returns focus to the button. The panel stays mounted while collapsed, so a draft paste survives. Pasted text is accepted as-is — the editor's live validation is where an almost-right spec gets fixed — and the snippet's name derives from the spec (its `title`, else a "Mark chart of y by x" phrase), falling back to "Pasted spec".
- Below the doors, two quiet secondary links: **Import your workspace** (for users restoring a workspace export — same file-picker import as the header control, see _Import & Export_) and **Read the deep dives** (the `/learn/` section, opened in a new tab).
- An **example gallery** of a few simple snippets showcasing distinct Vega-Lite capabilities (e.g. a bar chart, a time-series line, a scatter plot, an interactive brushed scatter, a stacked area, a donut, a binned histogram). Each example shows a **live preview** of the chart and a one-line description.
- **Add** on an example creates it as an ordinary snippet and makes it active (opening it in the editor).
- **Add all** creates the whole set at once and makes one of them active.
- Added examples are **ordinary snippets**: meaningfully named (not auto-generated timestamps), and thereafter editable, duplicable, and deletable like any other — they are the user's, not a special class (_own your data_).
+7
View File
@@ -35,6 +35,13 @@ record the resolution into the contract (`docs/architecture/09`+`10` and the rel
the gesture-specific framing. In `components/CompositionWireframe.tsx` (`pullLabel`, the
`'row'`/`'column'` ternaries in `resolveDrop`/`commitDrop`).
- **Post-first-snippet feature discoverability** — onboarding ends the instant one snippet
exists; draft/publish, extract-to-dataset, and theming are then discovered only by
accident (the CodeLens scaffolds are the exception — discoverable inline). Tours are
against the app's grain; decide what light-touch surface (if any) carries discovery: a
richer About/shortcuts panel, first-visit hints, or nothing. Context in
`docs/exploration/landing-onboarding-scope.md` (§ Parked).
## Deferred (not design debts, revisit on demand)
- **Drag-and-drop field assignment** — chips are click/keyboard-first by design; drag would
+5
View File
@@ -0,0 +1,5 @@
{
"$schema": "https://unpkg.com/knip@5/schema.json",
"entry": ["src/main.tsx", "src/landing/main.tsx", "src/learn/main.tsx", "scripts/*.ts"],
"ignoreDependencies": ["@fontsource/.*", "@fontsource-variable/.*", "marked"]
}
+7
View File
@@ -50,6 +50,13 @@ export function AboutModal() {
A local-first workspace for authoring and organizing Vega-Lite charts. Edit the JSON,
watch it render live, and keep a personal library of snippets.
</p>
<p className={styles.body}>
New to Vega-Lite, or want to go deeper? Read the{' '}
<a className={styles.link} href="/learn/" target="_blank" rel="noopener noreferrer">
deep dives
</a>
.
</p>
</section>
{/* Keyboard shortcuts */}
+73
View File
@@ -37,6 +37,79 @@
/* The two CTAs and the per-example Adds are shared Buttons (arch 09 §4). */
/* Paste-a-spec disclosure panel: revealed below the doors, form-shaped
(label above field, hint between GOV.UK textarea). */
.pastePanel {
margin-top: var(--space-4);
padding: var(--space-4);
border: var(--border-width) solid var(--border);
border-radius: var(--radius);
background: var(--layer-01);
/* Elevated surface: the textarea's field fill steps off --layer-01 (arch 09 §4). */
--field: var(--field-02);
--field-hover: var(--field-hover-02);
}
.pasteLabel {
display: block;
margin-bottom: var(--space-1);
font-size: 14px;
font-weight: 600;
}
.pasteHint {
margin: 0 0 var(--space-3);
font-size: 13px;
color: var(--text-secondary);
line-height: 1.4;
}
/* The field look (fill, underline, focus ring) comes from the base.css element
baseline; the module adds only sizing idiosyncrasies (arch 09 §4). */
.pasteInput {
display: block;
width: 100%;
resize: vertical;
padding: var(--space-2) var(--space-3);
font-family: var(--font-mono);
font-size: 12px;
line-height: 1.5;
}
.pasteActions {
display: flex;
gap: var(--space-3);
margin-top: var(--space-3);
}
/* Secondary paths (import / learn) as one quiet line of links below the doors. */
.altPaths {
margin: var(--space-4) 0 0;
font-size: 13px;
color: var(--text-secondary);
}
/* Inline link styling shared by the import action (a button semantically
it triggers the file picker) and the learn anchor. Focus ring comes from the
base.css baseline.
TODO: this accent-link recipe now exists in four modules (AboutModal .link,
DonateModal .email, DatasetsModal .linkButton, here) past the "third site"
threshold AboutModal.module.css records for promoting it to a shared
primitive; consolidate into one home (arch 09 §4's four mechanisms). */
.linkButton {
padding: 0;
border: none;
background: none;
font: inherit;
color: var(--accent);
text-decoration: underline;
cursor: pointer;
}
.hiddenInput {
display: none;
}
/* The "or start from an example" header row, with Add all pushed to the end. */
.galleryHead {
display: flex;
+35
View File
@@ -75,6 +75,41 @@ describe('Onboarding', () => {
expect(activeSnippetId).toBe(snippets[0].id);
});
test('the paste door creates a snippet from pasted text with a derived name', () => {
click('Paste a spec you already have');
const textarea = container.querySelector<HTMLTextAreaElement>('#onboarding-paste-input')!;
const pasted = JSON.stringify({ title: 'My chart', mark: 'bar' });
act(() => {
// React reads the value through the native setter; assign then dispatch.
Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')!.set!.call(
textarea,
pasted,
);
textarea.dispatchEvent(new Event('input', { bubbles: true }));
});
click('Add to library');
const { snippets, activeSnippetId } = useSnippetStore.getState();
expect(snippets).toHaveLength(1);
expect(snippets[0].spec).toBe(pasted);
expect(snippets[0].name).toBe('My chart'); // deriveSnippetName: title wins
expect(activeSnippetId).toBe(snippets[0].id);
});
test('the paste door is a disclosure: hidden until toggled, empty paste disabled', () => {
const panel = container.querySelector<HTMLElement>('#onboarding-paste-panel')!;
expect(panel.hidden).toBe(true);
click('Paste a spec you already have');
expect(panel.hidden).toBe(false);
// Add is disabled while the paste area is empty — nothing is created.
const add = Array.from(container.querySelectorAll('button')).find((b) =>
b.textContent?.includes('Add to library'),
)!;
expect(add.disabled).toBe(true);
click('Cancel');
expect(panel.hidden).toBe(true);
expect(useSnippetStore.getState().snippets).toHaveLength(0);
});
test('"Add all" adds every example and makes the bar chart active', () => {
click('Add all');
const { snippets, activeSnippetId } = useSnippetStore.getState();
+116 -2
View File
@@ -14,13 +14,14 @@
* independent of `LivePreview`'s single-host render serialization.
*/
import { useEffect, useRef } from 'react';
import { useEffect, useRef, useState } from 'react';
import type { VisualizationSpec } from 'vega-embed';
import { CHART_EXAMPLES, exampleSpecText, type ChartExample } from '@core/examples';
import { createSnippet as createSnippetRecord } from '@core/snippet';
import { createSnippet as createSnippetRecord, deriveSnippetName } from '@core/snippet';
import { chartConfigFor } from '@core/vega-themes';
import { openModal } from '../modals/ModalCoordinator';
import { renderSpec, type RenderHandle } from '../services/chart-renderer';
import { importWorkspace } from '../services/transfer';
import { useAppStore } from '../stores/AppStore';
import { usePanesStore } from '../stores/PanesStore';
import { useSnippetStore } from '../stores/SnippetStore';
@@ -91,6 +92,23 @@ export function Onboarding() {
const addSnippets = useSnippetStore((s) => s.addSnippets);
const applyOnboardingSplit = usePanesStore((s) => s.applyOnboardingSplit);
// The paste door (spec §02): a disclosure (WAI-ARIA APG disclosure pattern —
// button + aria-expanded/aria-controls, no focus trap), not a modal: the canvas
// has the whole workspace to itself, so revealing the paste surface in place
// costs no context. The panel stays mounted (`hidden`) so a draft paste
// survives a collapse.
const [pasteOpen, setPasteOpen] = useState(false);
const [pasteText, setPasteText] = useState('');
const pasteTriggerRef = useRef<HTMLButtonElement>(null);
const pasteAreaRef = useRef<HTMLTextAreaElement>(null);
const importInputRef = useRef<HTMLInputElement>(null);
// The revealed panel exists only for immediate input, so focus follows the
// expand; Cancel returns it to the trigger (NN/g #3 — a clearly marked exit).
useEffect(() => {
if (pasteOpen) pasteAreaRef.current?.focus();
}, [pasteOpen]);
// Leaving onboarding lays the workspace out at the default 25·25·50 split, so
// the first chart opens with a generous preview (spec §02). The canvas fills
// the window here, so its width is a good proxy for the panes container.
@@ -116,6 +134,36 @@ export function Onboarding() {
openModal('chartBuilder');
};
// Pasted text is accepted as-is: the editor is the product's validator, and an
// almost-right spec opening with live schema errors is the feature working.
// The name derives from the spec (title → "Mark chart of y by x"), with
// `nameSource: 'auto'` so it keeps tracking the spec until the user renames.
const handlePasteAdd = () => {
const text = pasteText.trim();
if (!text) return;
createSnippet({
name: deriveSnippetName(text) ?? 'Pasted spec',
nameSource: 'auto',
spec: text,
});
layoutWorkspace();
};
const closePaste = () => {
setPasteOpen(false);
pasteTriggerRef.current?.focus();
};
// Same hidden-picker pattern as the header's Import (spec §08 — the browser
// file dialog is the only chrome); reset so re-picking the same file re-fires.
// TODO: third hidden-file-picker site (App.tsx header, TypeControls.tsx) — past
// the shared-piece threshold; eng-council proposed a useFilePicker/HiddenFileInput
// shape. Consult before building (new hook/component kind).
const handleImportFile = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = '';
if (file) void importWorkspace(file);
};
const handleAddAll = () => {
// Stagger the timestamps so the first example (the bar chart) is the newest:
// it then sorts to the top of the library and `addSnippets` makes it active
@@ -148,8 +196,74 @@ export function Onboarding() {
<Button size="lg" onClick={handleBuild}>
<Icon name="chart" /> Build a chart from your data
</Button>
<Button
size="lg"
ref={pasteTriggerRef}
aria-expanded={pasteOpen}
aria-controls="onboarding-paste-panel"
onClick={() => setPasteOpen((open) => !open)}
>
<Icon name="import" /> Paste a spec you already have
</Button>
</div>
<div id="onboarding-paste-panel" className={styles.pastePanel} hidden={!pasteOpen}>
{/* Visible label above the field (GOV.UK textarea placeholder text is
not a substitute for a label). */}
<label className={styles.pasteLabel} htmlFor="onboarding-paste-input">
Paste a Vega-Lite spec
</label>
<p className={styles.pasteHint} id="onboarding-paste-hint">
Vega-Lite JSON from anywhere a notebook, the Vega editor, an AI chat. It becomes your
first snippet and opens in the editor, live errors and all.
</p>
<textarea
ref={pasteAreaRef}
id="onboarding-paste-input"
className={styles.pasteInput}
aria-describedby="onboarding-paste-hint"
rows={8}
spellCheck={false}
value={pasteText}
onChange={(e) => setPasteText(e.target.value)}
/>
<div className={styles.pasteActions}>
<Button onClick={handlePasteAdd} disabled={pasteText.trim() === ''}>
Add to library
</Button>
<Button variant="ghost" onClick={closePaste}>
Cancel
</Button>
</div>
</div>
{/* Secondary paths as quiet links below the doors (Carbon empty-states
secondary calls to action are links, not more buttons). */}
<p className={styles.altPaths}>
Restoring from an export?{' '}
<button
type="button"
className={styles.linkButton}
onClick={() => importInputRef.current?.click()}
>
Import your workspace
</button>
<span aria-hidden="true"> · </span>
New to Vega-Lite?{' '}
<a className={styles.linkButton} href="/learn/" target="_blank" rel="noopener noreferrer">
Read the deep dives
</a>
</p>
<input
ref={importInputRef}
type="file"
accept="application/json,.json"
className={styles.hiddenInput}
onChange={handleImportFile}
aria-hidden="true"
tabIndex={-1}
/>
<div className={styles.galleryHead}>
<h3 className={styles.galleryTitle}>Or start from an example</h3>
<Button className={styles.addAll} onClick={handleAddAll}>
+37
View File
@@ -1,6 +1,8 @@
import { describe, it, expect, beforeEach } from 'vitest';
import {
parseHash,
parseExampleHash,
parseSpecHash,
serializeHash,
readView,
replaceView,
@@ -75,6 +77,41 @@ describe('parseHash', () => {
});
});
describe('parseExampleHash', () => {
it('extracts the example id from the one-shot action link', () => {
expect(parseExampleHash('#example-brush')).toBe('brush');
expect(parseExampleHash('example-bar')).toBe('bar'); // leading "#" optional
});
it('returns null for anything that is not an example link', () => {
expect(parseExampleHash('')).toBeNull();
expect(parseExampleHash('#example-')).toBeNull(); // empty id
expect(parseExampleHash('#snippet-abc')).toBeNull();
expect(parseExampleHash('#build')).toBeNull();
});
it('is not a ViewState: parseHash degrades an example link to the default view', () => {
expect(parseHash('#example-brush')).toEqual({ kind: 'snippets' });
});
});
describe('parseSpecHash', () => {
it('extracts the payload from the one-shot spec link', () => {
expect(parseSpecHash('#spec-eyJtYXJrIjoiYmFyIn0')).toBe('eyJtYXJrIjoiYmFyIn0');
expect(parseSpecHash('spec-AAAA')).toBe('AAAA'); // leading "#" optional
});
it('returns null for anything that is not a spec link', () => {
expect(parseSpecHash('')).toBeNull();
expect(parseSpecHash('#spec-')).toBeNull(); // empty payload
expect(parseSpecHash('#example-brush')).toBeNull();
});
it('is not a ViewState: parseHash degrades a spec link to the default view', () => {
expect(parseHash('#spec-eyJtYXJrIjoiYmFyIn0')).toEqual({ kind: 'snippets' });
});
});
describe('round-trip identity', () => {
it('parseHash(serializeHash(v)) deep-equals v for every variant', () => {
for (const v of ALL_VIEWS) {
+37
View File
@@ -88,6 +88,43 @@ export function serializeHash(view: ViewState): string {
}
}
/**
* One-shot action link, parsed separately from `ViewState`: `#example-<id>`
* asks the app to add that gallery example (`@core/examples`) as a snippet at
* startup and open it. It is consumed once routing's settle step then
* replaces the hash with the created snippet's view so it never serializes
* back and never participates in Back/Forward. `parseHash` degrades it (like
* any unknown hash) to the default view, which is exactly the fallback for an
* id that no longer exists.
*/
export function parseExampleHash(rawHash: string): string | null {
const m = /^example-(.+)$/.exec(rawHash.replace(/^#/, ''));
return m ? m[1] : null;
}
/** Read the one-shot example id from the current URL, if any. */
export function readExampleId(): string | null {
return parseExampleHash(window.location.hash);
}
/**
* The second action link: `#spec-<payload>` carries a spec's text itself
* (base64url `@core/spec-link` owns the encoding), so a lesson stage or any
* sender can hand a self-contained spec into the app. Same one-shot contract
* as `#example-<id>`. The base64url alphabet has no percent-escapes, so
* reading `location.hash` is safe even in Firefox (which returns the hash
* percent-decoded).
*/
export function parseSpecHash(rawHash: string): string | null {
const m = /^spec-(.+)$/.exec(rawHash.replace(/^#/, ''));
return m ? m[1] : null;
}
/** Read the one-shot spec payload from the current URL, if any. */
export function readSpecPayload(): string | null {
return parseSpecHash(window.location.hash);
}
/** Read the current view from `window.location.hash`. */
export function readView(): ViewState {
return parseHash(window.location.hash);
+46
View File
@@ -12,6 +12,10 @@ import type { Snippet } from '@core/snippet';
import type { Dataset } from '@core/dataset';
import type { CustomTheme } from '@core/custom-theme';
import type { FontAsset } from '@core/font-asset';
import { CHART_EXAMPLES, exampleSpecText } from '@core/examples';
import { deriveSnippetName } from '@core/snippet';
import { decodeSpecPayload } from '@core/spec-link';
import { readExampleId, readSpecPayload } from '../infrastructure/url-hash';
import { loadSnippets } from '../infrastructure/snippet-store';
import { loadDatasets } from '../infrastructure/dataset-store';
import { loadCustomThemes } from '../infrastructure/theme-store';
@@ -23,6 +27,7 @@ import {
} from '../services/storage-errors';
import { notify } from '../stores/NotificationStore';
import { useSnippetStore } from '../stores/SnippetStore';
import { usePanesStore } from '../stores/PanesStore';
import { useDatasetStore } from '../stores/DatasetStore';
import { useCustomThemeStore } from '../stores/CustomThemeStore';
import { useFontStore } from '../stores/FontStore';
@@ -95,6 +100,47 @@ export async function initApp(): Promise<void> {
wireThemePersistence();
wireFontPersistence();
// One-shot action links (spec §01E): `#example-<id>` (the landing's hand-off
// links) adds that gallery example; `#spec-<payload>` (lesson stages, shared
// links) carries the spec text itself. Both add an ordinary snippet and open
// it. They run after persistence wiring (so the new snippet write-throughs;
// anything created before wiring would be treated as loaded baseline and
// never saved) and before routing (whose settle step below replaces the hash
// with the created snippet's view, so a reload doesn't re-add it). An unknown
// id or malformed payload is ignored and the hash degrades to the default view.
// TODO: the ordering constraints above (after wiring, before routing) have no
// automated coverage — an initApp integration test (fake-indexeddb + stubbed
// location.hash) would catch a reorder silently breaking the marketing links.
const addLinkedSnippet = (options: {
name: string;
spec: string;
nameSource?: 'auto' | 'user';
}): void => {
const firstSnippet = useSnippetStore.getState().snippets.length === 0;
useSnippetStore.getState().createSnippet(options);
// Entering the workspace directly, the onboarding canvas never shows — so
// give a first chart the same generous default split leaving it would
// (spec §02 → First-Run & Empty Workspace).
if (firstSnippet) usePanesStore.getState().applyOnboardingSplit(window.innerWidth);
};
const exampleId = readExampleId();
const example = exampleId ? CHART_EXAMPLES.find((e) => e.id === exampleId) : undefined;
if (example) {
// Same semantics as the gallery's Add: a curated name the user chose.
addLinkedSnippet({ name: example.name, spec: exampleSpecText(example) });
} else {
const payload = readSpecPayload();
const specText = payload !== null ? decodeSpecPayload(payload) : null;
if (specText !== null) {
// Same semantics as the paste door: derived name that tracks the spec.
addLinkedSnippet({
name: deriveSnippetName(specText) ?? 'Shared spec',
nameSource: 'auto',
spec: specText,
});
}
}
// Routing starts AFTER hydrate so the on-load hash restore can resolve snippet
// / dataset ids against the loaded stores (spec §01E, docs/architecture/04).
startRouting();
+1 -1
View File
@@ -21,7 +21,7 @@ import * as monaco from 'monaco-editor/esm/vs/editor/edcore.main';
import { useSnippetStore } from '../stores/SnippetStore';
/** Builds a zero-width CodeLens at the start of `line` invoking command `id` with `args`. */
export type LensFactory = (
type LensFactory = (
line: number,
title: string,
id: string | null,
+54
View File
@@ -0,0 +1,54 @@
/**
* Shared base64 codecs the one home for base64 in core (font bytes, spec-link
* payloads). `btoa`/`atob` and `TextEncoder`/`TextDecoder` are platform globals
* in every runtime we target (browsers, the Node test environment), so core
* stays portable without hand-rolled bit twiddling.
*
* Revisit when `Uint8Array.prototype.toBase64`/`fromBase64` (with the
* `base64url` alphabet option) is old enough to assume in arbitrary public
* browsers it deletes this module.
*/
/** Base64-encode raw bytes (32 KB chunks to stay under the argument-spread limit). */
export function bytesToBase64(buffer: ArrayBuffer | Uint8Array): string {
const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer);
let binary = '';
const CHUNK = 0x8000;
for (let i = 0; i < bytes.length; i += CHUNK) {
binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
}
return btoa(binary);
}
/** Decode standard base64 to raw bytes. Throws on malformed input (caller guards). */
export function base64ToBytes(base64: string): ArrayBuffer {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return bytes.buffer;
}
/** Encode text as unpadded base64url (RFC 4648 §5) — a URL-hash-safe alphabet. */
export function textToBase64Url(text: string): string {
return bytesToBase64(new TextEncoder().encode(text))
.replaceAll('+', '-')
.replaceAll('/', '_')
.replace(/=+$/, '');
}
/**
* Decode unpadded base64url back to text. Total: malformed input characters
* outside the alphabet, an impossible length, bytes that aren't valid UTF-8
* returns `null` rather than throwing.
*/
export function base64UrlToText(payload: string): string | null {
// A base64 stream never leaves exactly 6 leftover bits (length ≡ 1 mod 4).
if (payload.length === 0 || payload.length % 4 === 1) return null;
const b64 = payload.replaceAll('-', '+').replaceAll('_', '/');
try {
const bytes = base64ToBytes(b64 + '='.repeat((4 - (b64.length % 4)) % 4));
return new TextDecoder('utf-8', { fatal: true }).decode(bytes);
} catch {
return null;
}
}
+41
View File
@@ -96,6 +96,47 @@ export const CHART_EXAMPLES: ReadonlyArray<ChartExample> = [
},
},
},
{
id: 'brush',
name: 'Brushed scatter',
description: 'Drag a rectangle — points outside the brush fade.',
// Interactivity showpiece. Single-view on purpose: the gallery sizes every
// thumbnail with a top-level `width`/`height`, which composed (concat/layer)
// specs don't accept — a linked-views example needs a bespoke host.
spec: {
$schema: VEGA_LITE_SCHEMA_URL,
description: 'A scatter plot with an interval brush selection.',
data: {
values: [
{ x: 1.1, y: 2.6, group: 'A' },
{ x: 1.6, y: 3.1, group: 'A' },
{ x: 2.0, y: 2.2, group: 'A' },
{ x: 2.4, y: 3.6, group: 'A' },
{ x: 2.9, y: 2.9, group: 'A' },
{ x: 3.3, y: 4.2, group: 'B' },
{ x: 3.8, y: 3.4, group: 'B' },
{ x: 4.2, y: 4.8, group: 'B' },
{ x: 4.6, y: 3.9, group: 'B' },
{ x: 5.1, y: 5.2, group: 'B' },
{ x: 5.5, y: 4.4, group: 'C' },
{ x: 6.0, y: 5.7, group: 'C' },
{ x: 6.4, y: 4.9, group: 'C' },
{ x: 6.9, y: 6.1, group: 'C' },
{ x: 7.3, y: 5.3, group: 'C' },
],
},
params: [{ name: 'brush', select: 'interval' }],
mark: 'point',
encoding: {
x: { field: 'x', type: 'quantitative' },
y: { field: 'y', type: 'quantitative' },
color: {
condition: { param: 'brush', field: 'group', type: 'nominal' },
value: 'lightgray',
},
},
},
},
{
id: 'area',
name: 'Stacked area',
+2 -19
View File
@@ -20,6 +20,8 @@
* and the SVG embed treat every FontAsset the same regardless of source.
*/
import { base64ToBytes, bytesToBase64 } from './base64';
/** A FontAsset record's schema version (read-time migration target). */
export const CURRENT_FONT_VERSION = 1;
@@ -189,25 +191,6 @@ const FONT_MIME: Record<FontFormat, string> = {
otf: 'font/otf',
};
/** Base64-encode raw font bytes (32 KB chunks to stay under the spread limit). */
function bytesToBase64(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer);
let binary = '';
const CHUNK = 0x8000;
for (let i = 0; i < bytes.length; i += CHUNK) {
binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
}
return btoa(binary);
}
/** Decode base64 back to raw font bytes. Throws on malformed input (caller guards). */
function base64ToBytes(base64: string): ArrayBuffer {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return bytes.buffer;
}
/** A `data:` URL embedding a face's bytes — the `src` for an `@font-face` rule. */
export function fontDataUri(asset: FontAsset): string {
return `data:${FONT_MIME[asset.format]};base64,${bytesToBase64(asset.data)}`;
+39
View File
@@ -0,0 +1,39 @@
import { describe, expect, test } from 'vitest';
import { decodeSpecPayload, encodeSpecPayload, specLinkHref } from './spec-link';
describe('spec-link payload', () => {
test('round-trips ASCII spec text', () => {
const text = JSON.stringify({ mark: 'bar', data: { values: [{ a: 1 }] } }, null, 2);
expect(decodeSpecPayload(encodeSpecPayload(text))).toBe(text);
});
test('round-trips multi-byte text (Cyrillic, emoji, CJK)', () => {
for (const text of ['"title": "Доходи по кварталах"', '📈 chart', '売上高', 'a']) {
expect(decodeSpecPayload(encodeSpecPayload(text))).toBe(text);
}
});
test('every byte-length remainder round-trips (1, 2, and 3 mod 3)', () => {
for (const text of ['x', 'xy', 'xyz', 'xyzw']) {
expect(decodeSpecPayload(encodeSpecPayload(text))).toBe(text);
}
});
test('the payload uses only hash-safe characters (no percent-escapes)', () => {
const payload = encodeSpecPayload('{"$schema": "https://…", "mark": "point?&#%"}');
expect(payload).toMatch(/^[A-Za-z0-9_-]+$/);
});
test('malformed payloads decode to null, never throw', () => {
expect(decodeSpecPayload('')).toBeNull();
expect(decodeSpecPayload('abc!d')).toBeNull(); // character outside the alphabet
expect(decodeSpecPayload('AAAAA')).toBeNull(); // length ≡ 1 mod 4 is impossible
expect(decodeSpecPayload('_____-__')).toBeNull(); // valid alphabet, invalid UTF-8
});
test('specLinkHref targets the app entry with the spec- prefix', () => {
const href = specLinkHref('{"mark":"bar"}');
expect(href.startsWith('/app/#spec-')).toBe(true);
expect(decodeSpecPayload(href.slice('/app/#spec-'.length))).toBe('{"mark":"bar"}');
});
});
+41
View File
@@ -0,0 +1,41 @@
/**
* Shareable spec links the `#spec-<payload>` one-shot action link (spec §01
* Navigation, docs/architecture/04 action links): the payload carries the
* spec text itself, so a lesson stage or any sender can hand a self-contained
* spec into the app as a URL.
*
* Portable core: both the learn pages (which build these links) and the app
* (which consumes them) need the format, and learn must not import app
* infrastructure so the encoding is the single source of truth here.
*
* The payload is **base64url** (`core/base64`), not percent-encoding: Firefox
* returns `location.hash` percent-decoded, which corrupts a percent-encoded
* payload on read; base64url's alphabet (`AZ az 09 - _`) survives every
* hash read verbatim.
*
* The payload is uncompressed, so link length 4/3 of the spec text: a lesson
* stage with injected data runs tens of KB well inside browser URL limits
* (single-digit MB). Compress (e.g. a `spec2-` deflate variant) only if links
* ever need to travel through length-hostile channels.
*/
import { base64UrlToText, textToBase64Url } from './base64';
/** Encode spec text into a URL-hash-safe payload (unpadded base64url of UTF-8). */
export function encodeSpecPayload(specText: string): string {
return textToBase64Url(specText);
}
/**
* Decode a payload back to spec text. Total: any malformed input a truncated
* or hand-mangled link returns `null` rather than throwing, degrading to
* "no payload".
*/
export function decodeSpecPayload(payload: string): string | null {
return base64UrlToText(payload);
}
/** The full in-app link for a spec: `/app/#spec-<payload>`. */
export function specLinkHref(specText: string): string {
return `/app/#spec-${encodeSpecPayload(specText)}`;
}
+275 -4
View File
@@ -220,6 +220,20 @@
align-items: center;
gap: var(--space-3);
}
/* "Open in Astrolabe →" beside the Preview column head the hand-off from
playing with the demo to holding the same chart in the app. */
.colHeadLink {
margin-left: auto;
color: var(--accent);
text-decoration: none;
text-transform: none;
letter-spacing: normal;
font-size: 12px;
}
.colHeadLink:hover {
text-decoration: underline;
}
.libList {
flex: 1 1 auto;
min-height: 0;
@@ -461,9 +475,11 @@
}
/* theme demo */
/* Compact grid the theme *switch* is the demo; three charts in one glance,
not a page of scroll. */
.gallery {
display: flex;
flex-direction: column;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--space-4);
margin-top: var(--space-5);
}
@@ -598,6 +614,248 @@
margin-left: auto;
}
/* ── editor proof: staged still of the editor mid-thought (no Monaco) ─────── */
.edBody {
position: relative;
padding: var(--space-4) var(--space-5);
font-family: var(--font-mono);
font-size: 12px;
overflow-x: auto;
}
.edLines {
margin: 0;
line-height: 1.7;
white-space: pre;
color: var(--text);
}
.edLens {
display: block;
font-size: 11px;
color: var(--accent);
margin-bottom: 2px;
}
.edCursor {
display: inline-block;
width: 2px;
height: 13px;
vertical-align: text-bottom;
background: var(--accent);
}
.edPopup {
display: flex;
margin: var(--space-2) 0 var(--space-3) var(--space-8);
max-width: 420px;
border: 1px solid var(--border);
background: var(--layer-01);
box-shadow: 0 8px 24px -12px rgba(0, 0, 0, 0.4);
font-size: 12px;
}
.edOptions {
list-style: none;
margin: 0;
padding: var(--space-1) 0;
min-width: 110px;
border-right: 1px solid var(--border);
}
.edOptions li {
padding: 2px var(--space-3);
}
.edOptionSel {
background: var(--accent-soft);
color: var(--accent);
}
.edDoc {
padding: var(--space-2) var(--space-3);
color: var(--text-secondary);
line-height: 1.5;
}
.edSquiggle {
text-decoration: underline wavy var(--support-error);
text-underline-offset: 3px;
}
/* Marker hover, anchored under the squiggled token like Monaco's. */
.edHover {
margin: var(--space-2) 0 0 var(--space-8);
max-width: 420px;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--border);
border-left: 3px solid var(--support-error);
background: var(--layer-01);
box-shadow: 0 8px 24px -12px rgba(0, 0, 0, 0.4);
font-size: 12px;
color: var(--text-secondary);
line-height: 1.5;
}
/* ── objections: the habit cluster, answered as on-ramps ──────────────────── */
.objections {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--space-7);
padding: 0 0 var(--space-9);
}
.objections h3 {
font-size: 15px;
font-weight: 600;
margin: 0 0 var(--space-2);
}
.objections p {
margin: 0;
font-size: 14px;
line-height: 1.55;
color: var(--text-secondary);
}
/* Inline accent link inside body copy (builder CTA, close note). */
.inlineLink {
color: var(--accent);
text-decoration: none;
}
.inlineLink:hover {
text-decoration: underline;
}
/* Store-once-reference-many diagram: a dataset with its snippets as a tree. */
.refDiagram {
padding: var(--space-4);
border-bottom: 1px solid var(--border);
}
.refDataset {
display: flex;
align-items: center;
gap: var(--space-2);
font-size: 13px;
font-weight: 600;
}
.refDot {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--accent);
flex: 0 0 auto;
}
.refMeta {
font-weight: 400;
color: var(--text-secondary);
}
.refTree {
list-style: none;
margin: var(--space-1) 0 0 4px;
padding: 0;
}
.refTree li {
position: relative;
padding: 3px 0 3px 22px;
font-size: 13px;
}
/* Tree connectors: a vertical rail plus an elbow per row. */
.refTree li::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
border-left: 1px solid var(--border-strong);
}
.refTree li:last-child::before {
bottom: auto;
height: 14px;
}
.refTree li::after {
content: '';
position: absolute;
left: 0;
top: 14px;
width: 14px;
border-top: 1px solid var(--border-strong);
}
.refMore {
color: var(--text-secondary);
font-style: italic;
}
/* Stacked capability section: text intro above, demo at full column width. */
.capStack {
padding: var(--space-9) 0;
}
.capStack .shot {
margin-top: var(--space-6);
}
.themeNote {
margin: var(--space-3) 0 0;
font-size: 13px;
color: var(--text-secondary);
}
.closeNote {
margin-top: var(--space-4);
font-size: 14px;
color: var(--text-secondary);
}
/* ── showcase: live spec ↔ chart demos ("What Vega-Lite can do") ─────────── */
.showcase {
padding: var(--space-9) 0 var(--space-5);
}
.showcaseIntro {
max-width: 62ch;
margin-bottom: var(--space-8);
}
.demo {
margin-bottom: var(--space-9);
}
.demoTitle {
font-size: 18px;
font-weight: 600;
margin: 0 0 var(--space-2);
}
.demoBlurb {
color: var(--text-secondary);
font-size: 14px;
line-height: 1.55;
max-width: 66ch;
margin: 0 0 var(--space-5);
}
.demoRow {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1.15fr);
gap: var(--space-6);
align-items: stretch;
}
/* The spec pane: same chrome as the hero window's code column; the inner .code
pre scrolls when the spec outgrows the cap. */
.demoCode {
display: flex;
min-width: 0;
border: 1px solid var(--border);
background: var(--layer-01);
max-height: 400px;
overflow: hidden;
}
.demoCode .code {
min-width: 0;
}
.demoChartCard {
min-width: 0;
border: 1px solid var(--border);
background: var(--bg);
box-shadow: 0 16px 40px -20px rgba(0, 0, 0, 0.3);
padding: var(--space-5);
display: flex;
flex-direction: column;
justify-content: center;
/* Fixed-size composed charts (the facet demo) scroll inside the card rather
than widening the page on small screens. */
overflow-x: auto;
}
.demoHint {
margin: var(--space-4) 0 0;
font-family: var(--font-mono);
font-size: 12px;
color: var(--accent);
}
@media (max-width: 760px) {
.appBody {
grid-template-columns: 1fr;
@@ -612,11 +870,24 @@
.code {
max-height: 320px;
}
/* minmax(0, ): a plain 1fr track lets pre-formatted code set the column's
min-content width and push the page wider than the viewport. */
.cap,
.creedGrid {
grid-template-columns: 1fr;
.creedGrid,
.demoRow,
.objections,
.gallery {
grid-template-columns: minmax(0, 1fr);
gap: var(--space-6);
}
.demoCode {
max-height: 300px;
}
/* Section anchors don't fit a phone-width nav row; the page is one scroll
anyway keep brand, theme toggle, and the CTA. */
.navLink {
display: none;
}
.capRev .capText {
order: 0;
}
+354 -80
View File
@@ -27,10 +27,18 @@ import {
} from '@core/sample-dataset';
import { DEMO_CUSTOM_THEMES } from './demo-themes';
import { LandingChart } from './LandingChart';
import {
SHOWCASE_DEMOS,
showcaseDisplaySpec,
showcaseFitMode,
type ShowcaseDemo,
} from './showcase-specs';
import styles from './Landing.module.css';
// TODO: import { UiTheme } from '@core/theme' instead of redeclaring it — the
// learn entry already uses the canonical one.
// TODO: this file is the repo's second-largest; on the next added section, split
// into per-section files under src/landing/ (eng-council 2026-07).
type UiTheme = 'light' | 'dark';
// Minimal JSON syntax highlighter for the read-only spec view: keys, strings,
@@ -85,12 +93,41 @@ function Brand(): ReactNode {
);
}
/** The framed app-window chrome (traffic-dot title bar) every section shot sits in. */
function Shot({ bar, children }: { bar: ReactNode; children: ReactNode }): ReactNode {
return (
<div className={styles.shot}>
<div className={styles.shotBar}>
<span className={styles.dots}>
<i />
<i />
<i />
</span>{' '}
{bar}
</div>
{children}
</div>
);
}
// ── Hero: switch snippets, the editor + chart follow ────────────────────────────
function HeroAppWindow({ theme }: { theme: UiTheme }): ReactNode {
const [selected, setSelected] = useState(0);
// Open on the brushed scatter: the first thing a visitor sees is a chart that
// responds to them — the "this is live" argument made without words.
const [selected, setSelected] = useState(() =>
Math.max(
0,
CHART_EXAMPLES.findIndex((e) => e.id === 'brush'),
),
);
const example = CHART_EXAMPLES[selected];
const config = useMemo(() => chartConfigForSelection('astrolabe', theme), [theme]);
// A demo custom theme (not stock blue): the window is also the first proof
// that charts here look designed out of the box.
const config = useMemo(
() => chartConfigForSelection(customThemeSelection(1), theme, DEMO_CUSTOM_THEMES),
[theme],
);
return (
<div className={styles.appWin}>
@@ -130,7 +167,12 @@ function HeroAppWindow({ theme }: { theme: UiTheme }): ReactNode {
<JsonCode spec={example.spec} />
</div>
<div className={styles.col}>
<div className={styles.colHead}>Preview</div>
<div className={styles.colHead}>
Preview
<a className={styles.colHeadLink} href={`/app/#example-${example.id}`}>
Open in Astrolabe
</a>
</div>
<div className={styles.preview}>
<LandingChart spec={example.spec} config={config} />
</div>
@@ -140,6 +182,136 @@ function HeroAppWindow({ theme }: { theme: UiTheme }): ReactNode {
);
}
// ── Showcase: live spec ↔ chart pairs — "a page of JSON is an interactive chart" ─
function ShowcaseDemoRow({ demo, theme }: { demo: ShowcaseDemo; theme: UiTheme }): ReactNode {
const config = useMemo(
() => chartConfigForSelection(customThemeSelection(demo.themeId), theme, DEMO_CUSTOM_THEMES),
[demo.themeId, theme],
);
return (
<div className={styles.demo}>
<h3 className={styles.demoTitle}>{demo.title}</h3>
<p className={styles.demoBlurb}>{demo.blurb}</p>
<div className={styles.demoRow}>
{/* The displayed spec references data by name the app's idiom so the
text stays the length of the idea, not of the rows (showcase-specs.ts). */}
<div className={styles.demoCode}>
<JsonCode spec={showcaseDisplaySpec(demo)} />
</div>
<div className={styles.demoChartCard}>
<LandingChart spec={demo.spec} config={config} fitMode={showcaseFitMode(demo)} />
<p className={styles.demoHint}>{demo.hint}</p>
</div>
</div>
</div>
);
}
function ShowcaseSection({ theme }: { theme: UiTheme }): ReactNode {
return (
<section className={styles.showcase} id="capabilities">
<div className={styles.showcaseIntro}>
<div className={styles.capEyebrow}>Charts as text</div>
<h2 className={styles.capH}>
A page of JSON is <b>an interactive chart.</b>
</h2>
<p className={styles.capP}>
Vega-Lite is a grammar: declare what the data is and how it maps to marks, and the
rendering axes, legends, interaction follows. The three charts below are live, and the
text beside each one is its whole program the data referenced by name, the way a snippet
in Astrolabe reads.
</p>
</div>
{SHOWCASE_DEMOS.map((demo) => (
<ShowcaseDemoRow key={demo.id} demo={demo} theme={theme} />
))}
</section>
);
}
// ── Editor proof: a staged (no-Monaco) still of the editor mid-thought ──────────
// The landing must not import Monaco (post-build gate keeps `/` light), so the
// editor's intelligence is shown as a hand-built still: schema autocomplete with
// inline docs, a CodeLens scaffold row, and a validation catch. Purely
// presentational; the real behaviors live in the app (arch 08).
function EditorProof(): ReactNode {
return (
<Shot bar="revenue-by-quarter.vl.json · draft">
<div className={styles.edBody} aria-hidden="true">
<pre className={styles.edLines}>
{/* Real CodeLens labels (spec-param-scaffold / spec-transform-scaffold),
separated the way Monaco separates lenses. */}
<span className={styles.edLens}>
slider | dropdown | point | interval | Add transform
</span>
{'{\n'}
{' '}
<span className={styles.tokKey}>"data"</span>
{': { '}
<span className={styles.tokKey}>"name"</span>
{': '}
<span className={styles.tokStr}>"sales-2025"</span>
{' },\n'}
{' '}
<span className={styles.tokKey}>"mark"</span>
{': { '}
<span className={styles.tokKey}>"type"</span>
{': '}
<span className={styles.tokStr}>"area"</span>
{', '}
<span className={styles.tokKey}>"inter</span>
<span className={styles.edCursor} />
</pre>
<div className={styles.edPopup}>
<ul className={styles.edOptions}>
<li className={styles.edOptionSel}>interpolate</li>
<li>invalid</li>
<li>innerRadius</li>
</ul>
<div className={styles.edDoc}>
<b>interpolate</b> · string
<br />
The line interpolation method for line and area marks: "linear", "monotone",
"step-after"
</div>
</div>
<pre className={styles.edLines}>
{' '}
<span className={styles.tokKey}>"encoding"</span>
{': {\n'}
{' '}
<span className={styles.tokKey}>"x"</span>
{': { '}
<span className={styles.tokKey}>"field"</span>
{': '}
<span className={styles.tokStr}>"quarter"</span>
{' },\n'}
{' '}
<span className={styles.tokKey}>"color"</span>
{': { '}
<span className={styles.tokKey}>"field"</span>
{': '}
<span className={styles.tokStr}>"region"</span>
{', '}
<span className={styles.tokKey}>"type"</span>
{': '}
<span className={`${styles.tokStr} ${styles.edSquiggle}`}>"nomnal"</span>
{' }\n'}
{' }\n'}
{'}'}
</pre>
{/* Monaco's marker hover, with the JSON language service's actual message
shape the editor has no separate problems strip. */}
<div className={styles.edHover}>
Value is not accepted. Valid values: "nominal", "ordinal", "quantitative", "temporal".
</div>
</div>
</Shot>
);
}
// ── Chart Builder demo: pick fields, a mark, or an intent; the chart rebuilds ────
const MARK_OPTIONS: ReadonlyArray<{ type: MarkType; label: string }> = [
@@ -175,12 +347,34 @@ function columnType(name: string): (typeof BUILDER_TYPES)[number]['type'] {
function fieldMapping(name: string): ChannelMapping {
return { field: name, type: defaultFieldType(columnType(name)) };
}
/** A summed measure rows repeat per (month, region, channel), so an
unaggregated y reads as noise on every mark but the scatter. */
function summed(name: string): ChannelMapping {
return { ...fieldMapping(name), aggregate: 'sum' };
}
// Each mark tab shows an archetype that makes sense for sales-2024 — switching
// the mark alone would leave mappings that read as noise (a temporal bar stack,
// an unaggregated line). The demo must never show a meaningless chart; the
// selects below stay free for exploration.
const MARK_PRESET_ENCODINGS: Partial<Record<MarkType, Record<string, ChannelMapping>>> = {
bar: { x: fieldMapping('region'), y: summed('revenue'), color: fieldMapping('region') },
line: { x: fieldMapping('month'), y: summed('revenue'), color: fieldMapping('region') },
point: { x: fieldMapping('units'), y: fieldMapping('revenue'), color: fieldMapping('region') },
area: { x: fieldMapping('month'), y: summed('revenue'), color: fieldMapping('region') },
};
function markPreset(mark: MarkType): BuilderConfig {
const base = defaultBuilderConfig(SAMPLE_DATASET_NAME, SAMPLE_DATASET_COLUMNS);
const preset = MARK_PRESET_ENCODINGS[mark];
return { ...base, mark, encodings: preset ? { ...base.encodings, ...preset } : base.encodings };
}
function BuilderDemo({ theme }: { theme: UiTheme }): ReactNode {
const columns = SAMPLE_DATASET_COLUMNS;
const [config, setConfig] = useState<BuilderConfig>(() =>
defaultBuilderConfig(SAMPLE_DATASET_NAME, columns),
);
// First frame: the line archetype (revenue over time by region) — the demo's
// opening state is its argument.
const [config, setConfig] = useState<BuilderConfig>(() => markPreset('line'));
const active = activeIntent(config, columns);
const chartConfig = useMemo(() => chartConfigForSelection('astrolabe', theme), [theme]);
const spec = useMemo(() => {
@@ -205,7 +399,7 @@ function BuilderDemo({ theme }: { theme: UiTheme }): ReactNode {
type="button"
aria-pressed={config.mark === m.type}
className={config.mark === m.type ? styles.on : undefined}
onClick={() => setConfig({ ...config, mark: m.type })}
onClick={() => setConfig(markPreset(m.type))}
>
{m.label}
</button>
@@ -235,7 +429,11 @@ function BuilderDemo({ theme }: { theme: UiTheme }): ReactNode {
'y',
e.target.value === COUNT
? { type: 'quantitative', aggregate: 'count' }
: fieldMapping(e.target.value),
: // Rows repeat per (month, region, channel): keep measures summed
// everywhere except the scatter, which shows the raw rows.
config.mark === 'point'
? fieldMapping(e.target.value)
: summed(e.target.value),
)
}
>
@@ -292,7 +490,25 @@ const THEME_CHOICES: ReadonlyArray<{ selection: ChartThemeSelection; label: stri
{ selection: 'vox', label: 'Vox' },
{ selection: 'dark', label: 'Vega Dark' },
];
const GALLERY = THEME_PREVIEW_SPECS.filter((s) => ['bar', 'line', 'scatter'].includes(s.id));
// The core bar preview deliberately carries no colour encoding (it exercises
// title/axes/grid, so bars take the mark default) — on a marketing page that
// reads as "default blue". Give the landing's copy a colour channel so every
// gallery chart shows the selected theme's palette.
const GALLERY = THEME_PREVIEW_SPECS.filter((s) => ['bar', 'line', 'scatter'].includes(s.id)).map(
(s) =>
s.id === 'bar'
? {
...s,
spec: {
...s.spec,
encoding: {
...(s.spec.encoding as Record<string, unknown>),
color: { field: 'region', type: 'nominal', legend: null },
},
},
}
: s,
);
function ThemeDemo({ theme }: { theme: UiTheme }): ReactNode {
const [selection, setSelection] = useState<ChartThemeSelection>(customThemeSelection(1));
@@ -316,6 +532,10 @@ function ThemeDemo({ theme }: { theme: UiTheme }): ReactNode {
</button>
))}
</div>
<p className={styles.themeNote}>
Editorial, Blueprint, and Sunset are custom themes made in the Theme Builder; the rest ship
as presets.
</p>
<div className={styles.gallery}>
{GALLERY.map((ps) => (
<div key={ps.id} className={styles.mini}>
@@ -344,6 +564,9 @@ export function Landing(): ReactNode {
<div className={`${styles.wrap} ${styles.navIn}`}>
<Brand />
<span className={styles.navSpacer} />
<a className={styles.navLink} href="#capabilities">
What it can do
</a>
<a className={styles.navLink} href="#author">
Authoring
</a>
@@ -368,11 +591,12 @@ export function Landing(): ReactNode {
<header className={styles.hero}>
<div className={styles.wrap}>
<h1>
A home for your <b>Vega-Lite charts.</b>
Charts written as text. <b>A home to keep them.</b>
</h1>
<p>
Astrolabe is a local studio for Vega-Lite. Write a spec by hand or build one by
clicking, give it a theme, and keep all your charts in one searchable library.
Astrolabe is a local studio for Vega-Lite the grammar that turns a short JSON spec
into an interactive chart. Write specs by hand or build them by clicking, give them a
theme, and keep every chart in one searchable library.
</p>
<div className={styles.heroCta}>
<a className={`${styles.btn} ${styles.btnPrimary}`} href="/app/">
@@ -387,44 +611,89 @@ export function Landing(): ReactNode {
<div className={styles.wrap}>
<HeroAppWindow theme={theme} />
<p className={styles.stageHint}>
Pick a snippet on the left the editor and chart follow.
This is the real thing, not a screenshot: pick a snippet on the left, drag across the
brushed scatter it's Vega rendering live.
</p>
</div>
</section>
<div className={styles.wrap}>
<ShowcaseSection theme={theme} />
<section className={styles.cap} id="author">
<div className={styles.capText}>
<div className={styles.capEyebrow}>Two ways in</div>
<div className={styles.capEyebrow}>For the spec author</div>
<h2 className={styles.capH}>
Write the spec, or <b>build it by clicking.</b>
An editor that <b>knows Vega-Lite.</b>
</h2>
<p className={styles.capP}>
The editor is Monaco with the Vega-Lite schema loaded, so you get validation,
autocompletion, and inline docs without going online. Each chart keeps an editable
draft alongside a published version you can revert to.
The editor is Monaco with the Vega-Lite schema loaded: autocompletion, inline docs,
and validation as you type, all of it offline. One click scaffolds a working parameter
or data transform to build on.
</p>
<p className={styles.capP}>
If you'd rather not start from JSON, the builder works from the kind of chart you
want. Pick fields, a mark, or a whole intent it writes the spec for you. Try it:
When a chart misbehaves, the data inspector shows the rows going in and the rows the
marks actually draw the fastest answer to "why is my chart empty". And every snippet
keeps an editable draft alongside its published version, so you can always revert to
the last good chart.
</p>
</div>
<div className={styles.shot}>
<div className={styles.shotBar}>
<span className={styles.dots}>
<i />
<i />
<i />
</span>{' '}
Chart Builder · {SAMPLE_DATASET_NAME}
</div>
<EditorProof />
</section>
{/* The habit-cluster objections, answered where the skeptic is scrolling
(landing-onboarding scope memo): each answer is an on-ramp, not a rebuttal. */}
<div className={styles.objections}>
<div>
<h3>Already using the Vega editor?</h3>
<p>
It's a fine scratchpad one spec at a time, nothing kept. Astrolabe is the home: a
library of specs with their datasets, themes, and drafts.
</p>
</div>
<div>
<h3>Writing charts in Altair or another wrapper?</h3>
<p>
A wrapper's output already is a Vega-Lite spec. Paste it in, polish the last mile
(label formats, axis tweaks you'd otherwise translate back through the wrapper's API),
and keep the result.
</p>
</div>
<div>
<h3>Asking an AI for specs?</h3>
<p>
Paste what it gave you: live preview, schema validation, and the data inspector are
where an almost-right spec becomes right. There is no AI inside Astrolabe to see your
data.
</p>
</div>
</div>
<section className={`${styles.cap} ${styles.capRev}`} id="builder">
<div className={styles.capText}>
<div className={styles.capEyebrow}>Rather not start from JSON?</div>
<h2 className={styles.capH}>
The builder writes <b>the spec for you.</b>
</h2>
<p className={styles.capP}>
Point it at your data and work from the kind of chart you want: pick fields, a mark,
or a whole intent (compare, over time, distribution), and it writes ordinary Vega-Lite
the same spec you'd have written by hand, ready to keep clicking on or to open in
the editor.
</p>
<p className={styles.capP}>
<a className={styles.inlineLink} href="/app/#build">
Open the Chart Builder
</a>
</p>
</div>
<Shot bar={<>Chart Builder · {SAMPLE_DATASET_NAME}</>}>
<div className={styles.shotBody}>
<BuilderDemo theme={theme} />
</div>
</div>
</Shot>
</section>
<section className={`${styles.cap} ${styles.capRev}`} id="library">
<section className={styles.cap} id="library">
<div className={styles.capText}>
<div className={styles.capEyebrow}>One library</div>
<h2 className={styles.capH}>
@@ -435,21 +704,27 @@ export function Landing(): ReactNode {
it and Astrolabe updates every chart that used it.
</p>
<p className={styles.capP}>
Load data by pasting CSV or JSON, or by fetching a URL. You can also lift inline data
out of a spec into a shared dataset. Search and sort as the collection grows, and
duplicate a snippet to start a variant.
Paste CSV or JSON, fetch a URL, or lift the inline data already sitting in a spec out
into a shared dataset. Search and sort as the collection grows, and duplicate a
snippet to start a variant.
</p>
</div>
<div className={styles.shot}>
<div className={styles.shotBar}>
<span className={styles.dots}>
<i />
<i />
<i />
</span>{' '}
Datasets
</div>
<Shot bar="Datasets">
<div className={`${styles.shotBody} ${styles.shotBodyFlush}`}>
{/* Store-once-reference-many, as a picture: one dataset, the
snippets that read it hanging off it. */}
<div className={styles.refDiagram} aria-hidden="true">
<div className={styles.refDataset}>
<span className={styles.refDot} /> sales-2025
<span className={styles.refMeta}> one dataset, stored once</span>
</div>
<ul className={styles.refTree}>
<li>Revenue by quarter</li>
<li>Growth trend</li>
<li>Regional split</li>
<li className={styles.refMore}>rename it, and every chart follows</li>
</ul>
</div>
<div className={styles.row}>
<div className={styles.rowName}>
sales-2025 <span className={styles.rowSub}>· CSV · 1,204 rows</span>
@@ -484,62 +759,52 @@ export function Landing(): ReactNode {
</div>
</div>
</div>
</div>
</Shot>
</section>
<section className={styles.cap} id="theme">
{/* Stacked (text above, demo full width): the gallery needs the whole
column for three charts to breathe. */}
<section className={styles.capStack} id="theme">
<div className={styles.capText}>
<div className={styles.capEyebrow}>Make it yours</div>
<h2 className={styles.capH}>
Give your charts a <b>look of their own.</b>
</h2>
<p className={styles.capP}>
Sixteen presets to start from, or build your own in the Theme Builder colour, type,
axes, legend, and layout. The first three here are built from scratch. Try a few:
Upload your own fonts (variable fonts included) and apply one across a whole theme at
once. Sixteen presets to start from, or build your own in the Theme Builder: colour,
type, axes, legends, marks, titles, number formats.
</p>
<p className={styles.capP}>
Upload your own fonts, variable fonts included, and apply one across a whole theme at
once. Save the theme and use it on any chart.
Save a theme and use it on any chart. The first three here are built from scratch. Try
a few:
</p>
</div>
<div className={styles.shot}>
<div className={styles.shotBar}>
<span className={styles.dots}>
<i />
<i />
<i />
</span>{' '}
Theme Builder
</div>
<Shot bar="Theme Builder">
<div className={styles.shotBody}>
<ThemeDemo theme={theme} />
</div>
</div>
</Shot>
</section>
<section className={`${styles.cap} ${styles.capRev}`} id="export">
<section className={styles.cap} id="export">
<div className={styles.capText}>
<div className={styles.capEyebrow}>Exporting</div>
<div className={styles.capEyebrow}>Getting it out</div>
<h2 className={styles.capH}>
Save a chart in the <b>format you need.</b>
Exports with <b>the settings you reach for.</b>
</h2>
<p className={styles.capP}>
Export as a PNG at 13×, as an SVG, or as a Vega-Lite spec. The spec can carry its
data inline, so the file renders on its own wherever it lands.
Every embedded Vega chart ships the same tiny menu (save PNG, save SVG). Astrolabe's
export adds the settings that menu is missing: 13× scale for slides and print,
background control, and the spec itself as a file.
</p>
<p className={styles.capP}>
Your whole workspace exports and re-imports as a single JSON file.
The spec export can inline its data: one self-contained .vl.json that renders anywhere
Vega-Lite runs. That file is how you share a chart from a tool with no server behind
it.
</p>
</div>
<div className={styles.shot}>
<div className={styles.shotBar}>
<span className={styles.dots}>
<i />
<i />
<i />
</span>{' '}
Export chart
</div>
<Shot bar="Export chart">
<div className={styles.shotBody}>
<div className={styles.exp}>
<div className={styles.expRow}>
@@ -591,7 +856,7 @@ export function Landing(): ReactNode {
</div>
</div>
</div>
</div>
</Shot>
</section>
</div>
@@ -612,7 +877,8 @@ export function Landing(): ReactNode {
<h3>Local to your browser</h3>
<p>
Charts, data, and themes are saved in this browser and nowhere else there is no
server behind Astrolabe to receive them. It keeps working offline once installed.
server behind Astrolabe to receive them. Install it and switch off the network: it
keeps working. That's the privacy claim in a form you can test.
</p>
</div>
<div>
@@ -620,15 +886,16 @@ export function Landing(): ReactNode {
<h3>Ordinary Vega-Lite</h3>
<p>
Export a single chart or the whole library as standard JSON and open it in any other
Vega tool.
Vega tool. If Astrolabe vanished tomorrow, your charts wouldn't notice.
</p>
</div>
<div>
<span className={styles.creedKey}>yours</span>
<h3>Set up your way</h3>
<h3>One file, yours to keep</h3>
<p>
Author by hand or by clicking, with your own fonts and themes. Arrange the library
to match how you work.
The whole workspace snippets, datasets, themes, fonts exports as a single JSON
file. Back it up like any file you own and import it on any machine. No sync service
to trust.
</p>
</div>
</div>
@@ -643,6 +910,13 @@ export function Landing(): ReactNode {
<a className={`${styles.btn} ${styles.btnPrimary}`} href="/app/">
Open Astrolabe
</a>
<p className={styles.closeNote}>
New to Vega-Lite?{' '}
<a className={styles.inlineLink} href="/learn/">
Start with the deep dives
</a>
.
</p>
</div>
</section>
+13 -3
View File
@@ -1,7 +1,7 @@
import { useEffect, useRef, useState, type ReactNode } from 'react';
import type { Config } from 'vega-lite';
import type { VisualizationSpec } from 'vega-embed';
import { prepareSpecForRender } from '@core/rendering';
import { prepareSpecForRender, type FitMode } from '@core/rendering';
import styles from './Landing.module.css';
/**
@@ -18,10 +18,20 @@ export function LandingChart({
spec,
config,
className,
fitMode = 'width',
}: {
spec: Record<string, unknown>;
config: Config;
className?: string;
/**
* Sizing contract passed to `prepareSpecForRender`. The `'width'` default
* fits every single-view surface here; a *composed* spec (facet/concat) must
* pass `'default'` and declare its own sizes the fit recursion would set
* `width: 'container'` on the children, which Vega-Lite only supports on
* single and layered views (facet panels go timing-dependent, concat children
* fall back to pad and overflow).
*/
fitMode?: FitMode;
}): ReactNode {
const hostRef = useRef<HTMLDivElement>(null);
const [failed, setFailed] = useState(false);
@@ -37,7 +47,7 @@ export function LandingChart({
// Width-responsive: the chart fills its host (a definite-width `.chartNode`)
// and keeps a natural height — right for every landing surface (hero pane,
// builder, theme gallery).
const prepared: unknown = prepareSpecForRender(spec, { fitMode: 'width' });
const prepared: unknown = prepareSpecForRender(spec, { fitMode });
handle = await renderSpec(node, prepared as VisualizationSpec, config);
if (cancelled) {
handle.destroy();
@@ -57,7 +67,7 @@ export function LandingChart({
handle?.destroy();
handle = null;
};
}, [spec, config]);
}, [spec, config, fitMode]);
return (
<div className={className ?? styles.chartHost}>
+36
View File
@@ -0,0 +1,36 @@
import { describe, expect, test } from 'vitest';
import { compile } from 'vega-lite';
import { SHOWCASE_DEMOS, showcaseDisplaySpec, showcaseFitMode } from './showcase-specs';
// Same promise as the example gallery (examples.test.ts): every showcase chart
// must render — each spec is valid, self-contained Vega-Lite.
describe('landing showcase demos', () => {
test('ids are unique and demos are non-empty', () => {
const ids = SHOWCASE_DEMOS.map((d) => d.id);
expect(ids.length).toBeGreaterThan(0);
expect(new Set(ids).size).toBe(ids.length);
});
test('composed demos render at their declared sizes, single views fit the card', () => {
const byId = (id: string) => SHOWCASE_DEMOS.find((d) => d.id === id)!;
expect(showcaseFitMode(byId('tooltip'))).toBe('width'); // single view
expect(showcaseFitMode(byId('brush'))).toBe('default'); // vconcat
expect(showcaseFitMode(byId('facet'))).toBe('default'); // facet
});
describe.each(SHOWCASE_DEMOS.map((d) => [d.id, d] as const))('demo: %s', (_id, demo) => {
test('carries inline data and compiles as valid Vega-Lite', () => {
const data = demo.spec.data as { values?: unknown[] };
expect(Array.isArray(data?.values)).toBe(true);
expect(() => compile(demo.spec as unknown as Parameters<typeof compile>[0])).not.toThrow();
});
test('the displayed spec swaps inline data for a name reference, all else equal', () => {
const display = showcaseDisplaySpec(demo);
expect(display.data).toEqual({ name: demo.datasetName });
const { data: _a, ...restDisplay } = display;
const { data: _b, ...restReal } = demo.spec;
expect(restDisplay).toEqual(restReal);
});
});
});
+207
View File
@@ -0,0 +1,207 @@
/**
* The "What Vega-Lite can do" showcase the landing's popularization
* centerpiece. Three live, *interactive* charts, each beside the spec that
* produces it: the argument is that a page of JSON is a working chart.
*
* Same constraints as the app's example gallery (`@core/examples`): inline
* `data.values` (self-contained, renders offline), valid Vega-Lite (asserted in
* showcase-specs.test.ts). Rendered specs carry the data inline; the *displayed*
* spec shows `data: { name: … }` instead the app's own dataset-by-reference
* idiom so the text beside each chart stays the length of the idea, not of
* the data, and reads exactly like a snippet in Astrolabe.
*/
import type { FitMode } from '@core/rendering';
import { VEGA_LITE_SCHEMA_URL } from '@core/snippet';
export interface ShowcaseDemo {
/** Stable key — React list key and test identity. */
id: string;
/** Short heading over the demo row. */
title: string;
/** One or two sentences: the capability this demo argues. */
blurb: string;
/** The try-it nudge shown under the live chart. */
hint: string;
/** Dataset name shown in the displayed spec's `data: { name: … }`. */
datasetName: string;
/** The full spec (inline data) that actually renders. */
spec: Record<string, unknown>;
/** Demo custom-theme id (see demo-themes.ts) this chart renders in. */
themeId: number;
}
const TEMPS = [
{ month: '2025-01-01', city: 'Kyiv', temp: -3.1 },
{ month: '2025-02-01', city: 'Kyiv', temp: -1.4 },
{ month: '2025-03-01', city: 'Kyiv', temp: 4.2 },
{ month: '2025-04-01', city: 'Kyiv', temp: 11.3 },
{ month: '2025-05-01', city: 'Kyiv', temp: 17.6 },
{ month: '2025-06-01', city: 'Kyiv', temp: 20.8 },
{ month: '2025-07-01', city: 'Kyiv', temp: 22.9 },
{ month: '2025-08-01', city: 'Kyiv', temp: 22.1 },
{ month: '2025-01-01', city: 'Lisbon', temp: 11.6 },
{ month: '2025-02-01', city: 'Lisbon', temp: 12.4 },
{ month: '2025-03-01', city: 'Lisbon', temp: 14.5 },
{ month: '2025-04-01', city: 'Lisbon', temp: 15.9 },
{ month: '2025-05-01', city: 'Lisbon', temp: 18.2 },
{ month: '2025-06-01', city: 'Lisbon', temp: 21.4 },
{ month: '2025-07-01', city: 'Lisbon', temp: 23.6 },
{ month: '2025-08-01', city: 'Lisbon', temp: 24.0 },
{ month: '2025-01-01', city: 'Oslo', temp: -4.8 },
{ month: '2025-02-01', city: 'Oslo', temp: -4.1 },
{ month: '2025-03-01', city: 'Oslo', temp: 0.4 },
{ month: '2025-04-01', city: 'Oslo', temp: 5.9 },
{ month: '2025-05-01', city: 'Oslo', temp: 11.8 },
{ month: '2025-06-01', city: 'Oslo', temp: 15.7 },
{ month: '2025-07-01', city: 'Oslo', temp: 17.4 },
{ month: '2025-08-01', city: 'Oslo', temp: 16.3 },
];
const ENGINES = [
{ power: 68, efficiency: 22.4, origin: 'Japan' },
{ power: 75, efficiency: 20.8, origin: 'Japan' },
{ power: 88, efficiency: 19.1, origin: 'Japan' },
{ power: 97, efficiency: 17.5, origin: 'Japan' },
{ power: 110, efficiency: 15.9, origin: 'Japan' },
{ power: 130, efficiency: 13.6, origin: 'Japan' },
{ power: 118, efficiency: 14.8, origin: 'Japan' },
{ power: 72, efficiency: 18.9, origin: 'Europe' },
{ power: 85, efficiency: 17.8, origin: 'Europe' },
{ power: 100, efficiency: 16.2, origin: 'Europe' },
{ power: 115, efficiency: 14.7, origin: 'Europe' },
{ power: 140, efficiency: 12.1, origin: 'Europe' },
{ power: 165, efficiency: 10.4, origin: 'Europe' },
{ power: 90, efficiency: 15.3, origin: 'USA' },
{ power: 105, efficiency: 14.1, origin: 'USA' },
{ power: 125, efficiency: 12.8, origin: 'USA' },
{ power: 150, efficiency: 11.2, origin: 'USA' },
{ power: 200, efficiency: 8.6, origin: 'USA' },
];
const SIGNUPS = [
{ week: '2025-05-05', plan: 'Free', signups: 84 },
{ week: '2025-05-12', plan: 'Free', signups: 96 },
{ week: '2025-05-19', plan: 'Free', signups: 110 },
{ week: '2025-05-26', plan: 'Free', signups: 103 },
{ week: '2025-06-02', plan: 'Free', signups: 121 },
{ week: '2025-06-09', plan: 'Free', signups: 137 },
{ week: '2025-05-05', plan: 'Pro', signups: 22 },
{ week: '2025-05-12', plan: 'Pro', signups: 27 },
{ week: '2025-05-19', plan: 'Pro', signups: 25 },
{ week: '2025-05-26', plan: 'Pro', signups: 34 },
{ week: '2025-06-02', plan: 'Pro', signups: 39 },
{ week: '2025-06-09', plan: 'Pro', signups: 45 },
{ week: '2025-05-05', plan: 'Team', signups: 6 },
{ week: '2025-05-12', plan: 'Team', signups: 9 },
{ week: '2025-05-19', plan: 'Team', signups: 11 },
{ week: '2025-05-26', plan: 'Team', signups: 10 },
{ week: '2025-06-02', plan: 'Team', signups: 14 },
{ week: '2025-06-09', plan: 'Team', signups: 18 },
];
export const SHOWCASE_DEMOS: ReadonlyArray<ShowcaseDemo> = [
{
id: 'tooltip',
title: 'Tooltips are one word',
blurb:
'Interaction is declared like any other property. The tooltips on this chart cost a single one: "tooltip": true.',
hint: 'Hover the points.',
datasetName: 'city-temperatures',
themeId: 1, // Editorial
spec: {
$schema: VEGA_LITE_SCHEMA_URL,
description: 'Monthly average temperature by city.',
data: { values: TEMPS },
mark: { type: 'line', point: true, tooltip: true },
encoding: {
x: { field: 'month', type: 'temporal', title: 'Month' },
y: { field: 'temp', type: 'quantitative', title: '°C' },
color: { field: 'city', type: 'nominal' },
},
},
},
{
id: 'brush',
title: 'Selections drive other views',
blurb:
'A named selection in one view becomes a filter in another. Declare a param and a filter, and the views are linked — there is no event-handler code to write.',
hint: 'Drag a rectangle on the scatter — the bars recount.',
datasetName: 'engines',
themeId: 2, // Blueprint
spec: {
$schema: VEGA_LITE_SCHEMA_URL,
description: 'Engine power vs efficiency; brushing filters the count below.',
data: { values: ENGINES },
// Declared sizes, not container fit: Vega-Lite can't fit-size concat
// children (autosize falls back to pad and the axes overflow the card).
vconcat: [
{
width: 380,
height: 210,
params: [{ name: 'brush', select: 'interval' }],
mark: 'point',
encoding: {
x: { field: 'power', type: 'quantitative', title: 'Power (hp)' },
y: { field: 'efficiency', type: 'quantitative', title: 'km / l' },
color: {
condition: { param: 'brush', field: 'origin', type: 'nominal' },
value: 'lightgray',
},
},
},
{
width: 380,
height: 80,
transform: [{ filter: { param: 'brush' } }],
mark: 'bar',
encoding: {
y: { field: 'origin', type: 'nominal', title: null },
x: { aggregate: 'count', title: 'Engines in brush' },
color: { field: 'origin', type: 'nominal', legend: null },
},
},
],
},
},
{
id: 'facet',
title: 'One spec, a chart per group',
blurb:
'Add a facet and the grammar repeats the chart for every value of a field — same axes, same scales, honest comparison for free.',
hint: 'One declaration made three charts.',
datasetName: 'weekly-signups',
themeId: 3, // Sunset
spec: {
$schema: VEGA_LITE_SCHEMA_URL,
description: 'Weekly signups, one panel per plan.',
data: { values: SIGNUPS },
facet: { column: { field: 'plan', title: null } },
spec: {
width: 130,
height: 150,
mark: { type: 'area', line: true, tooltip: true },
encoding: {
x: { field: 'week', type: 'temporal', title: null },
y: { field: 'signups', type: 'quantitative', title: 'Signups' },
color: { field: 'plan', type: 'nominal', legend: null },
},
},
},
},
];
/** The spec as shown beside the chart: data by name, the app's own idiom. */
export function showcaseDisplaySpec(demo: ShowcaseDemo): Record<string, unknown> {
return { ...demo.spec, data: { name: demo.datasetName } };
}
/**
* Sizing contract for a demo's live render. Composed specs (facet/concat/repeat)
* keep their declared per-view sizes the width-fit recursion can't size their
* children (see LandingChart) while single and layered views fit the card.
*/
export function showcaseFitMode(demo: ShowcaseDemo): FitMode {
const composed = ['facet', 'vconcat', 'hconcat', 'concat', 'repeat'];
return composed.some((k) => k in demo.spec) ? 'default' : 'width';
}
+13
View File
@@ -102,6 +102,19 @@
min-height: 180px;
}
/* The per-stage hand-off into the app: the stage's spec, self-contained, as a
snippet opened in a new tab so the lesson keeps its place. */
.openLink {
display: inline-block;
margin-top: var(--space-3);
font-size: 13px;
color: var(--accent);
text-decoration: none;
}
.openLink:hover {
text-decoration: underline;
}
.note {
padding: var(--space-4) var(--space-5);
border-top: 1px solid var(--border);
+12
View File
@@ -2,6 +2,7 @@ import { useId, useMemo, useRef, useState, type KeyboardEvent, type ReactNode }
import type { Config } from 'vega-lite';
import { injectDatasets, type ProgressionStage } from '@core/lesson-parse';
import { formatSpec } from '@core/json-format';
import { specLinkHref } from '@core/spec-link';
import { changedLines } from '@core/spec-diff';
import { LandingChart } from '../landing/LandingChart';
import { Markdown } from './Markdown';
@@ -66,6 +67,14 @@ export function SpecProgression({
const activeStage = stages[active];
// Hand-off into the app (spec §01E → one-shot action links): the link carries
// the *injected* spec — the app has no lesson datasets, so the snippet must
// arrive self-contained even though the source pane shows data by name.
const openHref = useMemo(
() => specLinkHref(formatSpec(injectDatasets(activeStage.spec, datasets))),
[activeStage, datasets],
);
return (
<div className={styles.progression}>
<div
@@ -119,6 +128,9 @@ export function SpecProgression({
config={config}
className={styles.chart}
/>
<a className={styles.openLink} href={openHref} target="_blank" rel="noopener">
Open this stage in Astrolabe
</a>
</div>
</div>
<Markdown className={styles.note} source={activeStage.note} />
+52 -15
View File
@@ -5,10 +5,18 @@ tagline: How one word turns a noisy bar chart into a distribution — and where
---
Counting a continuous field is the most common chart that's _almost_ right. The naive
version compiles, renders, and quietly lies about your data. Walk the four stages below —
each tab is one small edit — and watch a picket fence become a distribution, then a finished
version compiles, renders, and quietly lies about your data. Walk the stages below — each
tab is one small edit — and watch a picket fence become a distribution, then a finished
chart.
:::data
{
"orders": [
{"minutes":12},{"minutes":14},{"minutes":15},{"minutes":16},{"minutes":18},{"minutes":19},{"minutes":20},{"minutes":21},{"minutes":22},{"minutes":23},{"minutes":24},{"minutes":25},{"minutes":26},{"minutes":28},{"minutes":30},{"minutes":31},{"minutes":33},{"minutes":35},{"minutes":37},{"minutes":40},{"minutes":43},{"minutes":46},{"minutes":50},{"minutes":55},{"minutes":61},{"minutes":68},{"minutes":77},{"minutes":86}
]
}
:::
:::progression
## raw counts
@@ -20,12 +28,12 @@ fence, not a distribution. It looks almost right, which is exactly what makes it
```vega-lite
{
"$schema": "https://vega.github.io/schema/vega-lite/v6.json",
"data": { "name": "orders" },
"mark": "bar",
"encoding": {
"x": { "field": "minutes", "type": "quantitative" },
"y": { "aggregate": "count" }
},
"data": { "values": [{"minutes":12},{"minutes":14},{"minutes":15},{"minutes":16},{"minutes":18},{"minutes":19},{"minutes":20},{"minutes":21},{"minutes":22},{"minutes":23},{"minutes":24},{"minutes":25},{"minutes":26},{"minutes":28},{"minutes":30},{"minutes":31},{"minutes":33},{"minutes":35},{"minutes":37},{"minutes":40},{"minutes":43},{"minutes":46},{"minutes":50},{"minutes":55},{"minutes":61},{"minutes":68},{"minutes":77},{"minutes":86}] }
}
}
```
@@ -37,12 +45,12 @@ counts each bucket — a real histogram from a one-word edit.
```vega-lite
{
"$schema": "https://vega.github.io/schema/vega-lite/v6.json",
"data": { "name": "orders" },
"mark": "bar",
"encoding": {
"x": { "field": "minutes", "type": "quantitative", "bin": true },
"y": { "aggregate": "count" }
},
"data": { "values": [{"minutes":12},{"minutes":14},{"minutes":15},{"minutes":16},{"minutes":18},{"minutes":19},{"minutes":20},{"minutes":21},{"minutes":22},{"minutes":23},{"minutes":24},{"minutes":25},{"minutes":26},{"minutes":28},{"minutes":30},{"minutes":31},{"minutes":33},{"minutes":35},{"minutes":37},{"minutes":40},{"minutes":43},{"minutes":46},{"minutes":50},{"minutes":55},{"minutes":61},{"minutes":68},{"minutes":77},{"minutes":86}] }
}
}
```
@@ -54,24 +62,51 @@ Take control of the resolution: `bin: { step: 10 }` forces clean 10-minute bucke
```vega-lite
{
"$schema": "https://vega.github.io/schema/vega-lite/v6.json",
"data": { "name": "orders" },
"mark": "bar",
"encoding": {
"x": { "field": "minutes", "type": "quantitative", "bin": { "step": 10 }, "title": "Delivery time (min)" },
"y": { "aggregate": "count", "title": "Orders" }
},
"data": { "values": [{"minutes":12},{"minutes":14},{"minutes":15},{"minutes":16},{"minutes":18},{"minutes":19},{"minutes":20},{"minutes":21},{"minutes":22},{"minutes":23},{"minutes":24},{"minutes":25},{"minutes":26},{"minutes":28},{"minutes":30},{"minutes":31},{"minutes":33},{"minutes":35},{"minutes":37},{"minutes":40},{"minutes":43},{"minutes":46},{"minutes":50},{"minutes":55},{"minutes":61},{"minutes":68},{"minutes":77},{"minutes":86}] }
}
}
```
## + a mean line
The first act of _composition_: the spec becomes a `layer` of two marks sharing the same
data and x-axis — the bars, and a `rule` at the mean. Each layer keeps its own mark and
encoding; the shared scale is what makes them one chart. This one jump is most of what
"layering" means.
```vega-lite
{
"$schema": "https://vega.github.io/schema/vega-lite/v6.json",
"data": { "name": "orders" },
"layer": [
{
"mark": "bar",
"encoding": {
"x": { "field": "minutes", "type": "quantitative", "bin": { "step": 10 }, "title": "Delivery time (min)" },
"y": { "aggregate": "count", "title": "Orders" }
}
},
{
"mark": { "type": "rule", "color": "#d6336c", "size": 2 },
"encoding": { "x": { "field": "minutes", "aggregate": "mean" } }
}
]
}
```
## polished
The last 20%: round the bar tops, add a tooltip that reports each bucket's range and count,
and _layer_ a mean line over the bars. Composition is where polish lives — one spec, two
marks sharing an axis.
The last 20% is legibility, not structure: round the bar tops, and add a tooltip that
reports each bucket's range and count.
```vega-lite
{
"$schema": "https://vega.github.io/schema/vega-lite/v6.json",
"data": { "name": "orders" },
"layer": [
{
"mark": { "type": "bar", "cornerRadiusEnd": 2 },
@@ -88,8 +123,7 @@ marks sharing an axis.
"mark": { "type": "rule", "color": "#d6336c", "size": 2 },
"encoding": { "x": { "field": "minutes", "aggregate": "mean" } }
}
],
"data": { "values": [{"minutes":12},{"minutes":14},{"minutes":15},{"minutes":16},{"minutes":18},{"minutes":19},{"minutes":20},{"minutes":21},{"minutes":22},{"minutes":23},{"minutes":24},{"minutes":25},{"minutes":26},{"minutes":28},{"minutes":30},{"minutes":31},{"minutes":33},{"minutes":35},{"minutes":37},{"minutes":40},{"minutes":43},{"minutes":46},{"minutes":50},{"minutes":55},{"minutes":61},{"minutes":68},{"minutes":77},{"minutes":86}] }
]
}
```
@@ -99,6 +133,9 @@ marks sharing an axis.
**The sharp edge.** Tempted to wire a slider to the bin width? It won't work. In Vega-Lite,
`bin.step` and `maxbins` are fixed numbers — no parameter or expression drives them, so a
bound slider compiles fine and then does nothing. The one binning property you _can_ make
interactive is `extent`: brush an interval selection to re-bin a chosen range. That's a
lesson of its own.
interactive is `extent`: brush an interval selection to re-bin a chosen range.
:::
Take it further: open the last stage in Astrolabe (the link under the chart) and try a
`step` of 5 against 20 — watch the story the histogram tells change with the resolution.
Then swap the rule's `mean` for `median` and see which one your outliers drag.
+164 -9
View File
@@ -12,8 +12,8 @@ re-aggregate by hand for every window you got curious about.
So we build three views over one interval selection. Brush a span of the run on the top
chart and the other two recompute over exactly that window — the cumulative gap between the
arms, and their share split. Five edits, and all three stay in sync through a single
`param`.
arms, and their share split. A handful of small edits, and everything stays in sync
through a single `param`.
:::data
{
@@ -522,14 +522,165 @@ you'll drag across, because a selection lives on the view whose marks define it.
}
```
## + a linked gap
## + filter by the brush
Append a second view that reads the selection. `filter: {param: brush}` keeps only the
brushed minutes; then it pivots A and B into columns, re-accumulates each, and plots
A B. Now dragging the band on the top chart rebases this one — brush the first half-hour
and the early gap shows; brush the tail and it flattens. "Is the imbalance localised in
time?" gets answered by dragging instead of re-querying. With nothing brushed the selection
is empty, which by default means _every_ row, so it opens on the full run.
Append a second view that reads the selection `filter: {param: brush}` is the entire
link. Everything downstream of that filter re-runs over only the brushed rows, so this
copy of the cumulative lines rebases to whatever window you drag: brush the first
half-hour and the early race shows; brush the tail and both lines restart from zero
there. With nothing brushed the selection is empty, which by default matches _every_
row — that's why it opens on the full run.
```vega-lite
{
"$schema": "https://vega.github.io/schema/vega-lite/v6.json",
"vconcat": [
{
"data": {
"name": "ev"
},
"transform": [
{
"sort": [
{
"field": "minute"
}
],
"window": [
{
"op": "sum",
"field": "n",
"as": "cum"
}
],
"groupby": [
"arm"
],
"frame": [
null,
0
]
}
],
"params": [
{
"name": "brush",
"select": {
"type": "interval",
"encodings": [
"x"
]
}
}
],
"mark": "line",
"encoding": {
"x": {
"field": "minute",
"type": "temporal",
"title": "Time (UTC)",
"axis": {
"format": "%H:%M"
}
},
"y": {
"field": "cum",
"type": "quantitative",
"title": "Cumulative assignments"
},
"color": {
"field": "arm",
"type": "nominal",
"scale": {
"domain": [
"A",
"B"
],
"range": [
"#3a7ca5",
"#e0913a"
]
},
"title": "Branch"
}
}
},
{
"data": {
"name": "ev"
},
"transform": [
{
"filter": {
"param": "brush"
}
},
{
"sort": [
{
"field": "minute"
}
],
"window": [
{
"op": "sum",
"field": "n",
"as": "cum"
}
],
"groupby": [
"arm"
],
"frame": [
null,
0
]
}
],
"mark": "line",
"encoding": {
"x": {
"field": "minute",
"type": "temporal",
"title": "Time (UTC)",
"axis": {
"format": "%H:%M"
}
},
"y": {
"field": "cum",
"type": "quantitative",
"title": "Rebased in the window"
},
"color": {
"field": "arm",
"type": "nominal",
"scale": {
"domain": [
"A",
"B"
],
"range": [
"#3a7ca5",
"#e0913a"
]
},
"legend": null
}
}
}
]
}
```
## + the gap math
Two rebased lines still make you subtract by eye. To plot A B directly, the rows have
to change _shape_: `pivot` turns the A and B rows into columns of one row per minute,
two `calculate`s patch the minutes where an arm is missing, and a two-op `window`
re-accumulates each column before the final subtraction. Four transforms in service of
one line — reshaping long rows into wide columns is a craft of its own, but the link to
the brush is unchanged: the same `filter` still heads the pipeline.
```vega-lite
{
@@ -1183,3 +1334,7 @@ brush}` inherits Vega-Lite's default `empty: "all"`, so until you drag something
matches every row — which is why the dashboard opens on the full run. To make it open blank
instead, set `empty: "none"` on the selection.
:::
Take it further: open the last stage in Astrolabe (the link under the chart) and try both
traps for yourself — move the `params` block onto the gap view and feel the dashboard go
dead, then put it back and set `empty: "none"` to see the blank-until-brushed variant.
+5
View File
@@ -16,6 +16,11 @@ const pkg = JSON.parse(readFileSync(new URL('./package.json', import.meta.url),
generateLearnPages();
export default defineConfig({
server: {
// Hetzner dev box: fronted by tailscale serve (HTTPS on the tailnet), which
// forwards the ts.net Host header — allow it past DNS-rebinding protection.
allowedHosts: ['.ts.net'],
},
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),