mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Compare commits
13 Commits
552900dac5
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 7846a8ec41 | |||
|
5ef3d402ab
|
|||
|
4925268168
|
|||
|
0928912c4d
|
|||
|
42717fe4f7
|
|||
|
194177c0c5
|
|||
|
e4ff442219
|
|||
|
acf14a8b13
|
|||
|
74b810f8c9
|
|||
|
f5b2620458
|
|||
|
65090da72c
|
|||
|
5ec7c3aa77
|
|||
|
db258a4e54
|
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
(~71–77% resolution, usually within 0–3 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 ~150–200 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: ~450–500 LoC**, plus ~100–150 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** (~90–110 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** (~55–60 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** (~40–50 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** (~25–30 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 (~100–150 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 (60–100 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: 2–3 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 1–2 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.
|
||||
@@ -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 2–3 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).
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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 :)
|
||||
|
||||
Спершу це була ідея просто редактор "сніппетів" - список специфікацій, по вибору відкривається редактор і превʼю. Потім зʼявилась ідея додавати/витягувати набори даних в окремі сутності. Згодом виникла і ідея побудувати візуальний редактор, а ще згодом - можливість створення та збереження кастомних тем
|
||||
@@ -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.
|
||||
|
||||
@@ -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_).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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)}`;
|
||||
|
||||
@@ -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"}');
|
||||
});
|
||||
});
|
||||
@@ -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 (`A–Z a–z 0–9 - _`) 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)}`;
|
||||
}
|
||||
@@ -220,6 +220,20 @@
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
/* "Open in Astrolabe →" beside the Preview column head — the hand-off from
|
||||
playing with the demo to holding the same chart in the app. */
|
||||
.colHeadLink {
|
||||
margin-left: auto;
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
text-transform: none;
|
||||
letter-spacing: normal;
|
||||
font-size: 12px;
|
||||
}
|
||||
.colHeadLink:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.libList {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
@@ -461,9 +475,11 @@
|
||||
}
|
||||
|
||||
/* theme demo */
|
||||
/* Compact grid — the theme *switch* is the demo; three charts in one glance,
|
||||
not a page of scroll. */
|
||||
.gallery {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: var(--space-4);
|
||||
margin-top: var(--space-5);
|
||||
}
|
||||
@@ -598,6 +614,248 @@
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* ── editor proof: staged still of the editor mid-thought (no Monaco) ─────── */
|
||||
.edBody {
|
||||
position: relative;
|
||||
padding: var(--space-4) var(--space-5);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.edLines {
|
||||
margin: 0;
|
||||
line-height: 1.7;
|
||||
white-space: pre;
|
||||
color: var(--text);
|
||||
}
|
||||
.edLens {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
color: var(--accent);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.edCursor {
|
||||
display: inline-block;
|
||||
width: 2px;
|
||||
height: 13px;
|
||||
vertical-align: text-bottom;
|
||||
background: var(--accent);
|
||||
}
|
||||
.edPopup {
|
||||
display: flex;
|
||||
margin: var(--space-2) 0 var(--space-3) var(--space-8);
|
||||
max-width: 420px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--layer-01);
|
||||
box-shadow: 0 8px 24px -12px rgba(0, 0, 0, 0.4);
|
||||
font-size: 12px;
|
||||
}
|
||||
.edOptions {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: var(--space-1) 0;
|
||||
min-width: 110px;
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
.edOptions li {
|
||||
padding: 2px var(--space-3);
|
||||
}
|
||||
.edOptionSel {
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
}
|
||||
.edDoc {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.edSquiggle {
|
||||
text-decoration: underline wavy var(--support-error);
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
/* Marker hover, anchored under the squiggled token like Monaco's. */
|
||||
.edHover {
|
||||
margin: var(--space-2) 0 0 var(--space-8);
|
||||
max-width: 420px;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border: 1px solid var(--border);
|
||||
border-left: 3px solid var(--support-error);
|
||||
background: var(--layer-01);
|
||||
box-shadow: 0 8px 24px -12px rgba(0, 0, 0, 0.4);
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ── objections: the habit cluster, answered as on-ramps ──────────────────── */
|
||||
.objections {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: var(--space-7);
|
||||
padding: 0 0 var(--space-9);
|
||||
}
|
||||
.objections h3 {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 var(--space-2);
|
||||
}
|
||||
.objections p {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.55;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Inline accent link inside body copy (builder CTA, close note). */
|
||||
.inlineLink {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
.inlineLink:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Store-once-reference-many diagram: a dataset with its snippets as a tree. */
|
||||
.refDiagram {
|
||||
padding: var(--space-4);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.refDataset {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.refDot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.refMeta {
|
||||
font-weight: 400;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.refTree {
|
||||
list-style: none;
|
||||
margin: var(--space-1) 0 0 4px;
|
||||
padding: 0;
|
||||
}
|
||||
.refTree li {
|
||||
position: relative;
|
||||
padding: 3px 0 3px 22px;
|
||||
font-size: 13px;
|
||||
}
|
||||
/* Tree connectors: a vertical rail plus an elbow per row. */
|
||||
.refTree li::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
border-left: 1px solid var(--border-strong);
|
||||
}
|
||||
.refTree li:last-child::before {
|
||||
bottom: auto;
|
||||
height: 14px;
|
||||
}
|
||||
.refTree li::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 14px;
|
||||
width: 14px;
|
||||
border-top: 1px solid var(--border-strong);
|
||||
}
|
||||
.refMore {
|
||||
color: var(--text-secondary);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Stacked capability section: text intro above, demo at full column width. */
|
||||
.capStack {
|
||||
padding: var(--space-9) 0;
|
||||
}
|
||||
.capStack .shot {
|
||||
margin-top: var(--space-6);
|
||||
}
|
||||
|
||||
.themeNote {
|
||||
margin: var(--space-3) 0 0;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.closeNote {
|
||||
margin-top: var(--space-4);
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* ── showcase: live spec ↔ chart demos ("What Vega-Lite can do") ─────────── */
|
||||
.showcase {
|
||||
padding: var(--space-9) 0 var(--space-5);
|
||||
}
|
||||
.showcaseIntro {
|
||||
max-width: 62ch;
|
||||
margin-bottom: var(--space-8);
|
||||
}
|
||||
.demo {
|
||||
margin-bottom: var(--space-9);
|
||||
}
|
||||
.demoTitle {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 var(--space-2);
|
||||
}
|
||||
.demoBlurb {
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
line-height: 1.55;
|
||||
max-width: 66ch;
|
||||
margin: 0 0 var(--space-5);
|
||||
}
|
||||
.demoRow {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1.15fr);
|
||||
gap: var(--space-6);
|
||||
align-items: stretch;
|
||||
}
|
||||
/* The spec pane: same chrome as the hero window's code column; the inner .code
|
||||
pre scrolls when the spec outgrows the cap. */
|
||||
.demoCode {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--layer-01);
|
||||
max-height: 400px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.demoCode .code {
|
||||
min-width: 0;
|
||||
}
|
||||
.demoChartCard {
|
||||
min-width: 0;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg);
|
||||
box-shadow: 0 16px 40px -20px rgba(0, 0, 0, 0.3);
|
||||
padding: var(--space-5);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
/* Fixed-size composed charts (the facet demo) scroll inside the card rather
|
||||
than widening the page on small screens. */
|
||||
overflow-x: auto;
|
||||
}
|
||||
.demoHint {
|
||||
margin: var(--space-4) 0 0;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.appBody {
|
||||
grid-template-columns: 1fr;
|
||||
@@ -612,11 +870,24 @@
|
||||
.code {
|
||||
max-height: 320px;
|
||||
}
|
||||
/* minmax(0, …): a plain 1fr track lets pre-formatted code set the column's
|
||||
min-content width and push the page wider than the viewport. */
|
||||
.cap,
|
||||
.creedGrid {
|
||||
grid-template-columns: 1fr;
|
||||
.creedGrid,
|
||||
.demoRow,
|
||||
.objections,
|
||||
.gallery {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: var(--space-6);
|
||||
}
|
||||
.demoCode {
|
||||
max-height: 300px;
|
||||
}
|
||||
/* Section anchors don't fit a phone-width nav row; the page is one scroll
|
||||
anyway — keep brand, theme toggle, and the CTA. */
|
||||
.navLink {
|
||||
display: none;
|
||||
}
|
||||
.capRev .capText {
|
||||
order: 0;
|
||||
}
|
||||
|
||||
+353
-79
@@ -27,10 +27,18 @@ import {
|
||||
} from '@core/sample-dataset';
|
||||
import { DEMO_CUSTOM_THEMES } from './demo-themes';
|
||||
import { LandingChart } from './LandingChart';
|
||||
import {
|
||||
SHOWCASE_DEMOS,
|
||||
showcaseDisplaySpec,
|
||||
showcaseFitMode,
|
||||
type ShowcaseDemo,
|
||||
} from './showcase-specs';
|
||||
import styles from './Landing.module.css';
|
||||
|
||||
// TODO: import { UiTheme } from '@core/theme' instead of redeclaring it — the
|
||||
// learn entry already uses the canonical one.
|
||||
// TODO: this file is the repo's second-largest; on the next added section, split
|
||||
// into per-section files under src/landing/ (eng-council 2026-07).
|
||||
type UiTheme = 'light' | 'dark';
|
||||
|
||||
// Minimal JSON syntax highlighter for the read-only spec view: keys, strings,
|
||||
@@ -85,12 +93,41 @@ function Brand(): ReactNode {
|
||||
);
|
||||
}
|
||||
|
||||
/** The framed app-window chrome (traffic-dot title bar) every section shot sits in. */
|
||||
function Shot({ bar, children }: { bar: ReactNode; children: ReactNode }): ReactNode {
|
||||
return (
|
||||
<div className={styles.shot}>
|
||||
<div className={styles.shotBar}>
|
||||
<span className={styles.dots}>
|
||||
<i />
|
||||
<i />
|
||||
<i />
|
||||
</span>{' '}
|
||||
{bar}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Hero: switch snippets, the editor + chart follow ────────────────────────────
|
||||
|
||||
function HeroAppWindow({ theme }: { theme: UiTheme }): ReactNode {
|
||||
const [selected, setSelected] = useState(0);
|
||||
// Open on the brushed scatter: the first thing a visitor sees is a chart that
|
||||
// responds to them — the "this is live" argument made without words.
|
||||
const [selected, setSelected] = useState(() =>
|
||||
Math.max(
|
||||
0,
|
||||
CHART_EXAMPLES.findIndex((e) => e.id === 'brush'),
|
||||
),
|
||||
);
|
||||
const example = CHART_EXAMPLES[selected];
|
||||
const config = useMemo(() => chartConfigForSelection('astrolabe', theme), [theme]);
|
||||
// A demo custom theme (not stock blue): the window is also the first proof
|
||||
// that charts here look designed out of the box.
|
||||
const config = useMemo(
|
||||
() => chartConfigForSelection(customThemeSelection(1), theme, DEMO_CUSTOM_THEMES),
|
||||
[theme],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.appWin}>
|
||||
@@ -130,7 +167,12 @@ function HeroAppWindow({ theme }: { theme: UiTheme }): ReactNode {
|
||||
<JsonCode spec={example.spec} />
|
||||
</div>
|
||||
<div className={styles.col}>
|
||||
<div className={styles.colHead}>Preview</div>
|
||||
<div className={styles.colHead}>
|
||||
Preview
|
||||
<a className={styles.colHeadLink} href={`/app/#example-${example.id}`}>
|
||||
Open in Astrolabe →
|
||||
</a>
|
||||
</div>
|
||||
<div className={styles.preview}>
|
||||
<LandingChart spec={example.spec} config={config} />
|
||||
</div>
|
||||
@@ -140,6 +182,136 @@ function HeroAppWindow({ theme }: { theme: UiTheme }): ReactNode {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Showcase: live spec ↔ chart pairs — "a page of JSON is an interactive chart" ─
|
||||
|
||||
function ShowcaseDemoRow({ demo, theme }: { demo: ShowcaseDemo; theme: UiTheme }): ReactNode {
|
||||
const config = useMemo(
|
||||
() => chartConfigForSelection(customThemeSelection(demo.themeId), theme, DEMO_CUSTOM_THEMES),
|
||||
[demo.themeId, theme],
|
||||
);
|
||||
return (
|
||||
<div className={styles.demo}>
|
||||
<h3 className={styles.demoTitle}>{demo.title}</h3>
|
||||
<p className={styles.demoBlurb}>{demo.blurb}</p>
|
||||
<div className={styles.demoRow}>
|
||||
{/* The displayed spec references data by name — the app's idiom — so the
|
||||
text stays the length of the idea, not of the rows (showcase-specs.ts). */}
|
||||
<div className={styles.demoCode}>
|
||||
<JsonCode spec={showcaseDisplaySpec(demo)} />
|
||||
</div>
|
||||
<div className={styles.demoChartCard}>
|
||||
<LandingChart spec={demo.spec} config={config} fitMode={showcaseFitMode(demo)} />
|
||||
<p className={styles.demoHint}>{demo.hint}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ShowcaseSection({ theme }: { theme: UiTheme }): ReactNode {
|
||||
return (
|
||||
<section className={styles.showcase} id="capabilities">
|
||||
<div className={styles.showcaseIntro}>
|
||||
<div className={styles.capEyebrow}>Charts as text</div>
|
||||
<h2 className={styles.capH}>
|
||||
A page of JSON is <b>an interactive chart.</b>
|
||||
</h2>
|
||||
<p className={styles.capP}>
|
||||
Vega-Lite is a grammar: declare what the data is and how it maps to marks, and the
|
||||
rendering — axes, legends, interaction — follows. The three charts below are live, and the
|
||||
text beside each one is its whole program — the data referenced by name, the way a snippet
|
||||
in Astrolabe reads.
|
||||
</p>
|
||||
</div>
|
||||
{SHOWCASE_DEMOS.map((demo) => (
|
||||
<ShowcaseDemoRow key={demo.id} demo={demo} theme={theme} />
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Editor proof: a staged (no-Monaco) still of the editor mid-thought ──────────
|
||||
// The landing must not import Monaco (post-build gate keeps `/` light), so the
|
||||
// editor's intelligence is shown as a hand-built still: schema autocomplete with
|
||||
// inline docs, a CodeLens scaffold row, and a validation catch. Purely
|
||||
// presentational; the real behaviors live in the app (arch 08).
|
||||
|
||||
function EditorProof(): ReactNode {
|
||||
return (
|
||||
<Shot bar="revenue-by-quarter.vl.json · draft">
|
||||
<div className={styles.edBody} aria-hidden="true">
|
||||
<pre className={styles.edLines}>
|
||||
{/* Real CodeLens labels (spec-param-scaffold / spec-transform-scaffold),
|
||||
separated the way Monaco separates lenses. */}
|
||||
<span className={styles.edLens}>
|
||||
+ slider | + dropdown | + point | + interval | + Add transform
|
||||
</span>
|
||||
{'{\n'}
|
||||
{' '}
|
||||
<span className={styles.tokKey}>"data"</span>
|
||||
{': { '}
|
||||
<span className={styles.tokKey}>"name"</span>
|
||||
{': '}
|
||||
<span className={styles.tokStr}>"sales-2025"</span>
|
||||
{' },\n'}
|
||||
{' '}
|
||||
<span className={styles.tokKey}>"mark"</span>
|
||||
{': { '}
|
||||
<span className={styles.tokKey}>"type"</span>
|
||||
{': '}
|
||||
<span className={styles.tokStr}>"area"</span>
|
||||
{', '}
|
||||
<span className={styles.tokKey}>"inter</span>
|
||||
<span className={styles.edCursor} />
|
||||
</pre>
|
||||
<div className={styles.edPopup}>
|
||||
<ul className={styles.edOptions}>
|
||||
<li className={styles.edOptionSel}>interpolate</li>
|
||||
<li>invalid</li>
|
||||
<li>innerRadius</li>
|
||||
</ul>
|
||||
<div className={styles.edDoc}>
|
||||
<b>interpolate</b> · string
|
||||
<br />
|
||||
The line interpolation method for line and area marks: "linear", "monotone",
|
||||
"step-after"…
|
||||
</div>
|
||||
</div>
|
||||
<pre className={styles.edLines}>
|
||||
{' '}
|
||||
<span className={styles.tokKey}>"encoding"</span>
|
||||
{': {\n'}
|
||||
{' '}
|
||||
<span className={styles.tokKey}>"x"</span>
|
||||
{': { '}
|
||||
<span className={styles.tokKey}>"field"</span>
|
||||
{': '}
|
||||
<span className={styles.tokStr}>"quarter"</span>
|
||||
{' },\n'}
|
||||
{' '}
|
||||
<span className={styles.tokKey}>"color"</span>
|
||||
{': { '}
|
||||
<span className={styles.tokKey}>"field"</span>
|
||||
{': '}
|
||||
<span className={styles.tokStr}>"region"</span>
|
||||
{', '}
|
||||
<span className={styles.tokKey}>"type"</span>
|
||||
{': '}
|
||||
<span className={`${styles.tokStr} ${styles.edSquiggle}`}>"nomnal"</span>
|
||||
{' }\n'}
|
||||
{' }\n'}
|
||||
{'}'}
|
||||
</pre>
|
||||
{/* Monaco's marker hover, with the JSON language service's actual message
|
||||
shape — the editor has no separate problems strip. */}
|
||||
<div className={styles.edHover}>
|
||||
Value is not accepted. Valid values: "nominal", "ordinal", "quantitative", "temporal".
|
||||
</div>
|
||||
</div>
|
||||
</Shot>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Chart Builder demo: pick fields, a mark, or an intent; the chart rebuilds ────
|
||||
|
||||
const MARK_OPTIONS: ReadonlyArray<{ type: MarkType; label: string }> = [
|
||||
@@ -175,12 +347,34 @@ function columnType(name: string): (typeof BUILDER_TYPES)[number]['type'] {
|
||||
function fieldMapping(name: string): ChannelMapping {
|
||||
return { field: name, type: defaultFieldType(columnType(name)) };
|
||||
}
|
||||
/** A summed measure — rows repeat per (month, region, channel), so an
|
||||
unaggregated y reads as noise on every mark but the scatter. */
|
||||
function summed(name: string): ChannelMapping {
|
||||
return { ...fieldMapping(name), aggregate: 'sum' };
|
||||
}
|
||||
|
||||
// Each mark tab shows an archetype that makes sense for sales-2024 — switching
|
||||
// the mark alone would leave mappings that read as noise (a temporal bar stack,
|
||||
// an unaggregated line). The demo must never show a meaningless chart; the
|
||||
// selects below stay free for exploration.
|
||||
const MARK_PRESET_ENCODINGS: Partial<Record<MarkType, Record<string, ChannelMapping>>> = {
|
||||
bar: { x: fieldMapping('region'), y: summed('revenue'), color: fieldMapping('region') },
|
||||
line: { x: fieldMapping('month'), y: summed('revenue'), color: fieldMapping('region') },
|
||||
point: { x: fieldMapping('units'), y: fieldMapping('revenue'), color: fieldMapping('region') },
|
||||
area: { x: fieldMapping('month'), y: summed('revenue'), color: fieldMapping('region') },
|
||||
};
|
||||
|
||||
function markPreset(mark: MarkType): BuilderConfig {
|
||||
const base = defaultBuilderConfig(SAMPLE_DATASET_NAME, SAMPLE_DATASET_COLUMNS);
|
||||
const preset = MARK_PRESET_ENCODINGS[mark];
|
||||
return { ...base, mark, encodings: preset ? { ...base.encodings, ...preset } : base.encodings };
|
||||
}
|
||||
|
||||
function BuilderDemo({ theme }: { theme: UiTheme }): ReactNode {
|
||||
const columns = SAMPLE_DATASET_COLUMNS;
|
||||
const [config, setConfig] = useState<BuilderConfig>(() =>
|
||||
defaultBuilderConfig(SAMPLE_DATASET_NAME, columns),
|
||||
);
|
||||
// First frame: the line archetype (revenue over time by region) — the demo's
|
||||
// opening state is its argument.
|
||||
const [config, setConfig] = useState<BuilderConfig>(() => markPreset('line'));
|
||||
const active = activeIntent(config, columns);
|
||||
const chartConfig = useMemo(() => chartConfigForSelection('astrolabe', theme), [theme]);
|
||||
const spec = useMemo(() => {
|
||||
@@ -205,7 +399,7 @@ function BuilderDemo({ theme }: { theme: UiTheme }): ReactNode {
|
||||
type="button"
|
||||
aria-pressed={config.mark === m.type}
|
||||
className={config.mark === m.type ? styles.on : undefined}
|
||||
onClick={() => setConfig({ ...config, mark: m.type })}
|
||||
onClick={() => setConfig(markPreset(m.type))}
|
||||
>
|
||||
{m.label}
|
||||
</button>
|
||||
@@ -235,7 +429,11 @@ function BuilderDemo({ theme }: { theme: UiTheme }): ReactNode {
|
||||
'y',
|
||||
e.target.value === COUNT
|
||||
? { type: 'quantitative', aggregate: 'count' }
|
||||
: fieldMapping(e.target.value),
|
||||
: // Rows repeat per (month, region, channel): keep measures summed
|
||||
// everywhere except the scatter, which shows the raw rows.
|
||||
config.mark === 'point'
|
||||
? fieldMapping(e.target.value)
|
||||
: summed(e.target.value),
|
||||
)
|
||||
}
|
||||
>
|
||||
@@ -292,7 +490,25 @@ const THEME_CHOICES: ReadonlyArray<{ selection: ChartThemeSelection; label: stri
|
||||
{ selection: 'vox', label: 'Vox' },
|
||||
{ selection: 'dark', label: 'Vega Dark' },
|
||||
];
|
||||
const GALLERY = THEME_PREVIEW_SPECS.filter((s) => ['bar', 'line', 'scatter'].includes(s.id));
|
||||
// The core bar preview deliberately carries no colour encoding (it exercises
|
||||
// title/axes/grid, so bars take the mark default) — on a marketing page that
|
||||
// reads as "default blue". Give the landing's copy a colour channel so every
|
||||
// gallery chart shows the selected theme's palette.
|
||||
const GALLERY = THEME_PREVIEW_SPECS.filter((s) => ['bar', 'line', 'scatter'].includes(s.id)).map(
|
||||
(s) =>
|
||||
s.id === 'bar'
|
||||
? {
|
||||
...s,
|
||||
spec: {
|
||||
...s.spec,
|
||||
encoding: {
|
||||
...(s.spec.encoding as Record<string, unknown>),
|
||||
color: { field: 'region', type: 'nominal', legend: null },
|
||||
},
|
||||
},
|
||||
}
|
||||
: s,
|
||||
);
|
||||
|
||||
function ThemeDemo({ theme }: { theme: UiTheme }): ReactNode {
|
||||
const [selection, setSelection] = useState<ChartThemeSelection>(customThemeSelection(1));
|
||||
@@ -316,6 +532,10 @@ function ThemeDemo({ theme }: { theme: UiTheme }): ReactNode {
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className={styles.themeNote}>
|
||||
Editorial, Blueprint, and Sunset are custom themes made in the Theme Builder; the rest ship
|
||||
as presets.
|
||||
</p>
|
||||
<div className={styles.gallery}>
|
||||
{GALLERY.map((ps) => (
|
||||
<div key={ps.id} className={styles.mini}>
|
||||
@@ -344,6 +564,9 @@ export function Landing(): ReactNode {
|
||||
<div className={`${styles.wrap} ${styles.navIn}`}>
|
||||
<Brand />
|
||||
<span className={styles.navSpacer} />
|
||||
<a className={styles.navLink} href="#capabilities">
|
||||
What it can do
|
||||
</a>
|
||||
<a className={styles.navLink} href="#author">
|
||||
Authoring
|
||||
</a>
|
||||
@@ -368,11 +591,12 @@ export function Landing(): ReactNode {
|
||||
<header className={styles.hero}>
|
||||
<div className={styles.wrap}>
|
||||
<h1>
|
||||
A home for your <b>Vega-Lite charts.</b>
|
||||
Charts written as text. <b>A home to keep them.</b>
|
||||
</h1>
|
||||
<p>
|
||||
Astrolabe is a local studio for Vega-Lite. Write a spec by hand or build one by
|
||||
clicking, give it a theme, and keep all your charts in one searchable library.
|
||||
Astrolabe is a local studio for Vega-Lite — the grammar that turns a short JSON spec
|
||||
into an interactive chart. Write specs by hand or build them by clicking, give them a
|
||||
theme, and keep every chart in one searchable library.
|
||||
</p>
|
||||
<div className={styles.heroCta}>
|
||||
<a className={`${styles.btn} ${styles.btnPrimary}`} href="/app/">
|
||||
@@ -387,44 +611,89 @@ export function Landing(): ReactNode {
|
||||
<div className={styles.wrap}>
|
||||
<HeroAppWindow theme={theme} />
|
||||
<p className={styles.stageHint}>
|
||||
Pick a snippet on the left — the editor and chart follow.
|
||||
This is the real thing, not a screenshot: pick a snippet on the left, drag across the
|
||||
brushed scatter — it's Vega rendering live.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className={styles.wrap}>
|
||||
<ShowcaseSection theme={theme} />
|
||||
<section className={styles.cap} id="author">
|
||||
<div className={styles.capText}>
|
||||
<div className={styles.capEyebrow}>Two ways in</div>
|
||||
<div className={styles.capEyebrow}>For the spec author</div>
|
||||
<h2 className={styles.capH}>
|
||||
Write the spec, or <b>build it by clicking.</b>
|
||||
An editor that <b>knows Vega-Lite.</b>
|
||||
</h2>
|
||||
<p className={styles.capP}>
|
||||
The editor is Monaco with the Vega-Lite schema loaded, so you get validation,
|
||||
autocompletion, and inline docs without going online. Each chart keeps an editable
|
||||
draft alongside a published version you can revert to.
|
||||
The editor is Monaco with the Vega-Lite schema loaded: autocompletion, inline docs,
|
||||
and validation as you type, all of it offline. One click scaffolds a working parameter
|
||||
or data transform to build on.
|
||||
</p>
|
||||
<p className={styles.capP}>
|
||||
If you'd rather not start from JSON, the builder works from the kind of chart you
|
||||
want. Pick fields, a mark, or a whole intent — it writes the spec for you. Try it:
|
||||
When a chart misbehaves, the data inspector shows the rows going in and the rows the
|
||||
marks actually draw — the fastest answer to "why is my chart empty". And every snippet
|
||||
keeps an editable draft alongside its published version, so you can always revert to
|
||||
the last good chart.
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.shot}>
|
||||
<div className={styles.shotBar}>
|
||||
<span className={styles.dots}>
|
||||
<i />
|
||||
<i />
|
||||
<i />
|
||||
</span>{' '}
|
||||
Chart Builder · {SAMPLE_DATASET_NAME}
|
||||
<EditorProof />
|
||||
</section>
|
||||
|
||||
{/* The habit-cluster objections, answered where the skeptic is scrolling
|
||||
(landing-onboarding scope memo): each answer is an on-ramp, not a rebuttal. */}
|
||||
<div className={styles.objections}>
|
||||
<div>
|
||||
<h3>Already using the Vega editor?</h3>
|
||||
<p>
|
||||
It's a fine scratchpad — one spec at a time, nothing kept. Astrolabe is the home: a
|
||||
library of specs with their datasets, themes, and drafts.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3>Writing charts in Altair or another wrapper?</h3>
|
||||
<p>
|
||||
A wrapper's output already is a Vega-Lite spec. Paste it in, polish the last mile
|
||||
(label formats, axis tweaks you'd otherwise translate back through the wrapper's API),
|
||||
and keep the result.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3>Asking an AI for specs?</h3>
|
||||
<p>
|
||||
Paste what it gave you: live preview, schema validation, and the data inspector are
|
||||
where an almost-right spec becomes right. There is no AI inside Astrolabe to see your
|
||||
data.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className={`${styles.cap} ${styles.capRev}`} id="builder">
|
||||
<div className={styles.capText}>
|
||||
<div className={styles.capEyebrow}>Rather not start from JSON?</div>
|
||||
<h2 className={styles.capH}>
|
||||
The builder writes <b>the spec for you.</b>
|
||||
</h2>
|
||||
<p className={styles.capP}>
|
||||
Point it at your data and work from the kind of chart you want: pick fields, a mark,
|
||||
or a whole intent (compare, over time, distribution), and it writes ordinary Vega-Lite
|
||||
— the same spec you'd have written by hand, ready to keep clicking on or to open in
|
||||
the editor.
|
||||
</p>
|
||||
<p className={styles.capP}>
|
||||
<a className={styles.inlineLink} href="/app/#build">
|
||||
Open the Chart Builder →
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<Shot bar={<>Chart Builder · {SAMPLE_DATASET_NAME}</>}>
|
||||
<div className={styles.shotBody}>
|
||||
<BuilderDemo theme={theme} />
|
||||
</div>
|
||||
</div>
|
||||
</Shot>
|
||||
</section>
|
||||
|
||||
<section className={`${styles.cap} ${styles.capRev}`} id="library">
|
||||
<section className={styles.cap} id="library">
|
||||
<div className={styles.capText}>
|
||||
<div className={styles.capEyebrow}>One library</div>
|
||||
<h2 className={styles.capH}>
|
||||
@@ -435,21 +704,27 @@ export function Landing(): ReactNode {
|
||||
it and Astrolabe updates every chart that used it.
|
||||
</p>
|
||||
<p className={styles.capP}>
|
||||
Load data by pasting CSV or JSON, or by fetching a URL. You can also lift inline data
|
||||
out of a spec into a shared dataset. Search and sort as the collection grows, and
|
||||
duplicate a snippet to start a variant.
|
||||
Paste CSV or JSON, fetch a URL, or lift the inline data already sitting in a spec out
|
||||
into a shared dataset. Search and sort as the collection grows, and duplicate a
|
||||
snippet to start a variant.
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.shot}>
|
||||
<div className={styles.shotBar}>
|
||||
<span className={styles.dots}>
|
||||
<i />
|
||||
<i />
|
||||
<i />
|
||||
</span>{' '}
|
||||
Datasets
|
||||
</div>
|
||||
<Shot bar="Datasets">
|
||||
<div className={`${styles.shotBody} ${styles.shotBodyFlush}`}>
|
||||
{/* Store-once-reference-many, as a picture: one dataset, the
|
||||
snippets that read it hanging off it. */}
|
||||
<div className={styles.refDiagram} aria-hidden="true">
|
||||
<div className={styles.refDataset}>
|
||||
<span className={styles.refDot} /> sales-2025
|
||||
<span className={styles.refMeta}> — one dataset, stored once</span>
|
||||
</div>
|
||||
<ul className={styles.refTree}>
|
||||
<li>Revenue by quarter</li>
|
||||
<li>Growth trend</li>
|
||||
<li>Regional split</li>
|
||||
<li className={styles.refMore}>…rename it, and every chart follows</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className={styles.row}>
|
||||
<div className={styles.rowName}>
|
||||
sales-2025 <span className={styles.rowSub}>· CSV · 1,204 rows</span>
|
||||
@@ -484,62 +759,52 @@ export function Landing(): ReactNode {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Shot>
|
||||
</section>
|
||||
|
||||
<section className={styles.cap} id="theme">
|
||||
{/* Stacked (text above, demo full width): the gallery needs the whole
|
||||
column for three charts to breathe. */}
|
||||
<section className={styles.capStack} id="theme">
|
||||
<div className={styles.capText}>
|
||||
<div className={styles.capEyebrow}>Make it yours</div>
|
||||
<h2 className={styles.capH}>
|
||||
Give your charts a <b>look of their own.</b>
|
||||
</h2>
|
||||
<p className={styles.capP}>
|
||||
Sixteen presets to start from, or build your own in the Theme Builder — colour, type,
|
||||
axes, legend, and layout. The first three here are built from scratch. Try a few:
|
||||
Upload your own fonts (variable fonts included) and apply one across a whole theme at
|
||||
once. Sixteen presets to start from, or build your own in the Theme Builder: colour,
|
||||
type, axes, legends, marks, titles, number formats.
|
||||
</p>
|
||||
<p className={styles.capP}>
|
||||
Upload your own fonts, variable fonts included, and apply one across a whole theme at
|
||||
once. Save the theme and use it on any chart.
|
||||
Save a theme and use it on any chart. The first three here are built from scratch. Try
|
||||
a few:
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.shot}>
|
||||
<div className={styles.shotBar}>
|
||||
<span className={styles.dots}>
|
||||
<i />
|
||||
<i />
|
||||
<i />
|
||||
</span>{' '}
|
||||
Theme Builder
|
||||
</div>
|
||||
<Shot bar="Theme Builder">
|
||||
<div className={styles.shotBody}>
|
||||
<ThemeDemo theme={theme} />
|
||||
</div>
|
||||
</div>
|
||||
</Shot>
|
||||
</section>
|
||||
|
||||
<section className={`${styles.cap} ${styles.capRev}`} id="export">
|
||||
<section className={styles.cap} id="export">
|
||||
<div className={styles.capText}>
|
||||
<div className={styles.capEyebrow}>Exporting</div>
|
||||
<div className={styles.capEyebrow}>Getting it out</div>
|
||||
<h2 className={styles.capH}>
|
||||
Save a chart in the <b>format you need.</b>
|
||||
Exports with <b>the settings you reach for.</b>
|
||||
</h2>
|
||||
<p className={styles.capP}>
|
||||
Export as a PNG at 1–3×, as an SVG, or as a Vega-Lite spec. The spec can carry its
|
||||
data inline, so the file renders on its own wherever it lands.
|
||||
Every embedded Vega chart ships the same tiny menu (save PNG, save SVG). Astrolabe's
|
||||
export adds the settings that menu is missing: 1–3× 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>
|
||||
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## + 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" }
|
||||
}
|
||||
},
|
||||
"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}] }
|
||||
{
|
||||
"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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)),
|
||||
|
||||
Reference in New Issue
Block a user