Compare commits

..

27 Commits

Author SHA1 Message Date
oleh 552900dac5 Build: landing/learn shed eager Monaco+Vega; post-build gate + immutable asset caching 2026-07-02 22:34:24 +03:00
oleh fb4b42a1a7 Docs: record the spec-follows-code regime across the working docs 2026-07-02 22:33:05 +03:00
oleh e42a535726 Editor: CodeLens + completion to scaffold Vega-Lite params; shared snippet core 2026-07-02 22:15:50 +03:00
oleh c5e4c4c76d Editor: cleaner transform-scaffold insertion
Insert the empty transform array inline (transform: []) instead of a multi-line
block, and pass adjustWhitespace:false to Monaco's snippet engine so it stops
re-basing the explicit indentation. The multi-line form left the closing bracket
under-indented and a stray blank line; the inline form lands cleanly indented as
the view's first property, with the first step filling it in place.
2026-07-01 16:00:49 +03:00
oleh 87b1b98c12 Editor: CodeLens to scaffold Vega-Lite data transforms
Add a discoverable '+ Add transform' / '+ filter/aggregate/...' CodeLens in the
spec editor that scaffolds field-typed transform[] steps, with a type-to-filter
completion on the same catalog as an accelerator. New pure core
(spec-data-transforms): step-slot and view-site classification, the field-typed
catalog, and comma affixing. Deliberately the one home the schema leaves bare —
inline channel transforms and the transform key stay with the JSON schema.

Rename the composition toolbar menu Transform -> Compose so it reads distinctly
from a data transform. Deferred (recorded at their sites): extract a shared
cursor-lens skeleton across the two installers; a shared completion replace-range
helper (now 3 sites); params scaffolding as the sibling feature.
2026-07-01 15:43:48 +03:00
oleh da6a675982 Editor: Vega-Lite expression intelligence; single-home render errors 2026-07-01 05:00:47 +03:00
oleh 9b2618cac6 Docs: deferred review findings get a durable breadcrumb, not a chat mention 2026-06-30 17:37:37 +03:00
oleh a8696f7195 Editor: TODO to lift the wireframe drag-math into core (eng-council) 2026-06-30 17:00:51 +03:00
oleh c019660692 Editor: composition wireframe — pull-out, stacking, and simplify 2026-06-30 15:42:22 +03:00
oleh 26b383ff33 Editor: render a layer as one framed row of mark glyphs
A layer is one plotting space with several marks stacked in z-order, so it now renders as a single frame holding its child marks as a row of glyphs, badged with a new 'layers' glyph — instead of offset stacked cards that read as separate spaces and hid the lower glyphs. Each mark stays an individual treeitem, so selection and Alt+up/down z-order reorder are unchanged.
2026-06-29 14:59:01 +03:00
oleh 216797ff9b Editor: interactive composition wireframe — drag to reorder and restructure
Drag a view's box (or Alt+up/down) to reorder it within its container; drag onto another view's edge to pair the two in a new row/column, move across containers, or insert. Each leaf shows a glyph of its mark type.

Core: spec-restructure.wrapViews (wrap, with flatten-to-insert, collapse, and data-pin invariants) and spec-insert.moveViewTo; the editor applies every drag as one undoable edit via AppStore.composeRequest, keeping the editor the single text source.
2026-06-29 14:25:31 +03:00
oleh 57604a80a6 Editor: composition structure wireframe (read-only) 2026-06-29 12:39:59 +03:00
oleh 345ecef5a3 Docs: explore visual composition editing (wireframe + on-chart overlay) 2026-06-29 12:39:40 +03:00
oleh 4d1825b5ff Editor: cursor-scoped Add view and reorder for composition views 2026-06-29 11:06:56 +03:00
oleh 01df58acf1 Editor: field hints show the column's raw data type, not the encoding type 2026-06-29 11:06:37 +03:00
oleh 8aa05e503d Inspector: live data updates under interactive selections 2026-06-29 09:58:32 +03:00
oleh 6656811b8e Editor: view-scoped Extract-to-Dataset for inline and self-defined data 2026-06-29 02:08:59 +03:00
oleh 8cad80f738 Inspector: multi-view data inspection with a per-table view picker 2026-06-28 22:16:59 +03:00
oleh a75ea5b59e Editor: view-scoped data context — per-view dataset columns in hints and transforms 2026-06-28 20:54:46 +03:00
oleh e69430834a Core: classify spec data blocks by Vega-Lite fidelity (spec-data) for dataset refs 2026-06-28 19:48:24 +03:00
oleh 31458114fb Editor: spec transforms (wrap/simplify/add-view) and dataset-aware hints 2026-06-28 16:56:20 +03:00
oleh 20e70bee0e Learn: per-lesson URLs, a :::data shared-dataset construct, and a linked-views deep dive 2026-06-28 12:34:30 +03:00
oleh fd5c51a761 Copy: state the AI-free stance as a confidentiality benefit; record the exploration 2026-06-27 23:11:20 +03:00
oleh 0c592fa81e Plan: add arch 11 (Learning Section) to the reference table 2026-06-27 22:38:57 +03:00
oleh 64977f961b Docs: field guide on embedding Vega-Lite in a live app 2026-06-26 23:17:00 +03:00
oleh fff7ae98f2 Plan: mark M0–M6 complete, replace milestone detail with a post-M6 backlog 2026-06-26 23:15:55 +03:00
oleh 43d3b480d9 Copy: pare product claims to what we can certify, cut rule-of-three cadence, record arch 10 §10 2026-06-26 23:09:47 +03:00
130 changed files with 13572 additions and 1610 deletions
+34 -10
View File
@@ -12,7 +12,7 @@ project's spec (`docs/spec/`) and architecture playbook (`docs/architecture/`).
This skill is also executed by a **clean-context subagent** at session wrap-up (see This skill is also executed by a **clean-context subagent** at session wrap-up (see
CLAUDE.md → Session wrap-up protocol). When running as that subagent: you deliberately CLAUDE.md → Session wrap-up protocol). When running as that subagent: you deliberately
have no session context — judge the diff against the written contracts only, and return have no session context — judge the diff against the written contracts only, and return
the summary (rule #15) as your final message so the session agent can relay it. If a the summary (rule #18) as your final message so the session agent can relay it. If a
change looks deliberate but its rationale is recorded nowhere, that absence is itself a change looks deliberate but its rationale is recorded nowhere, that absence is itself a
finding. finding.
@@ -37,8 +37,11 @@ Review all changes in scope. If changes span multiple patterns below, apply all
3. **Fix directly; don't ask first.** When you find an issue covered by these instructions, 3. **Fix directly; don't ask first.** When you find an issue covered by these instructions,
fix it in place rather than reporting it and waiting. Ask the user only when the fix is fix it in place rather than reporting it and waiting. Ask the user only when the fix is
genuinely ambiguous or several valid approaches exist with real trade-offs. When guidelines genuinely ambiguous or several valid approaches exist with real trade-offs. When guidelines
conflict, prefer in this order: **SOUL.md philosophy > `docs/spec/` behavioral contract > conflict, prefer in this order: **SOUL.md philosophy > `docs/spec/` behavioral record >
`docs/architecture/` patterns > local cleanup**. These instructions are not strictly `docs/architecture/` patterns > local cleanup**. (The spec is descriptive — the code
leads. A spec/code mismatch is fixed by updating the stale spec section, not by
reverting the code; only flag the code when it contradicts recorded _rationale_, not
merely an unrewritten section.) These instructions are not strictly
prohibitive — if a guideline has a valid reason to be bypassed, mention it in the summary. prohibitive — if a guideline has a valid reason to be bypassed, mention it in the summary.
### Code Quality ### Code Quality
@@ -155,7 +158,11 @@ role`) or the rule it demonstrates. - **Positional sub-section cross-refs.** Cit
14. **User-facing copy**: keep user-visible strings centralized and written for users (sentence 14. **User-facing copy**: keep user-visible strings centralized and written for users (sentence
case, active voice, no "please", no exclamation marks in errors). If/when an i18n layer case, active voice, no "please", no exclamation marks in errors). If/when an i18n layer
exists, route strings through it instead of hardcoding. exists, route strings through it instead of hardcoding. **Product claims** (landing,
About, onboarding, value props) follow `arch 10 §10`: claim only what we can certify, no
absolutes (never/always/fully/everything), no durability the platform doesn't back ("saved",
not "permanent"), and state a posture once per surface — reduce uncertain promises, keep the
real ones.
15. **Chart-builder guidance reasons over role, not raw type** (`src/core/chart-builder.ts`): 15. **Chart-builder guidance reasons over role, not raw type** (`src/core/chart-builder.ts`):
a `builderWarnings` rule (or any measure/dimension decision) must ask the post-transform a `builderWarnings` rule (or any measure/dimension decision) must ask the post-transform
@@ -167,9 +174,26 @@ role`) or the rule it demonstrates. - **Positional sub-section cross-refs.** Cit
high-precision: prefer structural/data-driven hints; lean on the intent front door + smart high-precision: prefer structural/data-driven hints; lean on the intent front door + smart
defaults for positive guidance rather than enumerating bad combinations. defaults for positive guidance rather than enumerating bad combinations.
16. **Editor transform-actions reuse an applier, not an inlined skeleton**
(`src/app/services/spec-transform-actions.ts`): a new `run*` action shaped
parse → `build(spec)``writeBack` (info toast on null) calls the matching shared
applier — `resolveTarget` (scoped), `applyArrayEdit` (one array), or `applyWholeSpecEdit`
(whole-spec drag/simplify) — never re-inlining the model/parse/writeBack prologue. The
family has grown by copy-paste twice (eng-council; arch 08).
17. **Spec tracks the surfaces it describes** (`docs/spec/`): a diff that **adds, removes,
moves, or renames a user-facing surface** — a feature, message, control, or affordance —
updates the `docs/spec/` section describing it (adding a section for new behavior), not
only the `docs/architecture/` pattern doc. The spec is the behavioral record and the code
leads; an arch-doc-only update leaves the spec describing a product that no longer exists.
An arch-only update once left spec §03E mandating an editor-pane error message after it had
moved to the preview (eng-council); a run of feature commits (2026-06-25 → 06-30: the
/learn/ section, the composition wireframe, editor scaffolds) once landed with zero spec
coverage (eng-council, 2026-07).
### Output ### Output
16. **Summary**: respond with a summary of changes — choices made due to these instructions, 18. **Summary**: respond with a summary of changes — choices made due to these instructions,
choices where multiple approaches existed, and non-obvious architectural assumptions the choices where multiple approaches existed, and non-obvious architectural assumptions the
user should know but might not spot in the diff. If the summary mentions an observation you user should know but might not spot in the diff. If the summary mentions an observation you
chose not to fix (rule #8), confirm a `// TODO:` breadcrumb was placed at the code site. chose not to fix (rule #8), confirm a `// TODO:` breadcrumb was placed at the code site.
@@ -188,7 +212,7 @@ role`) or the rule it demonstrates. - **Positional sub-section cross-refs.** Cit
Update relevant docs if the feature is significant: Update relevant docs if the feature is significant:
- **`docs/spec/`** — if product behavior changed (this is a contract; change deliberately). - **`docs/spec/`** — if product behavior changed (descriptive record; update it to match what shipped — see #17).
- **`docs/architecture/`** — if a new pattern, navigation map, or decision rule emerged. - **`docs/architecture/`** — if a new pattern, navigation map, or decision rule emerged.
- **`docs/IMPLEMENTATION-PLAN.md`** — mark milestone progress. - **`docs/IMPLEMENTATION-PLAN.md`** — mark milestone progress.
Use the `/doc-update` skill for session-discovered gaps. The list is not exclusive. Use the `/doc-update` skill for session-discovered gaps. The list is not exclusive.
@@ -208,7 +232,7 @@ If `package.json` changed:
### Alignment Check ### Alignment Check
- **SOUL.md** — philosophy (must not violate without good reason). - **SOUL.md** — philosophy (must not violate without good reason).
- **`docs/spec/`** — behavioral contract. - **`docs/spec/`** — behavioral record.
- **`docs/architecture/`** — the relevant pattern doc. - **`docs/architecture/`** — the relevant pattern doc.
--- ---
@@ -227,7 +251,7 @@ Usually not required unless the bug revealed incorrect docs, or the fix changes
### Alignment Check ### Alignment Check
- **SOUL.md** philosophy; **`docs/spec/`** behavioral contract; **`docs/architecture/`** patterns. - **SOUL.md** philosophy; **`docs/spec/`** behavioral record; **`docs/architecture/`** patterns.
--- ---
@@ -269,9 +293,9 @@ Update JSDoc/inline comments if signatures or behavior changed.
## Reference Documents ## Reference Documents
| Document | Purpose | | Document | Purpose |
| ------------------------------------------------------------------- | ----------------------------------------- | | ------------------------------------------------------------------- | ---------------------------------------- |
| [SOUL.md](../../../SOUL.md) | Project philosophy and core values | | [SOUL.md](../../../SOUL.md) | Project philosophy and core values |
| [AGENTS.md](../../../AGENTS.md) | AI onboarding and project context | | [AGENTS.md](../../../AGENTS.md) | AI onboarding and project context |
| [docs/spec/](../../../docs/spec/) | Behavioral contract_what_ the app does | | [docs/spec/](../../../docs/spec/) | Behavioral record_what_ the app does |
| [docs/architecture/](../../../docs/architecture/00-overview.md) | Architecture playbook — _how_ it's built | | [docs/architecture/](../../../docs/architecture/00-overview.md) | Architecture playbook — _how_ it's built |
| [docs/IMPLEMENTATION-PLAN.md](../../../docs/IMPLEMENTATION-PLAN.md) | Milestone sequence and scope | | [docs/IMPLEMENTATION-PLAN.md](../../../docs/IMPLEMENTATION-PLAN.md) | Milestone sequence and scope |
+6 -5
View File
@@ -30,9 +30,10 @@ For documentation organization, see **[CLAUDE.md](../../../CLAUDE.md)** and the
## The three documentation layers (know which one a gap belongs to) ## The three documentation layers (know which one a gap belongs to)
- **`docs/spec/`** — the _what_: behavioral contract (what the app does, acceptance points). - **`docs/spec/`** — the _what_: behavioral record (what the app does, acceptance points).
This is a **contract**. Only change it when product behavior genuinely changes, and do so The code leads; the spec is kept rewritten to match what shipped. If the session added or
deliberately — never as a casual "fill a doc gap" edit. A how-detail does NOT belong here. changed user-facing behavior, name the spec section that describes it — or write it — as
part of this pass. A how-detail does NOT belong here.
- **`docs/architecture/`** — the _how_: the patterns behind each layer (state, persistence, - **`docs/architecture/`** — the _how_: the patterns behind each layer (state, persistence,
modals, routing, rendering, inference, relationships). Most navigation maps and decision modals, routing, rendering, inference, relationships). Most navigation maps and decision
rules land here. rules land here.
@@ -60,8 +61,8 @@ not do Y"). If you can't state it concisely, it may be too implementation-specif
Map each gap to the right document: Map each gap to the right document:
| Gap type | Target document | | Gap type | Target document |
| ------------------------------------------------- | ----------------------------------------------------------------------------- | | ------------------------------------------------- | --------------------------------------------------------------------------------- |
| Product behavior, capabilities, acceptance points | `docs/spec/` (the relevant 0010 section) — **contract; change deliberately** | | Product behavior, capabilities, acceptance points | `docs/spec/` (the relevant 0010 section) — **record; keep it matching the code** |
| State / Zustand stores | `docs/architecture/01-state-and-stores.md` | | State / Zustand stores | `docs/architecture/01-state-and-stores.md` |
| Persistence, IndexedDB, localStorage, migrations | `docs/architecture/02-persistence.md` | | Persistence, IndexedDB, localStorage, migrations | `docs/architecture/02-persistence.md` |
| Modals, dialog lifecycle | `docs/architecture/03-modal-system.md` | | Modals, dialog lifecycle | `docs/architecture/03-modal-system.md` |
+4
View File
@@ -14,3 +14,7 @@ coverage
# M1.5 visual verification screenshots (local only) # M1.5 visual verification screenshots (local only)
.m15-screenshots/ .m15-screenshots/
# Generated per-lesson page shells (learn/<slug>/index.html) — produced from the
# lesson .md frontmatter by scripts/learn-pages.ts on every dev/build.
learn/*/
+9 -6
View File
@@ -11,9 +11,10 @@ a local library of **snippets** (saved Vega-Lite specs), edits each as JSON with
validation and a live chart preview, and reuses **datasets** across many snippets. Fully validation and a live chart preview, and reuses **datasets** across many snippets. Fully
local, offline-capable, no account. local, offline-capable, no account.
It is a **spec-driven rebuild** on an architecture adapted from its sibling project Syto. It is a rebuild on an architecture adapted from its sibling project Syto. **`docs/spec/`**
The authoritative behavioral contract is **`docs/spec/`** (sections 0010). Implement _to (sections 0010) is the behavioral record: the code leads and the spec is kept rewritten
the spec_; do not port legacy code. to match — a user-facing change isn't done until its spec section describes it. Do not
port legacy code.
### Technical Stack ### Technical Stack
@@ -74,7 +75,7 @@ src/
│ └── infrastructure/ # IndexedDB, localStorage, Monaco, settings adapters │ └── infrastructure/ # IndexedDB, localStorage, Monaco, settings adapters
styles/ # Global CSS (tokens, base) styles/ # Global CSS (tokens, base)
docs/ docs/
├── spec/ # Authoritative behavioral specification (0010) — the WHAT ├── spec/ # Behavioral specification (0010) — the WHAT (record; code leads)
├── architecture/ # Architecture playbook (0011) — the HOW (self-contained) ├── architecture/ # Architecture playbook (0011) — the HOW (self-contained)
│ └── visual-specimen.html # Standalone token sandbox + reusable-primitive catalog │ └── visual-specimen.html # Standalone token sandbox + reusable-primitive catalog
├── exploration/ # Point-in-time records (research, reviews, scope memos) — not maintained ├── exploration/ # Point-in-time records (research, reviews, scope memos) — not maintained
@@ -116,8 +117,10 @@ npm run format # Prettier
trimming IBM Plex to the latin subsets dropped Cyrillic — capability for an trimming IBM Plex to the latin subsets dropped Cyrillic — capability for an
internationally-usable app. We ship every script subset and precache them for offline; internationally-usable app. We ship every script subset and precache them for offline;
`unicode-range` means the browser only downloads what a glyph needs anyway.) `unicode-range` means the browser only downloads what a glyph needs anyway.)
- **Spec is the contract** — when in doubt, read `docs/spec/`. If the spec is wrong or - **Spec follows code** — `docs/spec/` is the descriptive record of behavior; the code
silent, raise it; change the spec deliberately rather than drifting from it. 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.
- **Core-first** — for each feature, build the pure `src/core/` logic with tests before UI. - **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 - **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), pass before any commit: `/doc-update` in-session first (flush unrecorded rationale),
+14 -8
View File
@@ -5,8 +5,9 @@ See @AGENTS.md for project overview, architecture rules, and the AI developer pr
## Documentation Index ## Documentation Index
- **[SOUL.md](SOUL.md)** — project philosophy and identity. _Read first._ - **[SOUL.md](SOUL.md)** — project philosophy and identity. _Read first._
- **[docs/spec/](docs/spec/)** — authoritative behavioral specification (sections 0010): - **[docs/spec/](docs/spec/)** — behavioral specification (sections 0010): the **what**.
the **what**. This is the contract; implement to it. Descriptive, kept current with the code: the code leads; on conflict amend the spec,
never drift silently. User-facing behavior ships with its spec section.
- **[docs/architecture/](docs/architecture/00-overview.md)** — architecture playbook - **[docs/architecture/](docs/architecture/00-overview.md)** — architecture playbook
(0011): the **how** (state, persistence, modals, routing, rendering, inference, (0011): the **how** (state, persistence, modals, routing, rendering, inference,
relationships, vega-editor techniques, visual design, interaction & feedback, learning relationships, vega-editor techniques, visual design, interaction & feedback, learning
@@ -31,8 +32,9 @@ See @AGENTS.md for project overview, architecture rules, and the AI developer pr
## Quick Orientation ## Quick Orientation
- Astrolabe is a **spec-driven rebuild** — the behavior is fixed in `docs/spec/`; the - Astrolabe is a **spec-recorded rebuild** — the architecture is adapted from Syto, and
architecture is adapted from Syto. Implement to the spec; don't port legacy code. `docs/spec/` records the behavior as built. Build deliberately, record in the spec;
don't port legacy code.
- **`src/core/` is portable and tested hardest.** Browser specifics live in - **`src/core/` is portable and tested hardest.** Browser specifics live in
`src/app/infrastructure/`. UI is React + Zustand. `src/app/infrastructure/`. UI is React + Zustand.
- **Editor is Monaco**, charts render via **vega-embed**, storage is **IndexedDB**. - **Editor is Monaco**, charts render via **vega-embed**, storage is **IndexedDB**.
@@ -70,7 +72,11 @@ run the review pass **before** anything is committed:
sweeps stay a deliberate act). sweeps stay a deliberate act).
Run the subagents **sequentially**, not in parallel — both may edit the working tree. Run the subagents **sequentially**, not in parallel — both may edit the working tree.
Relay each report back to the user. Arbitrate findings that needed session context: Relay each report back to the user, then arbitrate each finding to one of three ends —
either accept them, or overrule them **and** record the missing rationale where the **accept and fix**, **accept but defer**, or **overrule** — and **write down the two you
reviewer looked for it — an overruled finding without a writing-down will recur. Then don't act on now**, because a subagent's report is ephemeral and chat is not a record. A
commit only when invited. deferred finding gets a `// TODO:` at the relevant code site (or a line in the closest doc);
an overruled one records the missing rationale where the reviewer looked. Out of scope for
_this session_ is not out of scope for the _project_: with a single maintainer there is no
"someone else's problem", so an unrecorded deferral or beyond-scope note recurs as work
handed to your future self. Then commit only when invited.
+26 -5
View File
@@ -2,10 +2,11 @@
A browser-based **snippet manager for [Vega-Lite](https://vega.github.io/vega-lite/) A browser-based **snippet manager for [Vega-Lite](https://vega.github.io/vega-lite/)
visualizations**. Author chart specs as JSON, watch them render live, and keep a personal, visualizations**. Author chart specs as JSON, watch them render live, and keep a personal,
searchable library — fully local, offline-capable, no account. searchable library — local, offline-capable, no account.
> Astrolabe is a **spec-driven rebuild** on an architecture adapted from its sibling > Astrolabe is a rebuild on an architecture adapted from its sibling project Syto. The
> project Syto. The authoritative behavioral contract lives in [`docs/spec/`](docs/spec/). > behavioral record lives in [`docs/spec/`](docs/spec/) — the code leads; the spec is
> kept rewritten to match.
> See [SOUL.md](SOUL.md) for the philosophy and [docs/IMPLEMENTATION-PLAN.md](docs/IMPLEMENTATION-PLAN.md) > See [SOUL.md](SOUL.md) for the philosophy and [docs/IMPLEMENTATION-PLAN.md](docs/IMPLEMENTATION-PLAN.md)
> for the build sequence. > for the build sequence.
@@ -23,10 +24,30 @@ npm run typecheck
npm test # Vitest npm test # Vitest
``` ```
## What it does
- **Editor + live preview** — write Vega-Lite specs as JSON in Monaco with schema-aware
validation and autocomplete; the chart re-renders as you type.
- **Snippet library** — save, search, tag, and organize specs. Each snippet carries a stable
published version plus a separate editable draft, so you can tinker without losing a
known-good copy.
- **Reusable datasets** — store data once (inline, or fetched once from a URL and snapshotted)
and reference it by name from many snippets.
- **Chart Builder** — a no-JSON on-ramp that generates a spec from field, mark, and encoding
choices. The JSON stays the source of truth and is always editable.
- **Theming & fonts** — custom chart themes with a visual Theme Builder, a curated font roster,
and your own uploaded font faces.
- **Local-first** — your library lives in the browser (IndexedDB); offline-capable and installable
(PWA), with import/export for backup and transfer. No account, no server, no AI — with no
backend to send it to, your library stays on your device, so it's safe for confidential work
from the first chart.
## Status ## Status
**M0 — Skeleton.** Toolchain green (typecheck, tests, build, PWA). The three-pane shell Active development, **pre-1.0** and not yet publicly released — well past the initial milestones
renders; features land milestone by milestone per the implementation plan (MVP at end of M1). and usable day to day; the first public release will be `1.0.0`. Behavior is specified in
[`docs/spec/`](docs/spec/) and the architecture patterns in
[`docs/architecture/`](docs/architecture/00-overview.md).
## License ## License
+12 -7
View File
@@ -28,8 +28,10 @@ data lives in one place and the specs stay lean.
### 1. Local-Only by Default ### 1. Local-Only by Default
Everything runs in the browser. Snippets, datasets, and settings never leave the machine. Everything runs in the browser. Snippets, datasets, and settings never leave the machine.
No accounts, no uploads, no tracking. The only outbound requests are user-created No accounts, no uploads, no tracking, and the app contacts no third party on its own —
URL-dataset fetches. including an AI model. The only outbound requests are user-created URL-dataset fetches.
Because your work stays on the machine, confidential and work data are safe in Astrolabe
from the first chart.
### 2. Vega-Lite Native, Not Vega-Lite Hidden ### 2. Vega-Lite Native, Not Vega-Lite Hidden
@@ -71,15 +73,18 @@ clever.
- **Not a collaboration platform.** No multi-user, no sync, no comments. Import/export - **Not a collaboration platform.** No multi-user, no sync, no comments. Import/export
moves data between machines. moves data between machines.
- **Not a server app.** No backend, no rendering service, no account system. - **Not a server app.** No backend, no rendering service, no account system.
- **Not an AI tool.** No model authors, edits, or critiques charts, and nothing is sent to
one. The chart builder's recommendations are deterministic and rule-based, computed
locally — chosen over an LLM so results are explainable and nothing leaves the machine.
## Technical Philosophy ## Technical Philosophy
### Spec-Driven, Clean Implementation ### Spec-Recorded, Clean Implementation
The behavioral contract lives in `docs/spec/`. Astrolabe is a deliberate rebuild on a The behavioral record lives in `docs/spec/`. Astrolabe is a deliberate rebuild on a
robust architecture (adapted from Syto): we implement _to the spec_, not by porting old robust architecture (adapted from Syto): behavior is designed deliberately and recorded
code. When the spec and convenience conflict, the spec wins or the spec changes — never in the spec, not ported from old code. The code leads; the spec is rewritten to match
silent drift. what ships — never silent drift.
### Portable Core, Thin Browser Shell ### Portable Core, Thin Browser Shell
+59 -472
View File
@@ -1,8 +1,13 @@
# Astrolabe — Incremental Implementation Plan # Astrolabe — Incremental Implementation Plan
> A spec-driven rebuild of Astrolabe on Syto's architecture. The authoritative > A rebuild of Astrolabe on Syto's architecture. The behavioral record is
> behavioral contract is `docs/spec/` (sections 0010). This document sequences > `docs/spec/` (sections 0010) — the code leads, and the spec is kept
> the build into the **quickest path to a usable MVP**, then layers the rest. > rewritten to match.
>
> **The M0M6 build is complete** — the milestone map below is the record. Remaining
> work is post-M6 enhancement, tracked in the **live backlog** and owned in detail by
> the two scope docs it points to. Per-milestone build notes aren't kept here; the git
> history and the spec/architecture docs hold them.
> >
> **Method per milestone:** build core-first (portable, pure, tested) → wire UI → > **Method per milestone:** build core-first (portable, pure, tested) → wire UI →
> cover with tests → manual smoke check against the spec's acceptance points. > cover with tests → manual smoke check against the spec's acceptance points.
@@ -12,7 +17,7 @@
## Architectural ground rules ## Architectural ground rules
These are decided and apply to every milestone. The **how** behind each is written up These are decided and apply to all work. The **how** behind each is written up
self-containedly in [`docs/architecture/`](architecture/00-overview.md) — read the matching self-containedly in [`docs/architecture/`](architecture/00-overview.md) — read the matching
doc before implementing. doc before implementing.
@@ -42,499 +47,79 @@ doc before implementing.
## Milestone map ## Milestone map
The whole sequence is shipped. This table is the record; build notes for each live in the
git history and the spec/architecture docs.
| # | Milestone | Outcome | Spec | | # | Milestone | Outcome | Spec |
| -------- | --------------------------- | ------------------------------------------------------------------------------ | ------------------------------------------- | | -------- | -------------------------------- | ------------------------------------------------------------------------------ | ------------------------------------------- |
| **M0** | Skeleton ✅ | Repo builds, tests run, empty shell renders | — | | **M0** | Skeleton ✅ | Repo builds, tests run, empty shell renders | — |
| **M1** | **MVP core loop** | Author a Vega-Lite snippet, see it render live, it persists | §02, §03AC, §04, §09A | | **M1** | MVP core loop | Author a Vega-Lite snippet, see it render live, it persists | §02, §03AC, §04, §09A |
| **M1.5** | Visual design foundation ✅ | Apply the design language: tokens, IBM Plex, restyled M1 surfaces, chart theme | [arch 09](architecture/09-visual-design.md) | | **M1.5** | Visual design foundation ✅ | Apply the design language: tokens, IBM Plex, restyled M1 surfaces, chart theme | [arch 09](architecture/09-visual-design.md) |
| **M2** | Editor robustness ✅ | Draft/Published, validation, schema autocomplete, fit modes | §03DE, §04, §07(editor) | | **M2** | Editor robustness ✅ | Draft/Published, validation, schema autocomplete, fit modes | §03DE, §04, §07(editor) |
| **M3** | Datasets ✅ | Named reusable data + reference resolution in preview | §05, §03F, §09B | | **M3** | Datasets ✅ | Named reusable data + reference resolution in preview | §05, §03F, §09B |
| **M4** | Chart Builder ✅ | No-JSON chart composition from a dataset | §06 | | **M4** | Chart Builder ✅ | No-JSON chart composition from a dataset | §06 |
| **M4.5** | Snippet-library consolidation ✅ | Metadata panel (rename/comment/links), Duplicate | §02 |
| **M5** | Settings + Import/Export ✅ | Preferences + workspace backup/transfer | §07, §08, §09C | | **M5** | Settings + Import/Export ✅ | Preferences + workspace backup/transfer | §07, §08, §09C |
| **M6** | Shell polish ✅ | Resize/toggle panes, routing, shortcuts, toasts, a11y, offline | §01, §10 | | **M6** | Shell polish ✅ | Resize/toggle panes, routing, shortcuts, toasts, a11y, offline | §01, §10 |
**MVP boundary = end of M1** (a genuinely usable single-user chart authoring loop). **MVP boundary = end of M1** (a genuinely usable single-user chart authoring loop).
M1.5 makes it _look right_; M2 makes it _robust_; M3M6 make it _complete_. M1.5 made it _look right_; M2 made it _robust_; M3M6 made it _complete_.
Ship/dogfood after M1, iterate.
--- ---
## M0 · Skeleton ✅ (done) ## Live backlog
Vite + React + Zustand + TypeScript + Vitest (happy-dom) + vite-plugin-pwa. The M0M6 sequence is done; what's left is post-M6 enhancement. The detail — rationale,
`src/core``src/app` split, `useAppStore`, design tokens, three-pane placeholder citations, status logs — lives in the two scope docs below, which are the source of truth.
shell, first core module (`format-detection`) with tests. This is the at-a-glance list; keep it in sync with them.
**Verified:** `npm run typecheck`, `npm test`, `npm run build` (PWA SW generated). **Next (flagged for build):**
--- - **Multi-view data model** ([`multi-view-data-model-scope.md`](exploration/multi-view-data-model-scope.md)) —
durable composition support across the data-facing features. **Complete** (M1M5): the
## M1 · MVP core loop → _the quickest usable Astrolabe_ Vega-Lite-fidelity reference classifier (`core/spec-data`), the view-scoped editor data
context, per-view data inspection, view-scoped Extract (inline + self-defined `datasets`),
**Goal:** select/create a snippet, edit its spec JSON, watch a live Vega-Lite and live/interactive inspection. The durable contract is recorded in `docs/architecture`
preview, and have it survive reload. Single source kind: inline-data specs only 05 (live inspection) and 07 (reference detection + extraction, §3.13.2).
(datasets come in M3). No draft/published yet — edits save directly. - **Chart Builder · 3B starter examples** ([`chart-builder-enhancement-scope.md`](exploration/chart-builder-enhancement-scope.md) §3) —
a small set of curated starters, one per covered FT intent. Reshaped by 3C: a
**Core (`src/core/`)** builder-openable starter must reference a dataset, so it ships paired sample datasets (or is
reframed) — final shape decided at build time. Distinct from the inline-data onboarding
- `snippet.ts` — the Snippet type (spec §09A) + factory (`createSnippet`, gallery (`core/examples.ts``Onboarding.tsx`), which is Monaco-only.
default sample bar-chart template, auto-generated date/time name). - **Chart theming · Color-panel swatch reorder** ([`chart-theming-scope.md`](exploration/chart-theming-scope.md) §5) —
- `rendering.ts``prepareSpecForRender(spec, { fitMode })` skeleton; in M1 it's the last remaining slice-4b control: reorder a materialized scheme's swatches.
near pass-through (reference resolution is a no-op until M3, fit-mode is M2).
Establish the "transform a copy, never mutate stored spec" contract now. **Deferred / gated (have a home; not committed):**
**Infrastructure (`src/app/infrastructure/`)** - **Chart Builder · Phase 4** (gated until after Phase 3) — `theta`/pie, faceting (small
multiples), light styling/scale override panels, builder undo/redo, dataset lookup/join. Each
- `idb.ts` — thin IndexedDB wrapper (open, get/put/delete/getAll by store). must clear the promotion test: a control enters the builder only when it is **both common and
_(see [Architecture 02 · Persistence](architecture/02-persistence.md))_ awkward in JSON**.
- `snippet-store.ts` — persist snippets (object store `snippets`). - **Chart Builder** — field-chip drag-and-drop (click/keyboard-first shipped; drag deferred);
calculated-field autocomplete popup (Monaco-style completion for expressions).
**App** - **Chart theming** — Google Fonts opt-in CDN tier (keyless catalog, opt-in only); theme↔font
pairing metadata (a suggestion nicety); built-in expressive preset gallery ("Editorial",
- `stores/SnippetStore.ts``useSnippetStore` with `snippets`, `activeSnippetId`, "Terminal", "Sketch").
selector-derived `activeSnippet`; load-on-startup; create/select/delete/update actions
(debounced auto-save of edits, spec §03B). Seed one sample snippet on first run.
_(Superseded later: an empty library now shows the onboarding canvas instead of a
placeholder seed — spec §02 → First-Run & Empty Workspace.)_
- `components/SnippetLibrary.tsx` — list + "Create New" pinned item + select/delete.
- `components/SpecEditor.tsx` — Monaco JSON editor bound to active snippet's spec;
debounced write-back to the store. (Worker wiring via Vite `?worker` imports —
mine vega-editor's Monaco setup.)
- `components/LivePreview.tsx` — render current spec via `vega-embed` (actions:
false), debounced; clean empty pane when no/blank spec; basic error text.
- Fill the three panes in `App.tsx` with these.
**Tests (core-first)**
- `snippet.test.ts` — factory defaults, sample template validity, unique naming.
- `rendering.test.ts` — copy-not-mutate invariant; pass-through shape.
- A store test for create/select/delete/auto-save reducer logic (logic extracted
from the component so it's testable without DOM).
**Manual checks**
- Fresh load shows the onboarding canvas (welcome + Create + live example gallery);
Create or an example's Add lands you in the editor with a rendered chart. _(M1 originally
seeded a sample snippet; replaced by the onboarding canvas — spec §02.)_
- Type in the editor → preview updates after the debounce; bad JSON → editor keeps
working, preview shows an error, recovers when fixed.
- Reload → snippets and selection persist.
---
## M1.5 · Visual design foundation ✅ (done) → _make the MVP look like itself_
**Goal:** apply our design language so the running MVP looks deliberate, and every
later milestone builds on settled tokens instead of placeholders. The expensive part
(the design decisions) is already done — this milestone is _application_, not
invention. See [Architecture 09 · Visual Design Language](architecture/09-visual-design.md)
and the companion `visual-specimen.html`.
**Styles**
- Port the settled specimen tokens into `styles/tokens.css` (IBM-Plex type scale,
8px-based spacing, role-based color, square chrome, motion); light + dark themes
via `[data-theme]`.
- Self-host **IBM Plex Sans + Mono** in `styles/base.css` via `@fontsource`
(offline/PWA — never a CDN).
**App**
- Restyle the four M1 surfaces against the tokens: App shell, SnippetLibrary,
SpecEditor (Monaco theme follows `[data-theme]`), LivePreview. Tokens only — no
raw hexes, no hardcoded hues in components.
- Establish the reusable component conventions (buttons, fields, list rows, status,
focus ring) that M2M6 reuse.
- **Header theme toggle** (pulled forward from M5): a one-click light⇄dark control,
persisted via the `ui.theme` settings key (a minimal forward-compatible
`settings-store` adapter the full M5 UserSettings store will absorb). Hydrated
before first paint (no FOUC). Justified: the theme system was already complete,
so dogfooding dark mode through M2M4 beat waiting for the full settings UI.
**Core**
- Align `src/core/vega-themes.ts`: chart `Config` per theme + a categorical
`range.category` palette (clone `carbon-design-system/carbon-charts` for the
sequence — see Architecture 09 §8).
**Tests**
- Light: the design is mostly visual — a token/theme smoke check, trust the eye.
**Manual checks**
- The real app looks deliberate in both themes; theme flip repaints UI + chart.
- Keyboard focus ring visible; text/UI contrast passes AA in light and dark.
- No placeholder styling remains on the M1 surfaces.
**Verified:** `typecheck` + `test` (incl. `vega-themes.test.ts`) +
`build` (Plex woff2, all script subsets, bundled & precached via the PWA
`globPatterns`). Both themes screenshotted via the real
app (chrome + Monaco + chart all repaint on theme flip); focus ring visible.
Notes from the build-out: the placeholder `'experimental'` theme was renamed to
`'dark'` (the settled name); the swappable `[data-accent]` layer landed with
deep teal as the robust default (no switcher UI until M5); Monaco's `fontFamily` is
set to Plex Mono explicitly since it can't read the CSS token.
---
## M2 · Editor robustness ✅ (done)
**Goal:** the editor becomes trustworthy — draft vs published, schema-aware
assistance, and the fit-mode rendering contract.
**Core**
- `rendering.ts` — implement **fit-mode** transform (Original/Width/Height/Full →
Vega-Lite `"container"`), recursing into layered/concat/child specs (spec §04
Rendering Contract, step 2).
- `vega-lite-schema.ts` — provide the Vega-Lite JSON schema for Monaco's
validation/autocomplete. _Delivered early in M1.5 as
`infrastructure/monaco-schema.ts` (bundled schema, offline, `markdownDescription`
hover docs); no further work needed in M2._
**App**
- Snippet gains `spec` (published) + `draftSpec` (working) per §09A; editing
touches `draftSpec` only.
- `SpecEditor` header: Draft/Published toggle; **Publish** (promotes draft, recomputes
dataset refs — refs land in M3) + **Revert** (confirm dialog).
- Library list item: draft-vs-published **status indicator**.
- Monaco wired with the Vega-Lite schema → squiggles + autocomplete; inline error
surface in the editor pane (§03E).
- Preview **Fit control** (4 modes), persisted (`previewFitMode`).
**Tests**
- Fit-mode transforms for each mode incl. nested specs; copy-not-mutate.
- Draft/publish/revert reducer logic; "has unpublished changes" derivation.
**Manual checks**
- Edit draft, see status flip to "draft"; Publish → status clears; Revert →
draft restored with confirmation.
- Invalid spec shows inline error; autocomplete suggests Vega-Lite properties.
- Each fit mode resizes the chart as specified; choice survives reload.
**Verified:** `typecheck` + `test` (`rendering` fit-mode incl.
nested layer/concat/facet specs, `SnippetStore` draft/publish/revert/editorView,
`settings-store` `previewFitMode` round-trip) + `build` (PWA, 41 precache
entries) + `eslint` clean. Implementation notes: editing now writes the
**draft** only (`commitDraft` no longer touches `spec`); `publish`/`revert` live
in `SnippetStore`, with a `bufferEpoch` counter so programmatic buffer reloads
(select/create/revert) refresh Monaco without fighting the cursor mid-typing.
The Draft/Published view is a store-level `editorView`; the published view is
read-only and the preview renders whichever version is shown (`selectShownText`).
The editor (§03E) and preview (§04) share one render error via a small
`PreviewStore`. `previewFitMode` was pulled into `AppStore` + the settings
adapter, hydrated/persisted by a new `orchestration/preferences.ts` mirroring the
theme slice. Publish/Revert **success toasts** stay deferred to M6 (TODO
breadcrumbs at the call sites), matching the existing delete-toast convention.
Fit-mode rendering needed a layout fix: vega-embed brands the embed host with its
own `.vega-embed { display: inline-block }` (injected at runtime, wins the
cascade), which shrink-wrapped the host so `width: "container"` collapsed (Height
survived only via the old `min-height: 100%`). Fix: embed into a static-class
inner host (React never reconciles its className, so Vega's runtime classes
survive) inside a React-owned frame that carries the fit-sizing class via
two-class selectors that out-specify `.vega-embed`. All four fit modes
user-verified in the running app.
---
## M3 · Datasets ✅ (done)
**Goal:** named, reusable data that snippets reference by name; preview resolves
the reference.
**Core**
- `profiling.ts` — row/column counts, column names, **per-column type inference**
(number/text/date/boolean). _(see [Architecture 06 · Type Inference](architecture/06-type-inference.md))_
- `rendering.ts` — implement **dataset reference resolution** (§04 Rendering
Contract, step 1): `{data:{name}}` → inline values / raw text+format / URL+format,
recursing into sub-specs; "dataset not found" error.
- `dataset.ts` — Dataset type (§09B); name uniqueness helpers; rename-propagation
into referencing specs. _(see [Architecture 07 · Naming & Relationships](architecture/07-naming-and-relationships.md))_
**Infrastructure**
- `dataset-store.ts` — separate high-capacity IndexedDB store (§09E).
**App**
- `stores/DatasetStore.ts` + Datasets **modal** (list/detail panes, create form,
edit, delete, copy-reference) via the modal registry/coordinator.
- Snippet `datasetRefs` maintained on publish; library shows dataset icon +
Linked Datasets; dataset detail shows Linked Snippets (bidirectional name link, §09F).
- **Extract-to-Dataset** flow from the editor (§03F).
- URL datasets fetched once on add and **snapshotted** locally (profiled like inline;
render from the snapshot, refreshable on demand — see §05 / §04 rendering contract).
**Tests**
- Reference resolution per source/format incl. nested; not-found error.
- Profiling/type inference across mixed columns, nulls, booleans.
- Rename propagation; name-uniqueness + import-style auto-suffix.
**Manual checks**
- Create a dataset, reference it by name in a snippet → preview renders.
- Extract inline data → spec rewritten to a reference, dataset appears, links show both ways.
- Delete/rename a referenced dataset behaves per spec.
---
## M4 · Chart Builder ✅ (done)
**Goal:** no-JSON chart composition from a dataset → a new snippet.
> **Enhancement push (post-M4).** The forward plan now lives in
> [`docs/exploration/chart-builder-enhancement-scope.md`](exploration/chart-builder-enhancement-scope.md) — it merges
> the Tier-B backlog ([`chart-builder-research.md`](exploration/chart-builder-research.md) §8) with the
> Lyra interaction review ([`lyra-review.md`](exploration/lyra-review.md)) and sets a **Tier-C** target.
> Shipped beyond the Tier-B floor so far: per-channel aggregate/bin/`timeUnit`, sort/stack;
> **actionable hints** (one-click warning fixes); and a builder UX/perf batch (near-fullscreen
> modal, canvas preview + canvas max-dimension guard, data-aware default pre-population). See
> the scope doc §4 for the sequenced plan and current status.
**Core**
- `chart-builder.ts` — pure spec assembler: (mark ∈ Bar/Line/Point/Area/Circle) +
channels (X/Y/Color/Size) with field types (Quantitative/Nominal/Ordinal/Temporal)
- optional width/height → complete Vega-Lite spec with tooltips + named data ref
(§06 Output). Field-type defaults from inferred column type.
**App**
- Chart Builder **modal** (config pane + live preview pane), launched from a
selected dataset; default pre-population (first col→X, second→Y); validation
(≥1 channel); Create Snippet → new linked snippet becomes active.
**Tests**
- Spec assembly: mark/channel/type permutations, unmapped channels omitted,
width/height inclusion, field-type derivation, validation gate.
**Manual checks**
- Build a bar chart from a dataset in a few clicks; preview live-updates;
Create → new snippet opens and renders.
**Council****FT Visual Vocabulary** + **Datawrapper** are now **seated** (chart-choice
canon): this is where Astrolabe stops being a pass-through JSON editor and starts making
chart-shaped suggestions/defaults, so "_which chart, and why_" becomes a decision the app
owns — the one thing Carbon's data-viz styling doesn't cover. Rather than a styling-only
seating, we ran a full **research-first** pass (FT + Datawrapper + the formal engines
**Draco** and **Voyager**), recorded in [`docs/exploration/chart-builder-research.md`](exploration/chart-builder-research.md),
and chose the **Tier B "smart + guarded"** design: smart default mark for the data shape,
valid-type-locked field-type menus, Size-channel discipline, and non-blocking guidance.
The convergent rules and citations live in that doc; the spec (§06) was amended to match.
**Verified:** `typecheck` + `test` green — the pure `chart-builder.ts` assembler
(`chart-builder.test.ts`: mark/channel/type permutations, unmapped-channel omission,
validation gate). The Chart Builder modal is wired through the registry, launched from a
selected dataset (`DatasetsModal``openModal('chartBuilder')`). Data-aware
cardinality/extent profiling + chart warnings landed (A3/A4).
---
## M4.5 · Snippet-library consolidation ✅ (done)
**Why out of band:** a spec-vs-implementation audit after M4 found §02 features that no
later milestone owned — the **Selected-Snippet Metadata Panel** (inline name + comment
editing, timestamps, linked datasets) and the **Duplicate** operation. Without them a
snippet could only ever carry its auto-generated date-time name (no rename, no annotation,
no copy), a sharp edge for a _snippet manager_. Closed before M5 since the spec text
already existed and the work was core-first and cheap.
**Core**
- `snippet.ts``duplicateSnippet(source, {now,id})`: independent copy carrying both spec
versions, comment, tags, and dataset refs; "(copy)" name; fresh identity/timestamps;
cloned mutable members.
**App**
- `SnippetStore``renameSnippet`, `setComment` (both advance `modified` per §02 → Sort,
no editor-buffer touch), `duplicateActiveSnippet` (flushes the live buffer first, prepends
the copy, makes it active).
- `SnippetLibrary` — the metadata panel below the list: Name + Comment auto-save (debounced
while typing, flushed on blur), read-only Created/Modified, Linked Datasets list, and
Duplicate / Delete. Duplicate raises a success toast (the copy isn't self-evident, unlike
Create); §02-compliant.
**Also fixed (§03C divergence):** the preview debounced _every_ change, so a snippet
load / Draft↔Published switch incurred a 300 ms blank instead of the spec's **immediate**
render. `LivePreview` now renders immediately on `bufferEpoch`/`editorView` change and
debounces only keystroke (`shownText`-only) changes.
**Deferred at the time to M5/M6 (per §02):** Search, Sort controls + persistence, two
distinct empty-state messages, Storage Monitor — all now delivered in M6.
**Verified:** `typecheck` + `test` (`snippet` duplicate factory,
`SnippetStore` rename/comment/duplicate, a `SnippetLibrary` render test guarding the
auto-save effect against a render loop) + `eslint` clean + `build` (PWA, 41 precache
entries).
---
## M5 · Settings + Import/Export ✅ (done)
**Goal:** preferences and whole-workspace backup/transfer.
**Core**
- `settings.ts` — UserSettings shape + defaults + load-with-fallback (§07, §09C);
unknown/missing values fall back silently.
- `import-normalize.ts` — accept envelope / bare array / single snippet / foreign
shapes; field mapping (`content`→spec, `draft`→draftSpec, `createdAt`→created);
tag `"imported"`; merge rules (append, id-collision reassign, dataset-name
auto-suffix, datasets-before-snippets) (§08).
- `export-envelope.ts` — build the `{version, exportedAt, exportedBy, snippets, datasets}` envelope.
**Infrastructure**
- `settings-store.ts` (localStorage): **extend** the minimal M1.5 adapter (which
already persists `ui.theme`) to the full UserSettings record; `ux-prefs` for sort
- panel layout (§09D).
**App**
- **Distributed settings** — not a modal (design review, see spec §07 + arch 10). Each
cluster is a per-pane disclosure popover that applies **live**: Editor settings in the
editor toolbar, render debounce in the preview, date format in the library; theme stays
the header toggle. A shared `SettingsPopover` primitive (gear + non-modal popover, APG
disclosure) backs all three. Wire render-debounce + editor options + date-format through.
- Header **Import**/**Export** (direct file dialog / download, no modal).
- Date formatting util (smart/iso/custom) used by the library list + metadata panel.
**Tests**
- Import normalization across all accepted shapes; merge/collision/rename logic;
quota-overage messaging path. Envelope round-trip (export→import idempotence).
- Settings load-with-fallback for partial/unknown records.
**Manual checks**
- Change theme/debounce/date-format → takes effect; Cancel reverts; Reset confirms.
- Export → reimport into a populated workspace merges without overwrite; renames reported.
**Verified:** `typecheck` + `test` green — `import-normalize` (accepted shapes + merge/
collision/rename), `export-envelope` (round-trip), `settings` (load-with-fallback),
`UserSettingsStore`, `ux-prefs`. Distributed per-pane settings shipped as `SettingsPopover`
disclosures (editor toolbar, preview, library) applying live; header Import/Export wired
through `services/transfer.ts` (→ `normalizeImport` / envelope build), no modal.
---
## M6 · Shell polish & non-functional ✅ (done)
**Goal:** the workspace feels finished and meets §10.
**Scope note — desktop/tablet, not phone.** Astrolabe is a desktop and (at best)
tablet tool; phones are out of scope. So the touch/installable surface we target is
**iPad add-to-home-screen**, not iPhone — which is why the one PNG icon is a 180×180
`apple-touch-icon` and we don't chase phone-specific viewport/layout work.
- **Panes:** ~~drag-resize handles with min widths; widths persist~~ ✅ (pulled
forward after M2). ~~Per-pane show/hide **toggle strip** + visibility persist +
proportional redistribution on hide (§01A, §09D)~~ ✅.
- **Routing:** ~~URL hash view-state (`#snippet-<id>`, `#datasets/...`) with Back/Forward;
restore on load (§01E)~~ ✅.
- **Shortcuts:** ~~Cmd/Ctrl+Shift+N / +K / +S / +, / Esc via a single key router
(§01D)~~ ✅.
- **Toasts:** ~~success/error/warning/info, stacking, auto-dismiss, reduced-motion;
appear/disappear with a brief fade (§01F)~~ ✅ (fade-out two-phase dismiss landed last).
- **Library search / sort / empty states** (§02, §09D): ~~live search across
name/comment/draft spec; Sort by Modified/Created/Name/Size with a disclosure +
flip-on-reselect, persisted to `astrolabe:ux-prefs`; the two distinct empty states~~ ✅
(council-guided — see [arch 10](architecture/10-interaction-and-feedback.md)).
- **Storage monitor** for the snippet tier ~~(§02)~~ ✅ (`role="meter"` fill bar,
escalating ok/warning/critical at 0.8/0.95).
- **Live-preview busy indicator** (§04, §10): ~~non-blocking overlay + `aria-busy` for
renders past ~1s~~ ✅.
- **Import atomicity** (§08): ~~roll back on storage-quota failure so no partial import is
committed; actionable error~~ ✅ (effective at the service boundary; true cross-record
IDB-transaction atomicity would require exposing a raw transaction from `db.ts` — see
[arch 02](architecture/02-persistence.md)).
- **A11y:** modal focus trap + return, labelled icon buttons, contrast in both themes (§10) — ✅ in place.
- **About** and **Support** modals ~~(§01)~~ ✅. The project solicits nothing for
itself — **Support** offers feedback to the author and redirects donations to Ukraine's
defense (`savelife.in.ua`, the Come Back Alive foundation), where the author forwards any
project donations anyway.
- **Offline/installable:** the manifest ships a full SVG icon set (favicon / maskable /
monochrome) + a 180×180 `apple-touch-icon.png` (iPad add-to-home-screen) + `theme_color`,
and the SW precaches the shell — the app is **installable**. ✅ Manual verification in a
running/installed app passed (checklist: [manual-verification.md](manual-verification.md)).
- **Council** — ~~seat **web.dev** for the PWA/offline/storage surfaces none of the seated
members cover: the service-worker **update-available** prompt (`registerType: 'prompt'`),
storage **persistence** (`navigator.storage.persist()`), and the quota **estimate**
(`StorageManager.estimate()`)~~ ✅ seated + backfilled (update-prompt toast + `persist()`
request; estimate already wired). ~~**⏳ Remaining gap:** manifest ships no icons → not yet
installable~~ ✅ SVG icon set added + wired (favicon / maskable / monochrome). See
[`/council`](../.claude/skills/council/SKILL.md) and
[arch 10](architecture/10-interaction-and-feedback.md).
**Manual checks:** ✅ keyboard-only run-through; reload restores view from URL;
offline reload works; install as standalone; reduced-motion honored — all verified in a
running/installed app, plus visual verification of the library/monitor/modals/busy-indicator
surfaces.
--- ---
## Cross-cutting, do-as-you-go ## Cross-cutting, do-as-you-go
- **Build to the design language:** the foundation lands in M1.5; from M2 on, every - **Build to the design language:** the foundation landed in M1.5; every new component uses the
new component uses the [Architecture 09](architecture/09-visual-design.md) tokens [Architecture 09](architecture/09-visual-design.md) tokens and conventions — no placeholder
and conventions — no placeholder styling, no raw hues. Staying on it is the styling, no raw hues. Staying on it is the do-as-you-go part.
do-as-you-go part.
- **i18n** (optional, deferred): if translation is wanted, split a portable i18n - **i18n** (optional, deferred): if translation is wanted, split a portable i18n
registry (no React) from the app-layer bindings, mirroring the `core``app` registry (no React) from the app-layer bindings, mirroring the `core``app`
boundary. M1M6 can ship English-only with date formatting locale-aware (§10). boundary. The app ships English-only with date formatting locale-aware (§10).
Don't retrofit later if avoidable — keep user-facing strings centralized from M1. Keep user-facing strings centralized so a later retrofit stays cheap.
- **Versioning:** simplified semver `0.x.y`, `package.json``__APP_VERSION__` - **Versioning:** simplified semver `0.x.y`, `package.json``__APP_VERSION__`
(already wired). **Not yet released publicly** — the working version stays pre-1.0 through (already wired). **Not yet released publicly** — the working version stays pre-1.0; the
M0M6; the **first public release will be `1.0.0`**, cut on the maintainer's signal (don't **first public release will be `1.0.0`**, cut on the maintainer's signal (don't auto-bump in
auto-bump in the meantime). the meantime).
- **Docs trio:** keep `SOUL.md` / `AGENTS.md` / `CLAUDE.md` current as the app grows. - **Docs trio:** keep `SOUL.md` / `AGENTS.md` / `CLAUDE.md` current as the app grows.
--- ---
## Open items (carried from the exploration memos)
Deferred features whose reasoning lives in `docs/exploration/`; pulled here so the backlog
is in the maintained plan, not the archive.
**Chart theming** (`exploration/chart-theming-scope.md`):
- **Google Fonts opt-in CDN tier** — keyless catalog, opt-in only.
- **Theme↔font pairing metadata** — a suggestion nicety.
- **Built-in expressive theme preset gallery** — e.g. "Editorial", "Terminal", "Sketch".
- **Color panel swatch reorder** — the remaining slice-4b control (reorder a materialized
scheme's swatches).
**Chart Builder** (`exploration/chart-builder-enhancement-scope.md`):
- ~~**Open-in-builder (3C, strict hydration)**~~ ✅ shipped (2026-06-18): an editor-toolbar
**Open in builder** action reopens a builder-representable snippet to **edit it in place**
(`parseChartSpec` is the strict inverse of `buildChartSpec`, gated by re-assemble +
deep-compare). Only dataset-referencing specs hydrate (the builder's data model). See the
scope doc status log.
- **Builder starter examples (3B)** — a small set of curated starters, one per covered FT
intent, **authored in the builder dialect so they reopen via 3C**. Reshaped by 3C's
data-model edge: a builder-openable starter must reference a dataset, so 3B ships paired
sample datasets (or is reframed) — decide its shape now that the gate is live. Distinct from
the shipped onboarding gallery (`core/examples.ts``Onboarding.tsx`), which seeds an
inline-data snippet straight into the editor (Monaco-only, never the builder).
- ~~**Transform-aware data inspector** — show resolved post-transform rows~~ ✅ shipped: an
Input | Resolved data inspector below the Live Preview and Chart Builder charts, with a
draggable height divider (spec §04; arch 05 → "data inspector rides the boundary").
- **Field-chip drag-and-drop** — click/keyboard-first shipped; drag deferred.
- **Calculated-field autocomplete popup** — Monaco-style completion for expressions.
---
## Architecture reference ## Architecture reference
The **how** behind each milestone is documented self-containedly in The **how** behind each milestone is documented self-containedly in
@@ -551,3 +136,5 @@ The **how** behind each milestone is documented self-containedly in
| Unique names + import auto-suffix, snippet↔dataset links, rename propagation | [07 · Naming & Relationships](architecture/07-naming-and-relationships.md) | | Unique names + import auto-suffix, snippet↔dataset links, rename propagation | [07 · Naming & Relationships](architecture/07-naming-and-relationships.md) |
| Monaco setup, Vega-Lite schema service, editor patterns mined from vega/editor | [08 · Vega Editor Techniques](architecture/08-vega-editor-techniques.md) | | Monaco setup, Vega-Lite schema service, editor patterns mined from vega/editor | [08 · Vega Editor Techniques](architecture/08-vega-editor-techniques.md) |
| Design language: tokens, type, spacing, color roles, components, themes | [09 · Visual Design Language](architecture/09-visual-design.md) | | Design language: tokens, type, spacing, color roles, components, themes | [09 · Visual Design Language](architecture/09-visual-design.md) |
| Interaction & feedback: toasts, busy states, empty states, council resolutions | [10 · Interaction & Feedback](architecture/10-interaction-and-feedback.md) |
| Learning section `/learn/`: markdown lessons, before/after spec progressions | [11 · Learning Section](architecture/11-learning-section.md) |
+4 -2
View File
@@ -22,7 +22,9 @@ about user-facing widgets. At that overlap, one rule keeps them from drifting:
and never contradict it.** A playbook bullet may _name_ the behavior in one clause and cite and never contradict it.** A playbook bullet may _name_ the behavior in one clause and cite
the spec, then spend its words on the _how_ (the role, the keys, the focus move) and the the spec, then spend its words on the _how_ (the role, the keys, the focus move) and the
_why_ (the council/canon citation). When a playbook bullet and the spec disagree, the _why_ (the council/canon citation). When a playbook bullet and the spec disagree, the
**spec wins** and the bullet is the bug. Restatement is the leak: two docs describing the **spec wins** and the bullet is the bug — both describe the shipped code, so if the spec
section is itself stale, rewrite it to match the app, then cite it (the code leads; the
spec records). Restatement is the leak: two docs describing the
same behavior in their own words drift into contradiction; one cites the other instead. same behavior in their own words drift into contradiction; one cites the other instead.
## How to use this playbook ## How to use this playbook
@@ -44,7 +46,7 @@ about user-facing widgets. At that overlap, one rule keeps them from drifting:
| 05 | [Rendering, Theming & Preview](05-rendering-theming-preview.md) | vega-embed integration (`actions:false`, `view.finalize()`); field-name escaping; theme→config mapping; debounced non-blocking renderer; resilient error display. | | 05 | [Rendering, Theming & Preview](05-rendering-theming-preview.md) | vega-embed integration (`actions:false`, `view.finalize()`); field-name escaping; theme→config mapping; debounced non-blocking renderer; resilient error display. |
| 06 | [Type Inference & Profiling](06-type-inference.md) | Pure, portable column-type inference (number/text/date/boolean) and the dataset profile shape. | | 06 | [Type Inference & Profiling](06-type-inference.md) | Pure, portable column-type inference (number/text/date/boolean) and the dataset profile shape. |
| 07 | [Naming & Relationships](07-naming-and-relationships.md) | Unique-name enforcement + import auto-suffix; the bidirectional snippet↔dataset name link; rename propagation into specs. | | 07 | [Naming & Relationships](07-naming-and-relationships.md) | Unique-name enforcement + import auto-suffix; the bidirectional snippet↔dataset name link; rename propagation into specs. |
| 08 | [vega/editor Techniques](08-vega-editor-techniques.md) | Reference brief: borrowable Monaco-schema wiring, vega-embed lifecycle, two-tier validation, and data-flow/debounce techniques distilled from the official Vega-Lite editor — plus where we do better. | | 08 | [vega/editor Techniques](08-vega-editor-techniques.md) | Reference brief: borrowable Monaco-schema wiring, vega-embed lifecycle, two-tier validation, and data-flow/debounce techniques distilled from the official Vega-Lite editor — plus where we do better, and our editor-augmentation layer (structural transforms + data-aware hints). |
| 09 | [Visual Design Language](09-visual-design.md) | The _visual_ contract: principles inspired by IBM/Carbon, deliberate divergences (square chrome, free color/theming), the token system (Plex type, 8px spacing, role-based color, motion), component conventions, and where to mine the Carbon/IBM source repos for more. Companion: [`visual-specimen.html`](visual-specimen.html). | | 09 | [Visual Design Language](09-visual-design.md) | The _visual_ contract: principles inspired by IBM/Carbon, deliberate divergences (square chrome, free color/theming), the token system (Plex type, 8px spacing, role-based color, motion), component conventions, and where to mine the Carbon/IBM source repos for more. Companion: [`visual-specimen.html`](visual-specimen.html). |
| 10 | [Interaction & Feedback](10-interaction-and-feedback.md) | The _interaction_ contract: the feedback-channel decision table, latency/feedback budgets, the non-happy-path triad, the recovery & data-safety contract, the keyboard/focus contract, and the resolved widget patterns (window splitter, toolbar, segmented controls, selectable lists, search, sort, empty states, modals). Cites `spec/` for behavior; owns the _how_. | | 10 | [Interaction & Feedback](10-interaction-and-feedback.md) | The _interaction_ contract: the feedback-channel decision table, latency/feedback budgets, the non-happy-path triad, the recovery & data-safety contract, the keyboard/focus contract, and the resolved widget patterns (window splitter, toolbar, segmented controls, selectable lists, search, sort, empty states, modals). Cites `spec/` for behavior; owns the _how_. |
| 11 | [Learning Section](11-learning-section.md) | The `/learn/` deep-dive: a marketing-surface Vite entry reusing core + the landing chart embed; markdown-authored lessons (`import.meta.glob`) parsed into an ordered block model; the authoring/engine split (pure parser in core; `marked` only in `src/learn`). | | 11 | [Learning Section](11-learning-section.md) | The `/learn/` deep-dive: a marketing-surface Vite entry reusing core + the landing chart embed; markdown-authored lessons (`import.meta.glob`) parsed into an ordered block model; the authoring/engine split (pure parser in core; `marked` only in `src/learn`). |
+7
View File
@@ -236,6 +236,13 @@ feature owns — which modal is open, the runtime theme, transient render flags.
> that's the signal to extract a feature store. A bloated central store is the > that's the signal to extract a feature store. A bloated central store is the
> thing this split exists to prevent. > thing this split exists to prevent.
> Rule: a store earns its place by **decoupling** producers from consumers — a fact
> belongs in one when more than one component reads it, or when many sites produce it for
> one surface to consume (the imperative `notify()` / `confirm()` overlay stores). When a
> single component is both the only producer and the only consumer, the fact is that
> component's **local `useState`**, not a store — a store there decouples nothing, and is
> the shape to fold back.
--- ---
## 4. Actions: Mutations Live in the Store, Not Components ## 4. Actions: Mutations Live in the Store, Not Components
+2 -2
View File
@@ -284,8 +284,8 @@ export interface UserSettings {
formatting: { dateFormat: 'smart' | 'iso' | 'custom'; customDateFormat: string }; formatting: { dateFormat: 'smart' | 'iso' | 'custom'; customDateFormat: string };
} }
// Defaults must match the authoritative spec §07 table exactly — that is the // The spec §07 table records these defaults — keep the two matching; this is
// contract; this is just where it's encoded. // just where they're encoded.
const DEFAULTS: UserSettings = { const DEFAULTS: UserSettings = {
version: CURRENT_SETTINGS_VERSION, version: CURRENT_SETTINGS_VERSION,
editor: { editor: {
+4 -1
View File
@@ -75,7 +75,10 @@ export interface ModalConfig {
component: ComponentType<any>; // the body rendered inside the shell component: ComponentType<any>; // the body rendered inside the shell
/** Initialize transient modal state when it opens. `arg` carries an /** Initialize transient modal state when it opens. `arg` carries an
* optional sub-target (e.g. a dataset id for chartBuilder/extract). */ * optional sub-target (e.g. a dataset id for chartBuilder/datasets). OMIT
* when a service seeds the store *before* `openModal` — Extract is seeded by
* `services/extract-action` from the editor cursor, and an `init` here would
* re-read and clobber that view-scoped capture. */
init?: (arg?: string) => void; init?: (arg?: string) => void;
/** Serializable snapshot of in-progress edits, used to detect unsaved /** Serializable snapshot of in-progress edits, used to detect unsaved
@@ -42,26 +42,31 @@ above it is data; everything below it is a Vega `View` we own and must tear down
### The data inspector rides the boundary too ### The data inspector rides the boundary too
The data inspector (the Live Preview and Chart Builder panel showing the chart's input The data inspector (the Live Preview and Chart Builder panel showing each drawn table's
vs. resolved rows — spec §04) reads runtime rows through the handle, never the raw view: input vs. resolved rows — spec §04) reads runtime rows through the handle, never the raw
`RenderHandle.inspectData()` returns the input + resolved tables (`{ input, resolved }`, view: `RenderHandle.inspectData()` returns the inspectable tables (`{ tables }`, or `null`
or `null` when no chart is up), wrapping the view exactly like `toImageURL`. It works in when no chart is up), wrapping the view exactly like `toImageURL`. It works in two layers:
two layers:
- **Enumerate + pick (`view.getState` + `core/result-data`).** A compiled Vega dataflow - **Enumerate from the compiled spec (`core/inspect-views`).** A composed spec draws
holds many named datasets; Vega-Lite names them by convention — `source_<n>` per parsed several tables; `inspectableViews` walks the compiled Vega spec — the marks tree's
source, `data_<n>` per transform stage. The pure `pickSourceDataset` / `pickResultDataset` `from.data` (what each mark draws) and `data[].source` (the lineage, the documented Vega
choose the **most-upstream source** (the input) and **most-downstream output** (what the format) — to list, in document order, one entry per **distinct drawn table** with its
marks draw), skipping dataflow internals (`marks`, `root`, layout, selection `*_store`s). `resolved` (post-transform, what the marks draw) and `input` (most-upstream source) ends.
A spec with no transforms resolves both to the same table. The picking is pure (in `core`, Enumerating by drawn table, not by authored view, is forced by Vega-Lite desugaring (a
unit-tested); only the enumeration touches the view. `point: true` line compiles to two layers — a compiled table can't be traced back to one
- **Read lazily.** `getState` serializes the datasets it lists, so it is **only called while authored view). Selection `*_store`s and `facet_domain*` layout tables aren't drawn, so
the panel is open** — a collapsed inspector costs nothing, which is why the panel reads on they fall out for free. The walk is pure (in `core`, unit-tested); the boundary reads each
demand rather than on every render. table's rows via `view.data(name)`.
- **Read lazily.** Reading serializes rows, so it happens **only while the panel is open**
Limitation: one name per direction can't represent a multi-view spec (layer/concat/facet a collapsed inspector costs nothing, which is why the panel reads on demand rather than on
produce several `data_<n>`); the most-downstream/upstream ones are returned, and a full every render. (A multi-view spec yields several tables; the panel's `SelectControl` picker
dataset selector is left as a future option. chooses which to show — labels never expose Vega's compiler names, see arch 10.)
- **Stay live under interaction.** A selection that _filters_ a downstream view recomputes
that view's compiled table in place (no re-embed), so the open panel re-reads to track it —
"what am I visualizing now". `RenderHandle.onDataChange` attaches a debounced
`view.addDataListener` to each drawn table; a highlight selection (a `condition` encoding)
changes no data, so it never fires. Always live, no toggle — gated on the panel being open
like the read itself, and re-subscribed per settled render so it tracks the current handle.
--- ---
@@ -533,11 +538,12 @@ blank mid-edit.
### A second preview surface: the Chart Builder ### A second preview surface: the Chart Builder
The editor's `LivePreview` is **bound to the snippet editor** — it reads `SnippetStore` The editor's `LivePreview` is **bound to the snippet editor** — it reads `SnippetStore`
(shown spec), `AppStore` (fit mode/theme), and `PreviewStore` (shared error). The (shown spec) and `AppStore` (fit mode/theme), and keeps its render status (`error`/`busy`) in
**Chart Builder modal** needs a preview of a _different_ spec source (its config), so it its own local state. The **Chart Builder modal** needs a preview of a _different_ spec source
does **not** reuse `LivePreview`; it runs its own small debounced render over the same (its config), so it does **not** reuse `LivePreview`; it runs its own small debounced render
`chart-renderer.renderSpec` + `prepareSpecForRender`, with **local** error state (never the over the same `chart-renderer.renderSpec` + `prepareSpecForRender`, with its own **local**
shared `PreviewStore`, which would cross-talk with the editor). Two preview surfaces, one error state. Each preview surface owns its render status locally — no shared store to
cross-talk. Two preview surfaces, one
renderer service. Builder flow: `chart-builder.ts` (pure spec assembler) → `ChartBuilderStore` renderer service. Builder flow: `chart-builder.ts` (pure spec assembler) → `ChartBuilderStore`
(config + create) → `ChartBuilderModal`'s `BuilderPreview`. Reach for a reusable preview (config + create) → `ChartBuilderModal`'s `BuilderPreview`. Reach for a reusable preview
component only if a _third_ surface appears. component only if a _third_ surface appears.
@@ -598,13 +604,13 @@ the existing view is re-measured via a `ResizeObserver`-driven event — see §8
A spec that cannot be rendered must produce a **readable** message in the preview A spec that cannot be rendered must produce a **readable** message in the preview
area and recover on its own once the spec is valid again. Errors arise at three area and recover on its own once the spec is valid again. Errors arise at three
stages, all funneled to one error field the preview reads: stages, all funneled to the one error state the preview owns:
| Stage | Failure | Surfaced as | | Stage | Failure | Surfaced as |
| -------------------------------- | -------------------------------------- | ---------------------- | | -------------------------------- | -------------------------------------- | --------------------------------- |
| Parse | Invalid JSON | "Invalid JSON: …" | | Parse | Invalid JSON | `Invalid JSON · …` |
| Prepare (`prepareSpecForRender`) | Referenced dataset missing/unfetchable | "Dataset not found: …" | | Prepare (`prepareSpecForRender`) | Referenced dataset missing/unfetchable | `Dataset "x" not found · …` |
| Embed (`vega-embed`) | Vega-Lite compile / data error | "Rendering error: …" | | Embed (`vega-embed`) | Vega-Lite compile, or a bad expression | `Line N · …` / `Render error · …` |
```ts ```ts
// inside render(), driven by the debounced renderer // inside render(), driven by the debounced renderer
@@ -615,7 +621,7 @@ async function render(): Promise<void> {
if (!text) { if (!text) {
current?.destroy(); current?.destroy();
current = null; current = null;
usePreviewStore.getState().setError(null); setError(null); // `error`/`busy` are the pane's local state, not a store
return; return;
} }
@@ -623,7 +629,7 @@ async function render(): Promise<void> {
try { try {
parsed = JSON.parse(text); parsed = JSON.parse(text);
} catch (e) { } catch (e) {
usePreviewStore.getState().setError(`Invalid JSON: ${(e as Error).message}`); setError(`Invalid JSON · ${(e as Error).message}`);
return; // keep the last good chart underneath the error, or show the message return; // keep the last good chart underneath the error, or show the message
} }
@@ -633,14 +639,9 @@ async function render(): Promise<void> {
const config = chartConfigFor(uiTheme); const config = chartConfigFor(uiTheme);
current?.destroy(); current?.destroy();
current = await renderSpec(node, prepared, config); current = await renderSpec(node, prepared, config);
usePreviewStore.getState().setError(null); // success clears any prior error setError(null); // success clears any prior error
} catch (e) { } catch (e) {
usePreviewStore setError(`Render error · ${(e as Error).message}`);
.getState()
.setError(
`Rendering error: ${(e as Error).message}. ` +
`Check your JSON syntax and that the spec is valid Vega-Lite.`,
);
} }
} }
``` ```
@@ -655,10 +656,10 @@ manual retry, no reload.
- **Do** treat empty/blank spec text as "render nothing" — finalize the current - **Do** treat empty/blank spec text as "render nothing" — finalize the current
view, clear the error, show a clean empty pane. view, clear the error, show a clean empty pane.
- **Do** clear the error state on every successful render. - **Do** clear the error state on every successful render.
- **Do** make messages legible and actionable (the underlying reason plus a hint - **Do** make messages legible and actionable — lead with the location or the failing
to check JSON/Vega-Lite validity), never a raw stack trace dump. stage, then the underlying reason — never a raw stack trace dump.
- **Do** distinguish the failing stage in the message (Invalid JSON vs Dataset - **Do** distinguish the failing stage in the message (invalid JSON vs missing dataset
not found vs Rendering error). vs a bad expression or render error).
- **Don't** show a broken/partial chart — replace the chart area with the - **Don't** show a broken/partial chart — replace the chart area with the
message. message.
- **Don't** require a manual "retry"; validity restores the chart on its own. - **Don't** require a manual "retry"; validity restores the chart on its own.
@@ -724,5 +725,5 @@ bookkeeping. Gate the observer to responsive modes (Original needs no re-fit).
| Field names | `escapeVegaField` on every data-derived `field:` | `src/core/rendering.ts` | | Field names | `escapeVegaField` on every data-derived `field:` | `src/core/rendering.ts` |
| Debounce | Inline timer; `0` on buffer-load/view-switch, `renderDebounce` on keystroke (§5) | `LivePreview.tsx` (service not yet extracted) | | Debounce | Inline timer; `0` on buffer-load/view-switch, `renderDebounce` on keystroke (§5) | `LivePreview.tsx` (service not yet extracted) |
| Spec prep | `prepareSpecForRender` (pure, on a copy) | `src/core/rendering.ts` (see _Live Preview_) | | Spec prep | `prepareSpecForRender` (pure, on a copy) | `src/core/rendering.ts` (see _Live Preview_) |
| Errors | One error field, cleared on success, empty = nothing | `PreviewStore.error` | | Errors | One error field, cleared on success, empty = nothing | `LivePreview` local state (`error`/`busy`) |
| Container fit | Inner host + frame (out-specify `.vega-embed`); resize via synthetic `window:resize` | §8 (`LivePreview` + `chart-renderer`) | | Container fit | Inner host + frame (out-specify `.vega-embed`); resize via synthetic `window:resize` | §8 (`LivePreview` + `chart-renderer`) |
@@ -135,70 +135,50 @@ without waiting for a publish. Recomputation runs only on a _valid_ spec —
auto-save is debounced and parse-gated (spec §03B) — so a transiently-invalid auto-save is debounced and parse-gated (spec §03B) — so a transiently-invalid
draft never disturbs the links. draft never disturbs the links.
### 3.1 Extracting referenced names from a spec (pure — `src/core/spec-refs.ts`) ### 3.1 What counts as a reference, and extracting them (pure — `src/core/spec-data.ts`, `spec-refs.ts`)
A Vega-Lite spec can reference named data in several places: the top-level A library reference is exactly Vega-Lite **named data**: a `data` block with a
`data`, per-layer `data`, `data` inside `spec`/`facet`/`hconcat`/`vconcat`, and a string `name` and no `values`, `url`, or generator key (`sequence`/`sphere`/
lookup transform's `from.data`. A spec may also define its OWN inline datasets via `graticule`), whose name the spec does not define for itself via a top-level
a top-level `datasets` map — those are self-defined, not library references. `datasets` map. A `name` riding on inline `values` or a `url` is Vega-Lite's
Rather than enumerate Vega-Lite's grammar, we walk the spec recursively and runtime-rebind label — not a dependency — and self-defined `datasets` names
collect every `{ data: { name } }` — but **prune two keys**: never recurse into a resolve natively; both are left untouched. This mirrors Vega-Lite's own
`data` object's payload (its `values`/rows) or the top-level `datasets` map, `isNamedData`, so Astrolabe **extends** Vega-Lite rather than diverging: every
because those hold user data, not nested specs. Without the prune, a data _row_ native data form keeps working, and only true references are tracked and resolved.
carrying a field literally named `data: { name: "x" }` is misread as a reference.
This is pure, deterministic, and the most heavily unit-tested function here. That classification lives in **`core/spec-data`** (`classifyData`,
`libraryRefName`) — the single predicate that reference extraction, rename
(`spec-refs`), and render-time resolution (`rendering`) all route through, so they
cannot disagree on what is a dependency.
```ts ```ts
// src/core/spec-refs.ts // src/core/spec-data.ts — the shared classifier (mirrors vega-lite/src/data.ts)
type Json = unknown; /** The library name a `data` block references, or null for native VL data / self-defined names. */
export function libraryRefName(data: unknown, selfDefined: ReadonlySet<string>): string | null {
/** Collects every library dataset name referenced by `{ data: { name } }`, excluding self-defined ones. */ if (classifyData(data) !== 'named') return null; // url | values | generator → not a reference
export function extractDatasetRefs(spec: Json): string[] { const { name } = data as { name: string };
const root = typeof spec === 'string' ? safeParse(spec) : spec; return selfDefined.has(name) ? null : name;
const selfDefined = selfDefinedNames(root); // names from the spec's own top-level `datasets`
const names = new Set<string>();
const walk = (node: Json): void => {
if (Array.isArray(node)) {
for (const item of node) walk(item);
return;
}
if (node && typeof node === 'object') {
const obj = node as Record<string, Json>;
const data = obj.data as Record<string, Json> | undefined;
if (data && typeof data === 'object' && typeof data.name === 'string') {
if (!selfDefined.has(data.name)) names.add(data.name);
}
// Prune: a `data` payload and the `datasets` map hold user data, not refs.
for (const key of Object.keys(obj)) {
if (key === 'data' || key === 'datasets') continue;
walk(obj[key]);
}
}
};
walk(root);
return [...names];
}
function safeParse(s: string): Json {
try {
return JSON.parse(s);
} catch {
return null; // an unparseable draft simply has no resolvable refs
}
} }
``` ```
```ts References appear in several places — top-level `data`, per-layer `data`, `data`
// src/core/spec-refs.ts — thin wrapper, run on every draft change and on publish inside `spec`/`facet`/`hconcat`/`vconcat`, and a lookup transform's `from.data`.
Rather than enumerate the grammar, the walk recurses the spec but **prunes two
keys**: a `data` object's payload (its `values`/rows) and the top-level `datasets`
map hold user data, not nested specs. Without the prune, a data _row_ carrying a
field literally named `data: { name: "x" }` is misread as a reference.
/** The list stored on snippet.datasetRefs. Sorted + de-duped for stable diffs. */ That walk is **one shared pair** in `core/spec-data`, not re-implemented per pass:
export function recomputeDatasetRefs(spec: Json): string[] { `forEachDataBinding(spec, visit)` (read-only, `visit` returns `true` to stop early)
return extractDatasetRefs(spec).sort(); and `mapDataBindings(spec, mapData)` (returns a copy). Both compute the spec's
} self-defined names once and hand them to the callback, so a caller writes only its
``` own rule — _what is a reference_ (`libraryRefName`) or _what to rewrite_ — never the
walk. `extractDatasetRefs` collects through `forEachDataBinding`; `renameDatasetInSpec`
and `promoteSelfDefinedDataset` (§3.2) rewrite through `mapDataBindings`.
`recomputeDatasetRefs` is the thin sorted/de-duped wrapper stored on
`snippet.datasetRefs`, run on every draft change and on publish.
> A `spec` may be an object or a string (see the Data Model). Normalize once, > A `spec` may be an object or a string (see the Data Model). Normalize once,
> at the boundary, so the recursive walk never has to care. > at the boundary, so the recursive walk never has to care.
@@ -207,27 +187,57 @@ export function recomputeDatasetRefs(spec: Json): string[] {
- Treat `extractDatasetRefs` as the single source of truth for "what does this - Treat `extractDatasetRefs` as the single source of truth for "what does this
spec reference". The reverse-lookup and rename paths both depend on it spec reference". The reverse-lookup and rename paths both depend on it
agreeing with what the renderer actually resolves. agreeing with what the renderer actually resolves — which holds because all of
them classify through the same `core/spec-data` predicate.
- Recompute and store `datasetRefs` on **every draft change and on publish** - Recompute and store `datasetRefs` on **every draft change and on publish**
but only through the parse-gated, debounced auto-save (`commitDraft`) and the but only through the parse-gated, debounced auto-save (`commitDraft`) and the
programmatic extract/revert rewrites, never on raw keystrokes. That keeps the programmatic extract/revert rewrites, never on raw keystrokes. That keeps the
links in step with the edited draft while never recomputing from a links in step with the edited draft while never recomputing from a
transiently-invalid spec. transiently-invalid spec.
- Prune the **same two keys** (`data`, `datasets`) in all three ref walks — - Route every binding pass through the shared `forEachDataBinding` /
extraction here, `renameDatasetInSpec`, and the renderer's `resolveDatasetRefs` `mapDataBindings`. They share both halves that must agree — the `libraryRefName`
(`src/core/rendering.ts`). They must agree on what counts as a reference; if one classifier (what is a reference) and the prune of the **same two keys** (`data`,
descends into data payloads and another doesn't, extraction and rendering `datasets`) — so extraction, rename, and the reverse extraction cannot drift. If
disagree and a row field named `data` either gets counted, rewritten, or throws one classified differently, or descended into data payloads while another didn't,
`DatasetNotFoundError`. a row field named `data` would get counted, rewritten, or throw
`DatasetNotFoundError`. (The renderer's `resolveDatasetRefs` mutates in place and
can throw mid-walk, so it keeps its own copy of the walk — the one exception, held
in step by the same prune-two-keys rule.)
**Don't** **Don't**
- Don't let two code paths each have their own idea of "referenced names". - Don't let two code paths each have their own idea of "referenced names".
Renamer, ref-recomputer, and renderer must use the same walk shape. Renamer, ref-recomputer, and renderer must use the same walk shape.
- Don't add a fresh prune-walk for a new binding pass — reuse the shared pair. A
hand-written copy is one classifier tweak away from disagreeing with the others.
- Don't "enumerate the grammar" (scope the walk to a fixed list of container - Don't "enumerate the grammar" (scope the walk to a fixed list of container
keys) to fix the payload-descent problem — pruning the two data-bearing keys keys) to fix the payload-descent problem — pruning the two data-bearing keys
stays correct as Vega-Lite's composition grammar grows; an allow-list rots. stays correct as Vega-Lite's composition grammar grows; an allow-list rots.
### 3.2 Extracting embedded data into a dataset — the reverse (`spec-inline-data.ts`, `spec-refs.ts`)
Extract-to-Dataset is the inverse of a reference: it lifts a view's **embedded**
data into a stored dataset and rewrites the spec to reference it by name. It is a
cursor-scoped editor action — `services/extract-action` resolves the focused view's
binding (`dataBindingAtPath`), seeds the modal, then opens it; the gate
`specHasExtractableData` hides the toolbar action when no view carries liftable
data. Two embedded shapes lift, both routing through the same `spec-data` classifier
so they never disagree with reference detection:
- **Inline `values`** — `inlineValuesOf` captures the payload verbatim (a CSV/TSV
string is kept as-is); confirm rewrites that view's `data` block, at its anchor
path, to `{ name }`.
- **A self-defined `datasets` entry** the view references — `selfDefinedPayloadOf`
reads the named rows and `promoteSelfDefinedDataset` drops the `datasets` entry
(and the map when it empties). Keeping the name needs no reference rewrite — it
un-shadows onto the new library dataset; renaming rewrites every matching
reference. This is the reverse direction of the self-defined-vs-library
precedence in §3.1.
A `lookup` transform's inline `from.data` lifts like any view binding —
`dataBindingAtPath` finds it. A `url`, a generator, or an existing library
reference carries nothing to lift.
--- ---
## 4. Reverse lookup: who uses this dataset? ## 4. Reverse lookup: who uses this dataset?
@@ -341,7 +351,7 @@ detection + normalization + the dedupe/rename/id-reassign helpers; `core/export-
- browser IO (`infrastructure/file-transfer.ts`). The pure helpers are unit-tested - browser IO (`infrastructure/file-transfer.ts`). The pure helpers are unit-tested
hardest; `transfer.ts` only orchestrates (read stores → call core → commit → hardest; `transfer.ts` only orchestrates (read stores → call core → commit →
notify). The behavioral contract is spec §08. notify). The behavior is recorded in spec §08.
Three rules a future change must keep: Three rules a future change must keep:
+201 -1
View File
@@ -6,7 +6,7 @@
> borrowing and the gotchas worth avoiding, so we don't rediscover them from scratch in > borrowing and the gotchas worth avoiding, so we don't rediscover them from scratch in
> M1/M2. > M1/M2.
> >
> It is a **reference**, not a contract. The behavioral contract is still [`docs/spec/`](../spec/); > It is a **reference**, not a contract. The behavioral record is still [`docs/spec/`](../spec/);
> the patterns are still docs [01](01-state-and-stores.md)[07](07-naming-and-relationships.md). > the patterns are still docs [01](01-state-and-stores.md)[07](07-naming-and-relationships.md).
> This doc is the bridge: "here is how the canonical implementation does the editor/renderer > This doc is the bridge: "here is how the canonical implementation does the editor/renderer
> plumbing, and here is what we keep vs. improve." > plumbing, and here is what we keep vs. improve."
@@ -275,6 +275,206 @@ defaults-spread" discipline is worth keeping.
--- ---
## 5 · Editor augmentation (our layer over the borrowed base)
Beyond schema validation/completion (§1), the spec editor adds structural refactors,
data-aware hints, and expression intelligence — the edits and feedback that are awkward in
raw JSON and out of reach of the single-view visual builder. All transform and analysis
logic is pure `src/core/`; the Monaco glue is thin app-layer services.
**Core (pure, portable):**
- `spec-transforms` — wrap a view in `layer`/`hconcat`/`vconcat`/`facet`/`repeat`; collapse
a single-child composition (`unwrapSingleton`). Object-in/object-out. (Surfaced as the
toolbar **Compose** menu — named to read distinctly from a data `transform`, below.)
- `spec-data-transforms` — the data-pipeline counterpart: `transformSiteAt` resolves the view
the cursor is in and its `transform[]` range/count (for the CodeLens), `transformPlacementAt`
classifies a step slot (for the completion) — both tagged `shared` when the pipeline sits on a
composition parent, `view` on a unit. `DATA_TRANSFORMS` is the field-typed step catalog (filter,
calculate, aggregate, bin, timeUnit, window, joinaggregate, fold, lookup); each builder takes the
columns in scope and seeds each `${n:default}` tab stop with a type-appropriate one. A test
round-trips every snippet through `snippetToPlain` to assert it's valid JSON.
- `spec-params` — the parameter counterpart: `paramSiteAt` resolves both homes a parameter can take
(the **root** spec for a variable widget, the **nearest enclosing unit** for a selection —
grammar-confirmed against the bundled schema: a nested unit's `params[]` takes selections only),
and `paramPlacementAt` classifies a `params[]` slot (root → both families, nested → selections only)
for the completion. `PARAMS` is the catalog (slider, dropdown, radio, checkbox; point, interval); a
slider's bounds seed from the numeric field's `numericExtent`, a point selection's field from a
categorical column. A test validates every seeded entry against the bundled Vega-Lite schema.
- `spec-snippet` — the snippet-insertion text math both scaffolds share: `snippetToPlain`
(tab stops → default text, so catalogs and edits round-trip through `JSON.parse` in tests),
`arrayAffixes`/`appendEntryEdit` (the comma affixing that keeps a spliced array valid), and
`createArrayPropertyEdit` (the inline `"key": [ … ]` first-property insertion and its indent
math). Pure `(text, site) → { offset, snippet }` — a bug here writes invalid JSON into the
user's editor, so the edits are table-tested applied-and-reparsed, off the Monaco glue.
- `spec-data` — the Vega-Lite data model: classify a `data` block (`classifyData`, mirroring
`isNamedData`), the library reference name (`libraryRefName`), and the data binding in scope
at a cursor path (`dataBindingAtPath`, honoring a view's data inheritance from its parent).
- `spec-cursor``findViewRange` (cursor offset → the enclosing view's byte range),
`pathAtOffset` (cursor → JSON path), and `valueKeyAtOffset`/`stringValueAtOffset` (the JSON
context at the cursor), over `jsonc-parser`.
- `spec-fields` — field names a spec's transforms introduce (their `as`), scoped to a
cursor's ancestor chain (`derivedFieldNamesAtPath`).
- `spec-inline-data` — the rows a specific `data` binding carries for profiling
(`rowsForDataBinding`: inline `values`, or a self-defined `datasets` entry).
- `spec-insert` — the composition the cursor is in (`compositionTargetAt`), inserting a view
at an index (`insertView`) and reordering siblings (`moveView` swaps a neighbour, `moveViewTo`
slides to any index), plus `elementOffset` to re-find a view after the edit. It also owns the
shared `SpecPath` walkers (`valueAtPath`/`arrayAtPath`/`isPrefixPath`) — a module navigating a
path reuses these rather than re-inlining the array/object descent.
- `spec-view-tree` — the whole composition as a recursive tree (`viewTree`), each node carrying
its operator, orientation and byte range. Read at once (vs. `spec-insert`'s one-array edits) to
drive the composition wireframe — a schematic of the multi-view structure in a preview-toolbar
disclosure (`CompositionWireframe`); clicking a box reveals that view's range in the editor via
`AppStore.requestRevealView`, and on the draft it is **drag-editable** (reorder + restructure).
Interaction contract in [arch 10](10-interaction-and-feedback.md).
- `spec-restructure` — the path-targeted cross-container moves behind the wireframe's drag.
`wrapViews(target, source, axis, side)` pairs the dragged source beside the drop target in a new
concat (placed where the target was, the source removed), enforcing three invariants:
**flatten** a bare same-orientation concat nested directly in a concat (so a with-axis drop reads
as a plain _insert_, not redundant nesting), **collapse** the source's emptied container (unwrap a
one-child, drop a zero-child, recursing up the chain), and **data-pin** a source's inherited
`data` before it changes ancestor (`dataBindingAtPath`, so it never silently rebinds). Degenerate
drops — onto itself, its own ancestor/descendant, or the root — return null. `wrapContainer` is the
complement for a frame-margin drop: it stacks the source against the _whole_ container rather than
beside one view — the root included, lifting spec-level metadata onto the wrapper via
`spec-transforms.concatRootBeside` — pulling a view out into a new full-span row/column. The same
flatten/collapse cleanup runs after. `simplifyStructure` collapses redundant single-child
compositions recursively (a `{hconcat:[v]}` is just `v`; `facet`/`repeat` hold one child by design
and are left alone) — the wireframe's Simplify, returning null when nothing is redundant.
- `expr-validate` — one Vega expression, parsed with Vega's own `parseExpression` (no divergent
grammar): `validateExpression` (valid + parser message), `referencedFields` (its `datum.<field>`
references), and `activeCall` (the enclosing call + which argument the cursor is in, for signature
help).
- `spec-expressions` — the expressions embedded in a spec's JSON strings (`EXPRESSION_KEYS` =
`calculate`/`filter`/`expr`/`test`; only string values, so object predicates are skipped).
`expressionStringsIn` locates each (byte span + key) to drive markers; `firstExpressionError`
names the first malformed one (key + parser message + 1-based line) so a failed render can
attribute itself.
- `vega-expr-catalog` — the expression language's function/constant **names derived from
`vega-expression`'s own registry** (zero drift; a test asserts the curated set ⊆ derived), plus
curated parameter signatures for the commonly-typed functions (what the registry can't supply).
**Services (app, store-aware via `getState`):** `spec-transform-actions` (the
wrap/simplify/add-view operations and their surfaces), `spec-transform-scaffold` (the
data-transform scaffold — a CodeLens plus a completion), `spec-param-scaffold` (the parameter
scaffold — likewise a CodeLens plus a completion), `spec-dataset-hints` (data-column completion, hover,
inlay providers), `spec-expression-hints` (expression completion, signature help, hover, and
the diagnostic markers), `active-dataset` (`dataInfoAt(text, offset)` — the columns/types/stats
plus derived fields the draft sees at the cursor). `SpecEditor` does the wiring.
Decision rules:
- **Provider lifetime — global-once vs per-editor.** Language providers that need no editor
handle (code actions, completion, hover, inlay) register **once** for `json`, like the
schema and formatter; per-editor registration would duplicate them on remount. Pieces that
need the editor handle — the `addAction` context/F1 commands, and the CodeLens whose command
runs `executeEdits` — are installed **per editor** and disposed with it.
- **Cursor-aware CodeLenses share one skeleton.** The composition lens
(`spec-transform-actions`), the transform scaffold, and the parameter scaffold are the same
per-editor shape: a draft-gated provider that re-resolves the site under the cursor and refreshes
only when a keyed summary of it changes. That skeleton is `installCursorLens(editor, { resolve,
keyOf, lensesOf })` (`services/editor-cursor-lens`) — a new cursor lens supplies those three and its
own commands, nothing else. The tab-through snippet splice and the completion-slot plumbing both
scaffolds perform are shared the same way via `editor-snippet` — the Monaco glue over
`core/spec-snippet`'s tested edit math.
- **Transform scope.** A transform targets, in order: an explicit selection → the view the
cursor sits in (`findViewRange`) → the whole document. A "view" is a composition-array
element or a facet/repeat `spec` child; a flat unit spec has no inner view, so it scopes to
the whole document. `jsonc-parser` is error-tolerant, so scoping holds mid-edit; the path
logic stays in core and only the Monaco `Range` is built in the service.
- **One edit path.** The lightbulb returns a `WorkspaceEdit` (no editor handle); the toolbar
and palette use `executeEdits`. Both build the replacement through the same
serialize-and-reindent step, bracketed by `pushUndoStop`, so ⌘Z restores the prior text.
- **Transform actions share an applier, never re-inline the skeleton.** Every `run*` action is
parse → `build(spec)``writeBack` (toast on null). That prologue lives in a shared applier per
family — `resolveTarget` (scoped), `applyArrayEdit` (one array), `applyWholeSpecEdit` (whole-spec
drag/simplify) — so a new action passes its core call and differs only in scope and feedback. A
fresh `run*` reuses the matching applier rather than copying the model/parse/writeBack lines.
- **Wireframe restructuring is a core op + the editor's undo.** Every drag resolves to `moveViewTo`
(reorder within one container), `wrapViews` (pair, cross-container move, insert), or `wrapContainer`
(pull a view out around a container); the Simplify prompt resolves to `simplifyStructure`. The
wireframe only _requests_ each (`AppStore.requestComposeMove`/`requestComposeWrap`/
`requestComposeWrapContainer`/`requestComposeSimplify`) and `SpecEditor` applies it through the same
`executeEdits` + `pushUndoStop` path as the other transforms, so a drag is one ⌘Z and the editor
stays the single text source. `wrapViews` covers wrap and insert with one operation because it
flattens bare same-orientation nesting afterward; the drag's zone interaction model lives in
[arch 10](10-interaction-and-feedback.md).
- **Composition CodeLens is cursor-scoped.** It follows the view the cursor sits in —
` Add view above/below` at the view's edges and `↑/↓ Move` to reorder among its siblings —
rather than one fixed button per composition array; an empty composition shows a single ` Add view`,
and the F1 palette mirrors all four for the keyboard. The provider reads `editor.getPosition()`
and refreshes via an `onDidChange` emitter fired on cursor moves (keyed to the enclosing view,
so typing inside one view doesn't churn the lenses). After an edit the cursor follows the
affected view (`elementOffset`), so a repeated click keeps acting on it instead of the
neighbour that slid into place. Cursor-scoping does not strand a load-bearing action
([arch 10](10-interaction-and-feedback.md) — revealed actions): editing a composition puts
the cursor in a view exactly when add/reorder is wanted, and the palette is the ever-present
path for the keyboard.
- **Field source for hints (view-scoped).** `dataInfoAt(text, offset)` resolves the data
binding of the cursor's **nearest enclosing view** (`dataBindingAtPath` — a child inherits a
parent's data unless it declares its own), then its columns: a named **library dataset**
(matched case-insensitively, like the renderer), else the binding's **inline rows** profiled
on the fly (`rowsForDataBinding` + `core/profile`) — a "ghost dataset" with nothing stored.
Derived fields come from that view's and its ancestors' transforms only
(`derivedFieldNamesAtPath`), so a sibling view's `calculate` does not leak in. Profiling is
memoized per (draft text, enclosing view), so the inlay provider's many per-line queries
profile once. A composed spec whose views bind different datasets therefore gets the right
columns per view. Out of scope: data-dependent derived columns (`pivot`/`lookup` output) and
`url`/CSV-string inline data, which need the pipeline run or format-aware parsing.
- **No unknown-field diagnostic.** Hints are additive and forgiving, so over- or
under-listing costs nothing; a "field not in data" squiggle would false-positive on every
derived or data-dependent field, so there is deliberately none.
- **Expression intelligence is one service; its markers are per-editor.** `spec-expression-hints`
registers the expression completion/signature-help/hover **once for `json`** (like the other
providers), but the marker pass — validating every expression string and squiggling the invalid
ones with `setModelMarkers` (the app's only editor markers besides the JSON worker's, under the
`vega-expr` owner) — is **per editor**, since it writes to one model, and recomputes debounced on
edit and on a draft↔published toggle. All expression concerns (completion, hover, markers) live
here; `spec-dataset-hints` owns only data-column hints, so neither is a grab-bag.
- **Completion replace-ranges come from a self-parsed partial, never `getWordUntilPosition`.**
Monaco's JSON `wordPattern` counts `.` and `(` as word characters, so the model's "word" after
`datum.` or `fn(` spans the whole `datum.`/`fn(` token; used as a completion item's range it both
mis-targets the edit and filters every suggestion out (none start with `datum.`). A provider
completing inside a string must build the replace range from the partial it parses itself — a rule
the transform scaffold inherits (it parses the trailing element word off the line). This
`new Range(line, col partialLen, line, col)` construction is now at three sites
(`spec-expression-hints`, `spec-dataset-hints`, `spec-transform-scaffold`); a shared
`replaceRange(position, partialLength)` helper is earned and should be extracted on the next touch.
- **Data-transform scaffolding is a CodeLens (discoverable) plus a completion (accelerator), on
the one home the schema leaves bare.** A Vega-Lite data `transform` has three possible homes,
confirmed against the bundled schema (the `transform` array is on 16 spec types, i.e. every view
node): a `transform[]` **step**; the **inline** field props on an encoding channel
(`bin`/`timeUnit`/`aggregate`/`sort`); and a **new** pipeline on a bare view. We scaffold the step
home only, on the same "add only what the schema lacks" rule as `spec-dataset-hints`: the schema
already completes the inline channel keys and their enum values, and the `transform` key itself —
but never a ready, field-typed `{ "filter": … }`. The **CodeLens is the discoverable surface**
(a completion is invisible until provoked and competes silently with the schema's suggest items):
cursor-scoped like the composition CodeLens, it shows ` Add transform` on a view with no pipeline
and per-step ` filter`/` aggregate`/… on the array, each clicking through Monaco's snippet
engine so the field-typed tab stops survive. The completion is the type-to-filter accelerator on
the same catalog. A step's `scope` (`shared` when the array is on a composition parent, so it
feeds every child) is surfaced so the placement is not a surprise, and comma affixing keeps the
array valid whether the slot is empty, between elements, or appended after one without a trailing comma.
- **Parameter scaffolding splits by family, because `params` has two homes.** Unlike a `transform`
(on every view node), a **variable** widget (slider/dropdown/radio/checkbox, bound via `bind`) is only
legal in the **root** `params[]` — a document-global input any view's `filter` can read — while a
**selection** (point/interval, via `select`) attaches to the **unit** whose marks it reads. So the
scaffold does not reuse `transformSiteAt`: `paramSiteAt` resolves both homes, and the CodeLens is
cursor-scoped to match — variable widgets show at the top level (or a single-view spec, where the
root _is_ the unit, so both families land on one line), selections on the unit the cursor is in, and a
nested unit hides the variable widgets it cannot hold. The completion offers both families in the root
array, selections only in a nested unit's. Defaults are data-seeded where the data allows (a slider's
`min`/`max` from the numeric field's extent, a point's `fields` from a categorical column) and
structured placeholders otherwise (a dropdown's options).
- **Code-action menu icons are kind-derived** (a wrench for the `refactor.*` kinds) — Monaco's
`CodeAction` carries no icon field. Custom iconography lives only where it is supported:
CodeLens titles (`$(codicon)`), completion-item kinds, and glyph-margin decorations.
`jsonc-parser` is a direct dependency (Monaco bundles its own copy internally but does not
re-export it). A standalone `editor-augmentation-demo.html` loads Monaco from a CDN to
exercise these provider surfaces in isolation.
## Borrow list (where each lands) ## Borrow list (where each lands)
| Technique | Lands in | Milestone | | Technique | Lands in | Milestone |
+14 -2
View File
@@ -329,7 +329,7 @@ Carbon and drawn `fill: currentColor`.
**Core set** — recurring, cross-surface: **Core set** — recurring, cross-surface:
| Meaning | Carbon glyph | Form | Surfaces | | Meaning | Carbon glyph | Form | Surfaces |
| -------------------- | --------------- | ------------------------ | -------------------------------------------------------------------------------------------- | | --------------------- | --------------- | ------------------------ | -------------------------------------------------------------------------------------------- |
| Close / dismiss | `Close` (✕) | icon-only ⭐ | `ModalShell`, `Toaster` | | Close / dismiss | `Close` (✕) | icon-only ⭐ | `ModalShell`, `Toaster` |
| Theme → dark | `Asleep` (moon) | icon-only ⭐ | `ThemeToggle` (shown when light) | | Theme → dark | `Asleep` (moon) | icon-only ⭐ | `ThemeToggle` (shown when light) |
| Theme → light | `Light` (sun) | icon-only ⭐ | `ThemeToggle` (shown when dark) | | Theme → light | `Light` (sun) | icon-only ⭐ | `ThemeToggle` (shown when dark) |
@@ -343,6 +343,7 @@ Carbon and drawn `fill: currentColor`.
| Live search | `Search` | icon-in-field⁵ | Library search box (leading magnifier; the input's `aria-label`/placeholder names the field) | | Live search | `Search` | icon-in-field⁵ | Library search box (leading magnifier; the input's `aria-label`/placeholder names the field) |
| Settings (gear) | `Settings` | icon-only ⭐ | Per-pane settings disclosures (Editor, Preview, Library dates) | | Settings (gear) | `Settings` | icon-only ⭐ | Per-pane settings disclosures (Editor, Preview, Library dates) |
| Unpublished draft | (CSS dot) | status-glyph | Library row (paired with a hidden label) | | Unpublished draft | (CSS dot) | status-glyph | Library row (paired with a hidden label) |
| Composition structure | (custom frame) | icon-only ⭐ | Preview toolbar — composition-wireframe disclosure (a frame holding nested view blocks) |
**Pane-toggle set** — the one **custom** sub-family (not single Carbon glyphs): a **Pane-toggle set** — the one **custom** sub-family (not single Carbon glyphs): a
panel frame with one of three regions filled, where the filled bar's _position_ panel frame with one of three regions filled, where the filled bar's _position_
@@ -370,6 +371,16 @@ notifications/validation as they arrive):
| Success | `CheckmarkFilled` | `--support-success` | `Toaster` (success) | | Success | `CheckmarkFilled` | `--support-success` | `Toaster` (success) |
| Info | `InformationFilled` | `--support-info` | `Toaster` (info) | | Info | `InformationFilled` | `--support-info` | `Toaster` (info) |
**Mark set** — a custom `mark-*` sub-family, one simplified glyph per Vega-Lite mark
(bar, line, area, point, arc, rect, tick, rule, text, plus a `mark-generic` fallback),
drawn on the same 32-grid (stroked where a line reads truer than a fill). It labels the
leaves of the composition wireframe so views read apart at a glance; mark synonyms
(circle/square → point, trail → line, image → rect) collapse onto it via `markIconName`
(`mark-icon.ts`), and an unknown or absent mark falls to `mark-generic`. Decorative there
(the box's `aria-label` names the view), so these are not in the ⭐ icon-only set. The sibling
`layers` glyph (two offset planes) badges a `layer` node in the same wireframe — a row of marks
in one frame — as a single shared space rather than a concat's box-per-view.
**Scoped set** — single-surface, glyph **reserved** in the ledger but **not yet in **Scoped set** — single-surface, glyph **reserved** in the ledger but **not yet in
the `Icon` registry**: the `Icon` registry**:
@@ -379,7 +390,8 @@ the `Icon` registry**:
⭐ = **icon-only set** (the glyph alone names the control, via `aria-label`): the ⭐ = **icon-only set** (the glyph alone names the control, via `aria-label`): the
**universal** glyphs `close` + `theme`; the conventional disclosure/affordance **universal** glyphs `close` + `theme`; the conventional disclosure/affordance
glyphs `settings` (gear) and the **pane-toggle** trio (position is the meaning); glyphs `settings` (gear) and `structure` (the composition-wireframe frame) and the
**pane-toggle** trio (position is the meaning);
and **delete** as a deliberate destructive-row exception — a dense, repeated list and **delete** as a deliberate destructive-row exception — a dense, repeated list
action where a label would cost more than it gives. `search` is _not_ ⭐: its action where a label would cost more than it gives. `search` is _not_ ⭐: its
magnifier is a decorative lead-in to a labelled input (footnote ⁵), not a control magnifier is a decorative lead-in to a labelled input (footnote ⁵), not a control
@@ -35,10 +35,10 @@ interchangeable; picking the wrong one is the most common interaction bug. Choos
nature of the message, not by convenience. nature of the message, not by convenience.
| Channel | Use when | Blocks? | Dismissal | Implemented by | | Channel | Use when | Blocks? | Dismissal | Implemented by |
| -------------------- | --------------------------------------------------------------------------------------------------- | ----------- | ------------------------------------------------------------------------- | -------------------------------------------------------- | | -------------------- | ------------------------------------------------------------------------------------------------------------------------- | ----------- | ------------------------------------------------------------------------- | ----------------------------------------------------- |
| **Confirm dialog** | A **destructive or irreversible** action needs explicit consent (delete, revert, reset) | Yes — modal | User must choose; Escape/Cancel = no; backdrop click does **not** dismiss | `ConfirmStore` + `ConfirmDialog` | | **Confirm dialog** | A **destructive or irreversible** action needs explicit consent (delete, revert, reset) | Yes — modal | User must choose; Escape/Cancel = no; backdrop click does **not** dismiss | `ConfirmStore` + `ConfirmDialog` |
| **Toast** | A **non-blocking outcome** happened the user should know about (save failed, published, imported) | No | Auto for success/info; persists for error/warning; always a close button | `NotificationStore` + `Toaster` | | **Toast** | A **non-blocking outcome** happened the user should know about (save failed, published, imported) | No | Auto for success/info; persists for error/warning; always a close button | `NotificationStore` + `Toaster` |
| **Inline error** | A problem is **tied to a specific surface** and recovers in place (invalid spec → editor + preview) | No | Clears automatically when the cause is fixed | `PreviewStore`, surfaced in `SpecEditor` + `LivePreview` | | **Inline error** | A problem is **tied to a specific surface** and recovers in place (invalid spec → preview, cause squiggled in the editor) | No | Clears automatically when the cause is fixed | `LivePreview` local state; `vega-expr` editor markers |
| **Status indicator** | **Passive, ambient** state worth glancing at (draft vs. published, storage usage) | No | N/A — it just reflects state | library draft dot; storage monitor (later) | | **Status indicator** | **Passive, ambient** state worth glancing at (draft vs. published, storage usage) | No | N/A — it just reflects state | library draft dot; storage monitor (later) |
**Rules.** **Rules.**
@@ -80,9 +80,12 @@ nature of the message, not by convenience.
the other publishes silently (the exact inconsistency this rule prevents). The shortcut is the other publishes silently (the exact inconsistency this rule prevents). The shortcut is
owned globally by the EventRouter (arch 04), so the helper is the only place the outcome is owned globally by the EventRouter (arch 04), so the helper is the only place the outcome is
confirmed. confirmed.
- **The same failure can light up two channels.** An unrenderable spec shows the _same_ - **A render failure has one message home: the preview.** An unrenderable spec shows its
message inline in both the editor (§03E) and the preview (§04) — one producer message in the preview pane (§04), where the chart would be — error _xor_ chart, since the
(`PreviewStore`), two subscribers. That's intentional, not duplication. preview's `error` state is non-null only when the render failed (success/empty clear it; export
failures report through the export UI, never here). The editor marks the offending spot with
an inline squiggle (§03E) rather than repeating the text. Render status is `LivePreview`'s own
local state (`error`/`busy`) — one producer, one surface, so it needs no store.
## 2. Latency & feedback budgets ## 2. Latency & feedback budgets
@@ -221,6 +224,49 @@ below) like `PaneSplitHandle`, with the gesture in `useRowResizeDrag` (the row t
_(Consulted via `/council` → WAI-ARIA APG `windowsplitter`. This bullet is the contract; _(Consulted via `/council` → WAI-ARIA APG `windowsplitter`. This bullet is the contract;
cite it, not the APG file.)_ cite it, not the APG file.)_
**Resolved — composition structure wireframe.** The preview toolbar's structure disclosure (a
schematic of the spec's multi-view composition — `CompositionWireframe`, arch 08) is a
**WAI-ARIA APG `tree`** inside a disclosure popover (`usePopover`): bare nested boxes are
`tree``treeitem``group`, single-select via `aria-selected`, **one tab stop with a roving
tabindex**, arrow keys in **logical (document) order** — Up/Down between nodes, Left → parent,
Right → first child, Home/End, Enter/Space activate — not spatial, since a mixed horizontal/
vertical layout makes spatial arrows ambiguous. Each leaf carries a glyph of its mark type (the
`mark-*` icon sub-family, arch 09 §5) so views read apart at a glance. A `layer` — one plotting
space with several marks stacked — renders as **one frame** holding its child marks as a row of
glyphs, badged as layered (the `layers` glyph), rather than the box-per-view of a concat; each
mark stays an individual `treeitem` so selection and z-order reorder still work. Selecting a box reveals +
selects that view's source range in the editor (`AppStore.requestRevealView`) but **does not steal
focus**, so the wireframe stays the active surface while the editor scrolls to follow; the editor
selection is the single source of truth. The toolbar glyph appears **only for a composed spec**
a single-view spec hides the affordance rather than disclosing an empty tree. _(Council: APG
treeview; the cursor-scoping reachability rationale is in [arch 08](08-vega-editor-techniques.md).)_
On the **editable draft** the tree restructures the composition. Every restructure is **applied by
the editor** (which owns the one-⌘Z edit) via `AppStore.requestComposeMove`/`requestComposeWrap`,
never by writing the draft text directly — so the wireframe and editor share one undo history.
- **Reorder within a container — APG rearrangeable-listbox.** `Alt+↑`/`Alt+↓` moves the focused
view among its siblings: a direct modifier+arrow move, **not** a grab/drop mode. Focus follows
the moved box for consecutive moves (so a screen reader re-announces its new position), a
**polite** live region states the result, and `aria-keyshortcuts` advertises the keys. _(Council:
APG listbox-rearrangeable.)_
- **Restructure by drag — zone model against the children's box.** Intent is read from where the
pointer falls relative to a row/column's children, not one nearest edge, so each gesture owns a
generous target: the **interior central band reorders** (an insertion slot by main-axis position —
a drag _along_ the block rearranges it anywhere, not only on a sibling's edge); the **cross-axis
frame margin** (a row's top/bottom, a column's left/right — the gutter between frame and children,
or past the block) **pulls the source out** into a new full-span row/column wrapping the whole
container, the root included; a drop onto **a view's far cross edge** pairs the two in a
perpendicular split (`Shift` forces a pair from the centre). The source is the **innermost** view
under the pointer — `beginDrag` stops propagation so a nested ancestor frame, itself draggable,
can't claim the drag (un-stopped, its handler runs last on bubble and wins). The hit-test descends
only through `hconcat`/`vconcat` and treats `layer`/`facet`/`repeat`/grid as **opaque**. Feedback:
a cursor **chip** names the pending action, the target previews it (reorder line, pair half-split,
pull-out band), and every frame's pull-out margins glow faintly while dragging. The drag is a
pointer accelerator over keyboard-reachable capabilities (in-container reorder via `Alt+↑/↓`;
cross-container restructure via the editor's wrap actions), so it adds **no keyboard-only gap**.
Transform invariants in [arch 08](08-vega-editor-techniques.md).
**Resolved — pane toggle strip.** The persistent show/hide strip (spec §01A) is a **WAI-ARIA **Resolved — pane toggle strip.** The persistent show/hide strip (spec §01A) is a **WAI-ARIA
APG `toolbar`** (`role="toolbar"`, `aria-orientation="vertical"`, an `aria-label` such as APG `toolbar`** (`role="toolbar"`, `aria-orientation="vertical"`, an `aria-label` such as
"Workspace panes") — **not** a row of independently-tabbable buttons. Grouping into a toolbar "Workspace panes") — **not** a row of independently-tabbable buttons. Grouping into a toolbar
@@ -394,11 +440,13 @@ data-first door ("Build a chart from your data") beside its primary. _(Consulted
docs/exploration/chart-builder-enhancement-scope.md §3 · 3D. This bullet is the contract; cite it, not docs/exploration/chart-builder-enhancement-scope.md §3 · 3D. This bullet is the contract; cite it, not
the source.)_ the source.)_
**Resolved — one live region per shared message.** When the same error feeds two surfaces **Resolved — a render error lives in one place, the preview.** The render-error message
(the §1 "one producer, two subscribers" case — render errors via `PreviewStore`), exactly (`LivePreview`'s local `error` state) shows only in the preview pane, where the chart would be
**one** subscriber is the live region (`role="alert"` on the editor, where focus is); the (error _xor_ chart), and that single surface is the `role="alert"` live region — assertive, since the user
other shows the text visually with no live role. Two live regions would announce the same just caused it. It is announced regardless of where focus sits, so it needs no duplicate near
message twice. the editor; the editor instead pinpoints the cause with an inline squiggle. Messages share a
terse line-led / noun-led shape (`Line 14 · Unexpected end of input`, `Dataset "x" not found ·
`, `Invalid JSON · …`) — the location or the problem noun first, then the parser detail.
**Resolved — inline _live_ validation feedback is polite, glyphed, and field-linked.** A **Resolved — inline _live_ validation feedback is polite, glyphed, and field-linked.** A
validator that re-checks on **every keystroke** (the Chart Builder expression inputs — a validator that re-checks on **every keystroke** (the Chart Builder expression inputs — a
@@ -683,6 +731,39 @@ choice isn't re-litigated per surface:
_(Consulted via /council → WAI-ARIA APG tabs/accordion/disclosure, IBM Carbon _(Consulted via /council → WAI-ARIA APG tabs/accordion/disclosure, IBM Carbon
accordion usage, NN/g #6/#8.)_ accordion usage, NN/g #6/#8.)_
## 10. Product claims & promise copy
Declarative copy — the landing, the About modal, onboarding, empty-state value props —
makes **claims** about the product, not just feedback about an action. The care the §3
triad gives error wording applies here too: **say only what we can certify, and say it
once.** An overstated claim reads as insecurity, and the first time a user catches one
being false it costs more trust than the claim ever bought (NN/g credibility; GOV.UK
"don't oversell"). The test is not modesty for its own sake — it is that every sentence
survives a skeptical reading.
- **Claim what we can certify — not the future, not what we don't control.** "No account,
no server" is structural and always true (there is no backend). "Never leave your machine"
is a vow over every future build and every edge case; state the posture instead — "stored
locally on your device; there's no server to send them to."
- **No absolutes.** _never · always · fully · entirely · everything._ One edge case or one
future feature falsifies them, and the reader feels the overreach even when it happens to
hold. Prefer the scoped form: "works offline" over "fully offline"; "your library lives in
the browser" over "everything lives in the browser."
- **Don't promise durability the platform doesn't back.** Browser storage (IndexedDB, no
`persist()`) is best-effort and the browser may evict it. A chart is **saved**, not kept
forever — route the permanence claim through **export**, which is the real backup.
- **State a posture once per surface.** Repeating "local / no account / no server / offline"
across the hero, the lede, and a feature grid is three chances to sound unsure of it. Give
the posture one home and let the other surfaces describe the product.
- **Match the register, and don't under-sell.** Astrolabe is a free, spare-time tool: the
voice is plain and matter-of-fact, not manifesto. But concrete, true capabilities —
portable Vega-Lite JSON, two authoring modes, custom themes — are claims worth making
plainly. Reducing promises means cutting the _uncertain_ ones, never the real ones.
SOUL.md §"Local-Only by Default" is the internal **intent** and may be absolute; this
section governs how that intent is **phrased to users**, where the promise should be only as
strong as we can keep.
--- ---
## Do / Don't ## Do / Don't
@@ -694,6 +775,8 @@ accordion usage, NN/g #6/#8.)_
- Adopt the APG keyboard pattern for new widgets; route all global keys through the one - Adopt the APG keyboard pattern for new widgets; route all global keys through the one
router. router.
- Mark **optional** fields, not required ones (GOV.UK) — e.g. "Comment (optional)". - Mark **optional** fields, not required ones (GOV.UK) — e.g. "Comment (optional)".
- In product claims, say only what we can certify, once per surface; route durability
through export.
- Consult `/council` when this contract is silent — then record the answer back here. - Consult `/council` when this contract is silent — then record the answer back here.
**Don't** **Don't**
@@ -709,3 +792,5 @@ accordion usage, NN/g #6/#8.)_
- Don't ship a **dead disabled control** as a placeholder for an unbuilt feature — a - Don't ship a **dead disabled control** as a placeholder for an unbuilt feature — a
disabled button explains nothing and is skipped by assistive tech (GOV.UK, NN/g). Omit the disabled button explains nothing and is skipped by assistive tech (GOV.UK, NN/g). Omit the
action until it works, then show it enabled (e.g. "Build Chart" appears with M4). action until it works, then show it enabled (e.g. "Build Chart" appears with M4).
- Don't use absolutes in product claims (never/always/fully/everything) or promise what the
platform can't keep — say "saved," not "permanent."
+29 -8
View File
@@ -2,7 +2,10 @@
An interactive deep-dive into Vega-Lite, served at `/learn/`: a marketing surface separate An interactive deep-dive into Vega-Lite, served at `/learn/`: a marketing surface separate
from the app, where each lesson walks a spec from an 80%-naive version to a polished one to from the app, where each lesson walks a spec from an 80%-naive version to a polished one to
teach the grammar's dormant power and funnel readers into the app. teach the grammar's dormant power and funnel readers into the app. It is pitched past the
basics — fundamentals are left to the official Vega-Lite docs (linked from the index), so
the section leads with advanced cases (interaction, composition, transforms) rather than
fundamentals.
## A marketing surface, like the landing ## A marketing surface, like the landing
@@ -14,6 +17,12 @@ teach the grammar's dormant power and funnel readers into the app.
stays light. stays light.
- **Out of PWA scope.** The service worker is scoped to `/app/`, so the learning pages stay - **Out of PWA scope.** The service worker is scoped to `/app/`, so the learning pages stay
uncontrolled, always-fresh, and indexable — the point for organic-reach content. uncontrolled, always-fresh, and indexable — the point for organic-reach content.
- **An index plus a page per lesson.** `/learn/` is the index (a card per lesson); each
lesson is its own URL `/learn/<slug>/` — a separate indexable document with its
frontmatter-derived `<title>`/`<meta>`. The per-lesson HTML shells are generated from the
lesson frontmatter by `scripts/learn-pages.ts` (run from `vite.config` on every dev/build,
so "drop a file" still holds; the shells are git-ignored). The single `src/learn` entry
renders the index or one lesson from `location.pathname`.
## Lessons are markdown; the renderer is general ## Lessons are markdown; the renderer is general
@@ -22,22 +31,30 @@ A lesson is a `.md` file in `src/learn/lessons/`, discovered with `import.meta.g
document of ordered blocks_, not a fixed template: document of ordered blocks_, not a fixed template:
| Authored as | Block | Rendered by | | Authored as | Block | Rendered by |
| ---------------------------------------------------- | ------------- | --------------------------- | | ---------------------------------------------------- | ------------- | ----------------------------- |
| plain markdown | `prose` | `Markdown` | | plain markdown | `prose` | `Markdown` |
| `:::progression` wrapping `##` stages + fenced specs | `progression` | `SpecProgression` | | `:::progression` wrapping `##` stages + fenced specs | `progression` | `SpecProgression` |
| a bare fenced `vega-lite` block | `chart` | `LandingChart` | | a bare fenced `vega-lite` block | `chart` | `LandingChart` |
| `:::name … :::` | `callout` | `Markdown` (styled by name) | | `:::name … :::` | `callout` | `Markdown` (styled by name) |
| `:::data` wrapping a `{ name: rows }` JSON object | — metadata | injected into specs at render |
Inside a `:::progression`, each `##` heading is a stage: heading → tab label, prose → note, Inside a `:::progression`, each `##` heading is a stage: heading → tab label, prose → note,
the following fenced `vega-lite` block → spec. Inline data repeats per fenced block — there the following fenced `vega-lite` block → spec. A `:::data` block names datasets once for the
is no shared-data construct. 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.
## The pipeline ## The pipeline
`lessons/*.md``import.meta.glob` (in `LearnPage`) → `parseLesson` (`core/lesson-parse`) → `lessons/*.md``import.meta.glob` + `parseLesson` (`src/learn/lessons.ts`, using
`LessonBlock[]``LearnPage` block dispatch → `Markdown` | `SpecProgression` | `LandingChart`. `core/lesson-parse`)`LESSONS`. The `src/learn` entry reads the path: `/learn/`
`SpecProgression` renders each stage's spec with `formatSpec` (`core/json-format`) and `LearnIndex`, `/learn/<slug>/``LessonView`, both inside `LearnLayout` (shared
highlights what the stage changed with `changedLines` (`core/spec-diff`, an LCS line-diff). header/footer/theme). `LessonView` dispatches each block → `Markdown` | `SpecProgression` |
`LandingChart`. `SpecProgression` renders each stage's spec with `formatSpec`
(`core/json-format`) and highlights what the stage changed with `changedLines`
(`core/spec-diff`, an LCS line-diff). `parseLesson` also returns `datasets` (the `:::data`
blocks); `LessonView`/`SpecProgression` call `injectDatasets` so only the _rendered_ spec
carries the rows — the displayed-and-diffed spec keeps its by-name reference.
## Rules ## Rules
@@ -52,3 +69,7 @@ highlights what the stage changed with `changedLines` (`core/spec-diff`, an LCS
us), never user input — not an XSS surface. us), never user input — not an XSS surface.
- Lesson specs are fenced JSON parsed with `JSON.parse`; the source pane re-formats them with - Lesson specs are fenced JSON parsed with `JSON.parse`; the source pane re-formats them with
`formatSpec` so the shown JSON matches the editor's house style. `formatSpec` so the shown JSON matches the editor's house style.
- **Lesson charts render through `LandingChart` with `fitMode: 'width'`** — which sets
`width: "container"` and drops fixed heights on every view, and container width only works
for a single or layered view, not side-by-side. A multi-view lesson is therefore a
`vconcat` (a stacked column), not an `hconcat` dashboard, which would fight the sizing.
@@ -0,0 +1,853 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Editor augmentation — Monaco interactivity sandbox</title>
<!--
Throwaway sandbox (companion to docs/architecture/08). NOT a product feature
and not maintained — Monaco is loaded from CDN so this file touches nothing
in the Astrolabe build. Open it directly in a browser (needs internet for the
CDN). It demonstrates, against a live Vega-Lite spec, every editor-augmentation
surface discussed for spec editing:
1. Code actions (the lightbulb / ⌘. ) — wrap the focused view in
layer / hconcat / vconcat; change a mark, type, or field value.
2. Context-menu + F1 command-palette actions — the same transforms.
3. CodeLens — inline " add view / add encoding" affordances.
4. Dataset-aware completion — column names + types the JSON schema can't know.
5. Hover — inferred type + sample values for a bound column.
6. Inlay hints — ghost type annotations beside each field.
7. Diagnostics → quick fix — unknown field gets a squiggle and a "did you
mean…" fix (open the spec with a deliberate typo to see it on load).
Sub-tree scoping: select a child view's JSON and the wrap targets just that
selection; with no selection it wraps the whole document. (In the app the
cursor's enclosing node is found automatically via jsonc-parser; here we keep
the sandbox dependency-free so it runs straight off the filesystem.)
This is a superset of what shipped: the unknown-field diagnostic + quick fix (7)
and the " add encoding" CodeLens (3) are options explored here but deliberately
NOT shipped — the shipped product carries no field diagnostic (it would
false-positive on derived/data-dependent fields). See architecture 08 §5.
-->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans:wght@400;500;600&display=swap"
rel="stylesheet"
/>
<style>
:root {
--bg: #0b0c0e;
--panel: #14161a;
--panel-2: #1b1e24;
--border: #2a2e36;
--text: #e6e8ec;
--muted: #9aa3af;
--accent: #6ea8fe;
--accent-soft: #243245;
--good: #5ad19a;
--warn: #e0b341;
}
body.light {
--bg: #f5f6f8;
--panel: #ffffff;
--panel-2: #f0f2f5;
--border: #d8dce2;
--text: #161a1f;
--muted: #5b6470;
--accent: #2f6fed;
--accent-soft: #e4ecfb;
--good: #128a5b;
--warn: #9a6b00;
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
height: 100%;
}
body {
background: var(--bg);
color: var(--text);
font-family: "IBM Plex Sans", system-ui, sans-serif;
display: grid;
grid-template-rows: auto 1fr;
}
header {
display: flex;
align-items: center;
gap: 16px;
padding: 12px 18px;
border-bottom: 1px solid var(--border);
background: var(--panel);
}
header h1 {
font-size: 15px;
font-weight: 600;
margin: 0;
letter-spacing: 0.01em;
}
header .sub {
color: var(--muted);
font-size: 12.5px;
}
header .spacer {
flex: 1;
}
.control {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12.5px;
color: var(--muted);
}
button,
select {
font-family: inherit;
font-size: 12.5px;
color: var(--text);
background: var(--panel-2);
border: 1px solid var(--border);
border-radius: 6px;
padding: 5px 10px;
cursor: pointer;
}
button:hover {
border-color: var(--accent);
}
main {
display: grid;
grid-template-columns: 1.55fr 1fr;
min-height: 0;
}
#editor {
min-width: 0;
border-right: 1px solid var(--border);
}
aside {
overflow-y: auto;
padding: 14px 16px 40px;
background: var(--panel);
}
aside h2 {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--muted);
margin: 18px 0 8px;
}
.card {
background: var(--panel-2);
border: 1px solid var(--border);
border-radius: 8px;
padding: 11px 12px;
margin-bottom: 10px;
}
.card .name {
font-weight: 600;
font-size: 13px;
display: flex;
align-items: center;
gap: 8px;
}
.card .name .pill {
font-family: "IBM Plex Mono", monospace;
font-size: 10.5px;
font-weight: 500;
color: var(--accent);
background: var(--accent-soft);
border-radius: 5px;
padding: 2px 6px;
}
.card p {
font-size: 12.5px;
line-height: 1.5;
color: var(--muted);
margin: 7px 0 9px;
}
.card p code,
.how code {
font-family: "IBM Plex Mono", monospace;
font-size: 11.5px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 4px;
padding: 1px 5px;
color: var(--text);
}
.card .try {
font-size: 12px;
padding: 4px 9px;
}
.intro {
font-size: 12.5px;
line-height: 1.55;
color: var(--muted);
margin: 4px 0 6px;
}
.toast {
position: fixed;
bottom: 18px;
left: 50%;
transform: translateX(-50%);
background: var(--panel-2);
border: 1px solid var(--warn);
color: var(--text);
padding: 8px 14px;
border-radius: 8px;
font-size: 12.5px;
opacity: 0;
transition: opacity 0.2s;
pointer-events: none;
}
.toast.show {
opacity: 1;
}
</style>
</head>
<body>
<header>
<h1>Editor augmentation</h1>
<span class="sub">Monaco interactivity sandbox · companion to architecture 08</span>
<span class="spacer"></span>
<label class="control">
<input type="checkbox" id="inlay" checked />
Inlay hints
</label>
<label class="control">
Theme
<select id="theme">
<option value="dark">Dark</option>
<option value="light">Light</option>
</select>
</label>
<button id="reset">Reset spec</button>
</header>
<main>
<div id="editor"></div>
<aside>
<p class="intro">
A live Vega-Lite spec bound to a fake <code>weather</code> dataset
(<code>date</code>, <code>precipitation</code>, <code>temp_max</code>,
<code>temp_min</code>, <code>wind</code>, <code>weather</code>). Move the
cursor around and try each surface — every action edits the real document and
is undoable with <code>⌘/Ctrl+Z</code>.
</p>
<h2>Refactor & transform</h2>
<div class="card">
<div class="name"><span class="pill">⌘.</span> Lightbulb code actions</div>
<p>
The contextual refactor menu. Open it in the view and you'll see
<em>Wrap in layer / hconcat / vconcat</em>, plus value swaps when you're on a
<code>mark</code>, <code>type</code>, or <code>field</code> line. Select a
child view's JSON first to wrap just that part; with no selection it wraps the
whole document. This is the position-aware "give me ideas" surface.
</p>
<button class="try" data-act="quickfix">Open at cursor</button>
</div>
<div class="card">
<div class="name"><span class="pill">right-click / F1</span> Menu actions</div>
<p>
The same transforms as durable menu items — a discoverable home with the
lightbulb as the accelerator. Right-click the editor, or press
<code>F1</code> and type "wrap".
</p>
<button class="try" data-act="palette">Command palette</button>
</div>
<div class="card">
<div class="name"><span class="pill">inline</span> CodeLens</div>
<p>
Clickable affordances rendered above a line: <code> add layer</code> /
<code> add color encoding</code> over <code>"mark"</code>, and
<code> add view</code> over a composition array. Look just above the
<code>"mark"</code> line.
</p>
</div>
<h2>Beyond the schema</h2>
<div class="card">
<div class="name"><span class="pill">⌃Space</span> Dataset-aware completion</div>
<p>
In a <code>"field"</code> value, suggestions are the dataset's real columns
with their inferred types — something the Vega-Lite schema can't know. Also
augments <code>type</code>, <code>mark</code>, and <code>aggregate</code>
values. Click inside a field's quotes and press <code>⌃Space</code>.
</p>
</div>
<div class="card">
<div class="name"><span class="pill">hover</span> Field hover</div>
<p>
Hover a column name to see its inferred type and sample values pulled from
the bound dataset (merged with, not replacing, the schema's own hover).
</p>
</div>
<div class="card">
<div class="name"><span class="pill">ghost</span> Inlay hints</div>
<p>
Each <code>"field"</code> gets a faint type annotation beside it — annotation
without touching the text. Toggle it from the header.
</p>
</div>
<div class="card">
<div class="name"><span class="pill">squiggle</span> Diagnostics → quick fix</div>
<p>
A field not in the dataset gets a warning squiggle and a "Change to …" quick
fix. The starter spec ships one typo (<code>"wnd"</code>) so you can see it
immediately — open the lightbulb on that line.
</p>
</div>
<h2>Editor basics (for reference)</h2>
<div class="card">
<div class="name"><span class="pill">⇧⌥F</span> Format & misc</div>
<p>Format the document, then notice folding, multi-cursor, and the minimap all come free with Monaco.</p>
<button class="try" data-act="format">Format document</button>
</div>
</aside>
</main>
<div class="toast" id="toast"></div>
<script src="https://cdn.jsdelivr.net/npm/monaco-editor@0.54.0/min/vs/loader.js"></script>
<script>
// ---- Monaco CDN worker proxy (standard self-hosting-from-CDN snippet) ----
const CDN = "https://cdn.jsdelivr.net/npm/monaco-editor@0.54.0/min/";
self.MonacoEnvironment = {
getWorkerUrl: function () {
return (
"data:text/javascript;charset=utf-8," +
encodeURIComponent(
"self.MonacoEnvironment={baseUrl:'" +
CDN +
"'};importScripts('" +
CDN +
"vs/base/worker/workerMain.js');",
)
);
},
};
require.config({ paths: { vs: CDN + "vs" } });
// -------------------------- the fake dataset --------------------------
const COLUMNS = {
date: { type: "temporal", samples: ["2012-01-01", "2012-01-02", "2012-01-03"] },
precipitation: { type: "quantitative", samples: [0.0, 10.9, 0.8] },
temp_max: { type: "quantitative", samples: [12.8, 10.6, 11.7] },
temp_min: { type: "quantitative", samples: [5.0, 2.8, 7.2] },
wind: { type: "quantitative", samples: [4.7, 4.5, 2.3] },
weather: { type: "nominal", samples: ["drizzle", "rain", "sun"] },
};
const COLUMN_NAMES = Object.keys(COLUMNS);
const MARKS = ["bar", "line", "point", "area", "tick", "circle", "rect"];
const VL_TYPES = ["quantitative", "nominal", "ordinal", "temporal"];
const AGGREGATES = ["mean", "sum", "median", "min", "max", "count"];
const STARTER = JSON.stringify(
{
$schema: "https://vega.github.io/schema/vega-lite/v6.json",
data: { name: "weather" },
mark: "bar",
encoding: {
x: { field: "date", type: "temporal", timeUnit: "month" },
y: { field: "precipitation", type: "quantitative", aggregate: "mean" },
color: { field: "weather", type: "nominal" },
size: { field: "wnd", type: "quantitative" }, // deliberate typo → squiggle
},
},
null,
2,
);
require(["vs/editor/editor.main"], function () {
const editor = monaco.editor.create(document.getElementById("editor"), {
value: STARTER,
language: "json",
theme: "vs-dark",
automaticLayout: true,
fontFamily: "'IBM Plex Mono', ui-monospace, Menlo, monospace",
fontSize: 13,
tabSize: 2,
scrollBeyondLastLine: false,
minimap: { enabled: true },
quickSuggestions: { other: true, comments: false, strings: true },
suggestOnTriggerCharacters: true,
inlayHints: { enabled: "on" },
});
const model = editor.getModel();
// ---- Vega-Lite schema (same approach as the app's monaco-schema.ts:
// register the schema explicitly, no network schema-request service) so the
// schema's own validation / completion / hover work alongside the custom
// providers below. The spec's $schema URI is matched by fileMatch:['*'].
fetch("https://cdn.jsdelivr.net/npm/vega-lite@6.4.3/build/vega-lite-schema.json")
.then((r) => r.json())
.then((schema) => {
addMarkdownDescriptions(schema); // Monaco renders rich hovers only from markdownDescription
monaco.languages.json.jsonDefaults.setDiagnosticsOptions({
validate: true,
enableSchemaRequest: false,
schemas: [
{ uri: "https://vega.github.io/schema/vega-lite/v6.json", fileMatch: ["*"], schema },
],
});
})
.catch(() => toast("Couldn't load the Vega-Lite schema from CDN (offline?)."));
// ===================== helpers =====================
function toast(msg) {
const el = document.getElementById("toast");
el.textContent = msg;
el.classList.add("show");
setTimeout(() => el.classList.remove("show"), 1800);
}
// Copy each `description` to `markdownDescription` so Monaco hovers render
// the schema docs as markdown (plain `description` hovers as flat text).
function addMarkdownDescriptions(node) {
if (Array.isArray(node)) return node.forEach(addMarkdownDescriptions);
if (node && typeof node === "object") {
if (typeof node.description === "string" && node.markdownDescription === undefined)
node.markdownDescription = node.description;
for (const k of Object.keys(node)) addMarkdownDescriptions(node[k]);
}
}
function reindent(text, baseCol) {
if (!baseCol) return text;
const pad = " ".repeat(baseCol);
return text
.split("\n")
.map((l, i) => (i === 0 ? l : pad + l))
.join("\n");
}
// Move the unit-level props into the wrapper; keep shared props on top.
function wrapSpec(spec, kind) {
const top = {};
const inner = {};
const sharedForLayer = [
"$schema", "data", "width", "height", "title", "name", "description", "config", "resolve",
];
const sharedForConcat = ["$schema", "data", "title", "name", "description", "config"];
const shared = kind === "layer" ? sharedForLayer : sharedForConcat;
for (const k of Object.keys(spec)) {
if (shared.includes(k)) top[k] = spec[k];
else inner[k] = spec[k];
}
const placeholder = { mark: "point", encoding: {} };
top[kind] = [inner, placeholder];
return top;
}
// Compute (don't apply) the wrap edit. Targets the selection when there is
// one (so you can wrap a single child view), else the whole document.
// Returns null when the target text isn't a JSON object.
function planWrap(selection, kind) {
let range, srcText, baseCol;
if (selection && !selection.isEmpty()) {
range = selection;
srcText = model.getValueInRange(range);
baseCol = selection.startColumn - 1;
} else {
range = model.getFullModelRange();
srcText = model.getValue();
baseCol = 0;
}
let obj;
try {
obj = JSON.parse(srcText);
} catch {
return null;
}
if (!obj || typeof obj !== "object" || Array.isArray(obj)) return null;
return { range, text: reindent(JSON.stringify(wrapSpec(obj, kind), null, 2), baseCol) };
}
function applyWrap(kind) {
const plan = planWrap(editor.getSelection(), kind);
if (!plan) {
toast("Fix the JSON syntax first, then try again.");
return;
}
editor.pushUndoStop();
editor.executeEdits("wrap", [{ range: plan.range, text: plan.text }]);
editor.pushUndoStop();
editor.focus();
}
// The {range,value} of a "key": "value" string on a given line.
function stringValueRange(lineNumber, key) {
const line = model.getLineContent(lineNumber);
const re = new RegExp('("' + key + '"\\s*:\\s*")([^"]*)(")');
const m = re.exec(line);
if (!m) return null;
const startCol = m.index + m[1].length + 1; // 1-based col of value start
const endCol = startCol + m[2].length;
return {
range: new monaco.Range(lineNumber, startCol, lineNumber, endCol),
value: m[2],
};
}
function levenshtein(a, b) {
const dp = Array.from({ length: a.length + 1 }, (_, i) => [i]);
for (let j = 0; j <= b.length; j++) dp[0][j] = j;
for (let i = 1; i <= a.length; i++)
for (let j = 1; j <= b.length; j++)
dp[i][j] = Math.min(
dp[i - 1][j] + 1,
dp[i][j - 1] + 1,
dp[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1),
);
return dp[a.length][b.length];
}
function closestColumn(name) {
let best = null;
let bestD = Infinity;
for (const c of COLUMN_NAMES) {
const d = levenshtein(name, c);
if (d < bestD) {
bestD = d;
best = c;
}
}
return bestD <= 3 ? best : null;
}
// ===================== diagnostics: unknown field =====================
function refreshMarkers() {
const markers = [];
const lineCount = model.getLineCount();
for (let ln = 1; ln <= lineCount; ln++) {
const found = stringValueRange(ln, "field");
if (found && !COLUMN_NAMES.includes(found.value)) {
const suggestion = closestColumn(found.value);
markers.push({
severity: monaco.MarkerSeverity.Warning,
message:
'"' + found.value + '" is not a column in the weather dataset' +
(suggestion ? '. Did you mean "' + suggestion + '"?' : "."),
startLineNumber: found.range.startLineNumber,
startColumn: found.range.startColumn,
endLineNumber: found.range.endLineNumber,
endColumn: found.range.endColumn,
code: "unknown-field",
});
}
}
monaco.editor.setModelMarkers(model, "astrolabe", markers);
}
editor.onDidChangeModelContent(refreshMarkers);
refreshMarkers();
// ===================== 1. code action provider =====================
monaco.languages.registerCodeActionProvider("json", {
provideCodeActions(model, range, context) {
const actions = [];
const pos = range.getStartPosition();
const line = model.getLineContent(pos.lineNumber);
// Wrap actions — available anywhere the document parses to an object.
for (const [kind, label] of [
["layer", "Wrap focused view in a layer"],
["hconcat", "Wrap focused view in horizontal concat"],
["vconcat", "Wrap focused view in vertical concat"],
]) {
const plan = planWrap(range, kind);
if (plan) {
actions.push({
title: label,
kind: "refactor.rewrite",
edit: {
edits: [
{
resource: model.uri,
versionId: model.getVersionId(),
textEdit: { range: plan.range, text: plan.text },
},
],
},
});
}
}
const replaceValue = (title, key, value, kindStr, preferred) => {
const v = stringValueRange(pos.lineNumber, key);
if (!v || v.value === value) return;
actions.push({
title,
kind: kindStr || "refactor.rewrite",
isPreferred: !!preferred,
edit: {
edits: [
{
resource: model.uri,
versionId: model.getVersionId(),
textEdit: { range: v.range, text: value },
},
],
},
});
};
if (/"mark"\s*:/.test(line))
MARKS.forEach((mk) => replaceValue("Change mark to “" + mk + "”", "mark", mk));
if (/"type"\s*:/.test(line))
VL_TYPES.forEach((t) => replaceValue("Set type: " + t, "type", t));
if (/"field"\s*:/.test(line))
COLUMN_NAMES.forEach((c) => replaceValue("Change field to “" + c + "”", "field", c));
// Quick fix tied to the unknown-field markers.
for (const m of context.markers) {
if (m.code !== "unknown-field") continue;
const bad = model.getValueInRange(m);
const fix = closestColumn(bad);
if (!fix) continue;
actions.push({
title: 'Change to "' + fix + '"',
kind: "quickfix",
isPreferred: true,
diagnostics: [m],
edit: {
edits: [
{
resource: model.uri,
versionId: model.getVersionId(),
textEdit: {
range: new monaco.Range(
m.startLineNumber,
m.startColumn,
m.endLineNumber,
m.endColumn,
),
text: fix,
},
},
],
},
});
}
return { actions, dispose() {} };
},
});
// ===================== 2. context-menu / F1 actions =====================
editor.addAction({
id: "demo.wrap.layer",
label: "Wrap Focused View in a Layer",
contextMenuGroupId: "astrolabe",
contextMenuOrder: 1,
run: () => applyWrap("layer"),
});
editor.addAction({
id: "demo.wrap.hconcat",
label: "Wrap Focused View in Horizontal Concat",
contextMenuGroupId: "astrolabe",
contextMenuOrder: 2,
run: () => applyWrap("hconcat"),
});
editor.addAction({
id: "demo.wrap.vconcat",
label: "Wrap Focused View in Vertical Concat",
contextMenuGroupId: "astrolabe",
contextMenuOrder: 3,
run: () => applyWrap("vconcat"),
});
editor.addAction({
id: "demo.add.color",
label: "Add Color Encoding",
contextMenuGroupId: "astrolabe",
contextMenuOrder: 4,
run: () => addColorEncoding(),
});
function addColorEncoding() {
let spec;
try {
spec = JSON.parse(model.getValue());
} catch {
toast("Fix the JSON syntax first, then try again.");
return;
}
spec.encoding = spec.encoding || {};
spec.encoding.color = { field: "weather", type: "nominal" };
editor.pushUndoStop();
editor.executeEdits("add-color", [
{ range: model.getFullModelRange(), text: JSON.stringify(spec, null, 2) },
]);
editor.pushUndoStop();
}
// ===================== 3. CodeLens =====================
const lensWrapLayer = editor.addCommand(0, () => applyWrap("layer"));
const lensAddColor = editor.addCommand(0, () => addColorEncoding());
const lensAddView = editor.addCommand(0, (_ctx, kind) => applyWrap(kind || "hconcat"));
monaco.languages.registerCodeLensProvider("json", {
provideCodeLenses(model) {
const lenses = [];
const lineCount = model.getLineCount();
for (let ln = 1; ln <= lineCount; ln++) {
const line = model.getLineContent(ln);
if (/"mark"\s*:/.test(line)) {
const range = new monaco.Range(ln, 1, ln, 1);
lenses.push({ range, command: { id: lensWrapLayer, title: " add layer" } });
lenses.push({ range, command: { id: lensAddColor, title: " add color encoding" } });
}
if (/"(layer|hconcat|vconcat|concat)"\s*:\s*\[/.test(line)) {
lenses.push({
range: new monaco.Range(ln, 1, ln, 1),
command: { id: lensAddView, title: " add view", arguments: ["hconcat"] },
});
}
}
return { lenses, dispose() {} };
},
resolveCodeLens(_model, lens) {
return lens;
},
});
// ===================== 4. completion provider =====================
monaco.languages.registerCompletionItemProvider("json", {
triggerCharacters: ['"', ":", " "],
provideCompletionItems(model, position) {
const before = model
.getValueInRange(new monaco.Range(position.lineNumber, 1, position.lineNumber, position.column));
const word = model.getWordUntilPosition(position);
const range = new monaco.Range(
position.lineNumber,
word.startColumn,
position.lineNumber,
word.endColumn,
);
const md = (s) => ({ value: s });
let items = [];
if (/"field"\s*:\s*"[^"]*$/.test(before)) {
items = COLUMN_NAMES.map((c) => ({
label: c,
kind: monaco.languages.CompletionItemKind.Field,
detail: COLUMNS[c].type + " · from dataset “weather”",
documentation: md("Sample: " + COLUMNS[c].samples.join(", ")),
insertText: c,
range,
}));
} else if (/"type"\s*:\s*"[^"]*$/.test(before)) {
items = VL_TYPES.map((t) => ({
label: t,
kind: monaco.languages.CompletionItemKind.EnumMember,
insertText: t,
range,
}));
} else if (/"mark"\s*:\s*"[^"]*$/.test(before)) {
items = MARKS.map((mk) => ({
label: mk,
kind: monaco.languages.CompletionItemKind.EnumMember,
insertText: mk,
range,
}));
} else if (/"aggregate"\s*:\s*"[^"]*$/.test(before)) {
items = AGGREGATES.map((a) => ({
label: a,
kind: monaco.languages.CompletionItemKind.Function,
insertText: a,
range,
}));
}
return { suggestions: items };
},
});
// ===================== 5. hover provider =====================
monaco.languages.registerHoverProvider("json", {
provideHover(model, position) {
const w = model.getWordAtPosition(position);
if (!w) return null;
const name = w.word;
if (COLUMNS[name]) {
const col = COLUMNS[name];
return {
range: new monaco.Range(position.lineNumber, w.startColumn, position.lineNumber, w.endColumn),
contents: [
{ value: "**" + name + "** · `" + col.type + "`" },
{ value: "Sample values: " + col.samples.join(", ") },
{ value: "_from the bound dataset “weather”_" },
],
};
}
if (MARKS.includes(name)) {
return {
range: new monaco.Range(position.lineNumber, w.startColumn, position.lineNumber, w.endColumn),
contents: [{ value: "**mark · " + name + "**" }, { value: "A Vega-Lite mark type." }],
};
}
return null;
},
});
// ===================== 6. inlay hints =====================
monaco.languages.registerInlayHintsProvider("json", {
provideInlayHints(model, range) {
const hints = [];
for (let ln = range.startLineNumber; ln <= range.endLineNumber; ln++) {
const v = stringValueRange(ln, "field");
if (v && COLUMNS[v.value]) {
hints.push({
position: { lineNumber: ln, column: v.range.endColumn + 1 },
label: ": " + COLUMNS[v.value].type,
kind: monaco.languages.InlayHintKind.Type,
paddingLeft: true,
});
}
}
return { hints, dispose() {} };
},
});
// ===================== UI wiring =====================
document.getElementById("theme").addEventListener("change", (e) => {
const dark = e.target.value === "dark";
monaco.editor.setTheme(dark ? "vs-dark" : "vs");
document.body.classList.toggle("light", !dark);
});
document.getElementById("inlay").addEventListener("change", (e) => {
editor.updateOptions({ inlayHints: { enabled: e.target.checked ? "on" : "off" } });
});
document.getElementById("reset").addEventListener("click", () => {
model.setValue(STARTER);
refreshMarkers();
});
document.querySelectorAll(".try").forEach((btn) => {
btn.addEventListener("click", () => {
editor.focus();
const act = btn.dataset.act;
if (act === "palette") editor.trigger("demo", "editor.action.quickCommand", null);
else if (act === "quickfix") {
// park the cursor on the "mark" line so the lightbulb has something to show
const text = model.getValue();
const idx = text.split("\n").findIndex((l) => /"mark"\s*:/.test(l));
if (idx >= 0) editor.setPosition({ lineNumber: idx + 1, column: 5 });
editor.trigger("demo", "editor.action.quickFix", null);
} else if (act === "format") editor.getAction("editor.action.formatDocument").run();
});
});
});
</script>
</body>
</html>
+447
View File
@@ -0,0 +1,447 @@
# Embedding Vega-Lite in a real app: the parts the docs don't warn you about
Vega-Lite is a joy to author and a little treacherous to embed. The grammar is
well documented; the _runtime_ — view lifecycle, sizing, fonts, export, theming —
is where you lose an afternoon to a blank chart with no error in the console.
This is a field guide from building a browser app that renders arbitrary,
user-authored Vega-Lite specs live: type JSON on the left, see the chart on the
right, export it, theme it, keep it responsive. Everything below is something that
actually cost us time, with the fix and — more importantly — _why_ it happens, so
you can recognize the next variant of it.
It assumes `vega-embed`. If you hand-roll `compile → parse → new View()`, the same
issues apply; you just own more of the plumbing.
---
## 1. The view is the bug surface, not the spec
Every successful `vegaEmbed()` hands back a `result.view` — a live Vega `View`. It
owns timers, signal listeners, event handlers, and DOM. Render a new spec into the
same node without disposing the old view and the old one **leaks**: its listeners
keep firing, resources accumulate, and a long editing session slowly degrades.
```ts
let current: Awaited<ReturnType<typeof vegaEmbed>> | null = null;
async function rerender(node: HTMLElement, spec, config) {
current?.view.finalize(); // tear down the previous view FIRST
node.replaceChildren(); // drop any DOM the previous embed left behind
current = await vegaEmbed(node, spec, { config });
}
```
Two rules that pay for themselves:
- **`finalize()` before every re-embed, and on unmount.** This is the single most
important discipline. `finalize()` is not optional cleanup; it's how you avoid a
zombie view.
- **Keep all `vegaEmbed()` calls behind one small module.** Components ask it to
"draw this spec into this node" and get back a handle with `destroy()`,
`toImageURL()`, `resize()`. Nothing else imports `vega-embed` or touches a `View`
directly. This one boundary is what makes every other fix in this article land in
exactly one place.
---
## 2. Container sizing breaks in two completely different ways
`width: "container"` / `height: "container"` is how Vega-Lite does responsive
sizing. It's also the source of the two most baffling bugs we hit, and they look
nothing alike.
### Gotcha A — the chart collapses to zero width
Symptom: `width: "container"` charts render a sliver; `height: "container"` is
often fine. Classic "width is broken, height works" head-scratcher.
Cause: `vega-embed` injects `.vega-embed { display: inline-block }` into `<head>`
at runtime. Because it's injected late, it **wins the cascade** over a class you put
on that same element. `inline-block` shrink-wraps horizontally, and `"container"`
width reads `host.clientWidth` — which is now ~0. (Height survives because a tall
parent still gives the box a `clientHeight`.)
A nasty wrinkle: `vega-embed` only adds its responsive `chart-wrapper` element —
the thing its own `width: 100%` rule targets — **when the actions menu is enabled**.
If you pass `actions: false` (you probably do; see §7), that path is dead and your
element is branded `inline-block` directly, with nothing fixing it.
Fix: embed into a dedicated **inner host** with a _static_ className, nested inside
an outer frame you control. Size the inner host with a **two-class selector** so you
out-specify `.vega-embed`:
```css
/* one class loses to .vega-embed; two classes win */
.fitWidth .host {
width: 100%;
}
```
Keep the inner host's class static so React (or whatever owns the DOM) never
re-reconciles it and stomps Vega's runtime classes.
### Gotcha B — the chart never follows a resize
Symptom: a responsive chart sizes correctly on first render, then ignores the pane
being dragged wider.
Cause: Vega-Lite compiles `"container"` sizing into width/height signals that
re-read `containerSize()` **only on a `window:resize` event**. Two consequences
people rediscover the hard way:
1. `view.resize()` does **not** re-measure. It re-runs layout with the _stale_ size.
2. A pane drag (a splitter, a layout change) fires no `window:resize`, so nothing
re-fits on its own.
Fix: observe the host with a `ResizeObserver` and synthesize the event Vega is
actually listening for.
```ts
const ro = new ResizeObserver(() => {
window.dispatchEvent(new Event('resize')); // the mechanism, not a hack
});
ro.observe(host);
```
This is the documented mechanism, not a workaround — it's literally what the Vega
editor does. `ResizeObserver` callbacks are frame-batched, so it tracks a drag
smoothly with no debounce. Bonus: only the container-bound dimension carries the
resize handler, so a width-only chart re-fits width and leaves height natural for
free, with zero bookkeeping.
---
## 3. Fonts must finish loading _before_ you render — any renderer
This one is invisible until you ship a custom font. The chart renders, the text
looks slightly wrong (spacing off, labels colliding or over-padded), and it
_sometimes_ fixes itself on the next edit.
Cause: Vega measures every text label with canvas `measureText` **regardless of
renderer** — SVG, canvas, even the headless `'none'` renderer runs a layout pass. If
a web font is still loading when you embed, the entire chart is laid out with
_fallback_ font metrics. When the real font swaps in, the glyphs change but the
layout was already computed against the wrong widths.
Fix: gate the render on the fonts the spec actually references.
```ts
async function ensureFontsLoaded(families: string[]) {
if (!document.fonts?.load) return; // no-op in tests / old browsers
const loads = families.flatMap((f) =>
['400', '600', '700'].map((w) => document.fonts.load(`${w} 16px ${f}`)),
);
// allSettled, not all: a missing face (offline, 404, a system family with no
// @font-face) is EXPECTED — degrade to fallback metrics, never fail the chart.
await Promise.race([
Promise.allSettled(loads),
new Promise((r) => setTimeout(r, 3000)), // bound a slow first fetch
]);
}
```
Two judgment calls worth copying: use `allSettled` (a font failing to load is not a
chart error — it's a render-with-fallback), and cap the wait with a timeout so a
slow network never freezes the preview. A cached face resolves near-instantly; the
timeout only ever bounds the very first fetch of an uncached subset.
---
## 4. SVG vs canvas is a real performance cliff, and canvas has a silent ceiling
The default `renderer: 'svg'` is the right call almost always — crisp at any zoom,
inspectable, copyable, themeable. But SVG renders **one DOM node per mark**. A chart
with thousands of marks (say one bar per row of a 10k-row dataset) costs _seconds_
of main-thread layout and paint per render. We measured ~6.5s of paint on ~10k rows —
and the freeze lands _after_ the chart first appears, because the browser paints the
SVG tree lazily. The tab locks up holding a chart that looks done.
Switch many-mark charts to `renderer: 'canvas'`: a single node, painted in
milliseconds. The raster trade-off (not crisp on zoom) is invisible for an ephemeral
preview, and — crucially — image export is renderer-agnostic (§7), so you lose
nothing downstream.
But canvas has its own trap: a **hard maximum dimension**. Browsers cap a canvas
backing store at ~32,767px per side (less on Safari, which is also area-bound). Past
that, the canvas fails to allocate and draws **nothing** — no error, no exception,
just a blank surface and sometimes a null 2D context. A tall categorical chart
(hundreds of natural-height rows) blows past this easily.
Fix: before committing to canvas, run a headless layout probe and read the resolved
size. The `'none'` renderer computes layout without allocating a canvas:
```ts
const probe = await vegaEmbed(detachedDiv, spec, { renderer: 'none', config });
const height = probe.view.height();
probe.view.finalize();
const limit = 32767 / (window.devicePixelRatio || 1); // backing store is dpr×
if (height > limit) throw new ChartTooLargeError(height, limit);
```
Now you can tell the user the _real_ cause ("this chart is 50,000px tall") instead
of handing back a blank box. SVG has no such cap — it just gets slow — so the probe
is canvas-only.
---
## 5. Exporting an image has three sharp edges
You'd think `view.toImageURL()` is the export story. It isn't, quite.
**Retina blur.** `toImageURL`'s `scaleFactor` ignores `devicePixelRatio`. A naive
"1×" PNG export comes out at half resolution on a 2× display — soft, obviously
wrong next to the crisp on-screen chart. Multiply the scale by dpr yourself:
```ts
const dpr = window.devicePixelRatio || 1;
const canvas = await view.toCanvas(scale * dpr); // "1×" now matches the screen
```
**Transparent background.** If your theme sets `background: 'transparent'` (you
probably do, so the chart shows the pane color through it — §6), every export is
also transparent. Usually not what someone wants in a PNG. Composite an opaque color
under the canvas, and inject a full-bleed `<rect>` as the first child of the root
`<svg>` for the vector path:
```ts
svg = svg.replace(/(<svg\b[^>]*>)/, `$1<rect width="100%" height="100%" fill="${bg}"/>`);
```
**SVG drops your fonts.** `view.toSVG()` serializes only the `font-family` _name_.
Open that SVG anywhere the font isn't installed and it falls back to a system font.
If the font is one your users uploaded, embed it as a base64 `@font-face` rule inside
a `<style>` at the top of the SVG:
```ts
const rule = `@font-face{font-family:"${family}";src:url(${dataUri}) format("woff2");}`;
// SVG is XML, and a family name can contain & or <, so wrap the CSS in CDATA —
// and defensively split the one sequence CDATA can't contain:
const css = rule.replace(/]]>/g, ']]]]><![CDATA[>');
svg = svg.replace(/(<svg\b[^>]*>)/, `$1<style type="text/css"><![CDATA[${css}]]></style>`);
```
PNG needs none of this — the raster already baked the glyphs in. Only the vector
format leaks the font dependency.
One nice property to lean on: export is **renderer-agnostic**. `view.toCanvas()` and
`view.toSVG()` draw to their own off-screen surface, independent of how the chart is
displayed. So you can show an SVG chart on screen and still export a high-res PNG, or
show a canvas preview (§4) and still export a clean SVG.
---
## 6. Theme is a config you merge at embed time — and Vega is picky about it
A Vega-Lite **config** object styles every chart globally: fonts, axis colors,
background, the categorical palette. The right model is to keep the config _out_ of
the user's stored spec and inject it at embed time, so the same spec re-themes for
free when the UI flips light/dark:
```ts
await vegaEmbed(node, spec, { config: chartConfigFor(theme) });
```
Three things that bit us:
- **The spec wins, key by key.** Vega-Lite merges your injected `config` _under_ the
spec's own `config` (`mergeConfig(opt.config, spec.config)`). That's the behavior
you want — a snippet can override or opt out locally — but know it: you can't force
a style the spec contradicts.
- **Don't rebuild a config from a fixed schema.** If you let users edit a config
through structured controls, mutate the config object _in place_; don't reconstruct
it from a known set of keys. Preset themes (and Vega proper) carry Vega-_layer_
keys — `symbol`, `shape`, `path`, `group` — that aren't in the Vega-Lite `Config`
type but are forwarded to Vega at render. A rebuild silently drops them.
- **A bare scheme name passes compile but fails at render.** Writing
`range: { category: "tableau20" }` (a bare string) survives Vega-Lite _compilation_
and then Vega rejects it at _render_ with "Unrecognized scale range value" — and
blanks the chart. The accepted form is the object: `range: { category: { scheme:
"tableau20" } }`. This compile-passes/render-fails split is a recurring Vega theme;
when a chart goes blank with a console error but no compile error, suspect a value
that's structurally valid JSON but semantically wrong for the runtime.
If you want themes that follow the system light/dark, keep exactly **one** function
that maps `(selection, uiTheme) → config`. Every render resolves through it; nothing
else decides a chart's styling. (We also offer the `vega-themes` package's presets
verbatim — it's already in your tree as a `vega-embed` dependency, so the famous
FiveThirtyEight / Excel / Carbon looks are free.)
---
## 7. `actions: false` does more than hide a menu
You'll almost certainly want `actions: false` — the built-in "Save as / View Source /
Open in Vega Editor" overlay doesn't belong on most embeds, and you'll provide your
own export. Just know two side effects:
- As noted in §2, it removes the responsive `chart-wrapper`, so you own host sizing.
- You also give up the built-in PNG/SVG export, so build your own through the view
(§5). That's a feature, not a cost — you get dpr-correct, background-filled,
font-embedded exports the built-in menu never gave you.
And for tooltips: pass `tooltip: { disableDefaultStyle: true }` so `vega-tooltip`
doesn't inject its own light/dark stylesheet. The tooltip element (`#vg-tooltip-
element`) is appended to `<body>`, so once the default style is gone you style it
entirely from your own CSS — and because it lives under `<html>`, it inherits your
`[data-theme]` cascade for free. `vega-tooltip` still handles positioning and the
`.visible` toggle; you just supply the look.
---
## 8. Field names with dots are not what you think
If you construct specs from data-derived column names (a chart builder, an
auto-encoding helper), this _will_ bite you. Vega-Lite treats `.`, `[`, and `]`
inside a `field:` as **nested-property accessors**: `field: "user.age"` reads
`row.user.age`, not a column literally named `"user.age"`. Real-world CSVs have
columns like `Price ($)` or `2021.Q3` all the time.
```ts
const escapeField = (name: string) => name.replace(/([.[\]])/g, '\\$1');
encoding.x = { field: escapeField(columnName), type: 'quantitative' };
```
Escape every data-derived name before it lands in any field-position key — `field`,
`as`, `groupby`, tooltip fields, the lot. (For specs a user hand-authored, escaping
is their responsibility; don't rewrite their `field:` values.)
---
## 9. Never mutate the spec you render
Rendering should be a pure function of (spec, config). If your pipeline rewrites the
spec on the way to the view — resolving dataset references to inline values, applying
a responsive sizing mode, escaping fields — do it on a **deep copy**:
```ts
const prepared = structuredClone(userSpec);
// ...mutate `prepared` freely: resolve refs, set width:"container", etc.
await vegaEmbed(node, prepared, { config });
// userSpec is untouched — what the user sees in the editor is still what they wrote.
```
The moment rendering mutates the stored spec, you get spooky action: a fit-mode
toggle permanently rewrites the user's `width`, an export inlines a 2MB dataset into
the document they're editing. Keep the transform pure and copy-first, and it stays
unit-testable without a DOM as a bonus.
A related subtlety: a "fit to container" mode that sets `width: "container"` should
also _delete_ the spec's explicit `height` (and vice-versa) so the unconstrained
dimension recomputes naturally. Which means a surface that lets the user type an
explicit width/height must opt _out_ of fit mode while they do — the two fight over
the same keys.
---
## 10. Live editing: debounce the input, guard the output
For a live preview that re-renders as the user types, two independent concerns:
**Debounce edit → state, not state → render.** Re-rendering must never compete with
typing. Debounce the editor's text changes (we make the delay user-configurable,
~5005000ms); render only after a pause. But render _immediately_ for non-typing
changes — loading a different spec, a theme flip, a fit-mode toggle. The debounce
exists for keystroke churn and nothing else; detect "this was a keystroke" by
elimination (the text changed but the document identity didn't).
**Guard against out-of-order renders.** `vegaEmbed`/`runAsync` is async, so a slow
render can resolve _after_ a newer one already mounted. A bare debounce doesn't cover
this. Stamp each render with a generation token and let only the latest one win:
```ts
let generation = 0;
async function render() {
const mine = ++generation;
const handle = await renderSpec(node, spec, config);
if (mine !== generation) {
handle.destroy();
return;
} // a newer render superseded us
current = handle;
}
```
And keep the _last good chart on screen_ while the next render computes — overlay a
subtle busy indicator rather than blanking the pane. A pane that flickers to empty on
every keystroke feels broken even when it's fast.
---
## 11. Errors: a blank spec is not an error, and recovery should be automatic
Three stages fail, and you want them distinguishable in the message: JSON parse
("Invalid JSON: …"), spec preparation ("Dataset not found: …"), and embed itself
("Rendering error: …", the Vega-Lite compile or Vega runtime failure). Funnel all
three to one error field the preview reads.
The behaviors that make it feel solid:
- **Empty/blank text renders nothing** — a clean pane, not an error. Finalize the
current view, clear the error, stop.
- **Every successful render clears the error.** Recovery is then automatic: the next
valid edit re-renders and wipes the message. No retry button, no reload.
- **Keep the last good chart visible under a parse error** if you can, so a
half-typed keystroke doesn't strobe the whole pane.
- **Wrap `runAsync`/embed in try/catch and finalize on failure.** Vega won't catch
runtime errors for you, and a half-initialized view leaks if you don't finalize it.
- **Don't dump a raw stack trace.** Give the reason plus a hint ("check your JSON and
that the spec is valid Vega-Lite").
---
## 12. If you also embed an editor (Monaco) — wire it yourself
Optional, but if you're putting users in front of raw spec JSON you'll want schema
validation and autocomplete. The non-obvious parts:
- **Bundle the schema; never fetch it.** `import schema from
'vega-lite/vega-lite-schema.json'` and register it once, globally, via the JSON
language service (`setDiagnosticsOptions`). Version-locked to your installed
Vega-Lite, offline-safe, no runtime network call. Set `enableSchemaRequest: false`
so the worker can't go fetch an unbundled `$schema` URL behind your back.
- **Bind by `fileMatch`, not by the doc's `$schema` value.** If you key validation
off the `$schema` line, a spec without one gets zero validation and zero
completions. Match your model URIs instead so it always works.
- **Monaco workers are on you.** With a CDN loader they're automatic; self-hosted,
you must set `MonacoEnvironment.getWorker` to return the JSON worker for label
`'json'` and the editor worker otherwise. No worker means no squiggles and no
completions — and no error telling you why.
- **`quickSuggestions: { strings: true }`.** Vega-Lite enum values (`"bar"`,
`"quantitative"`) live _inside JSON strings_, where Monaco disables autocomplete by
default. Without this, completions silently never appear.
- **Two layers, two tiers.** The Monaco worker gives inline squiggles; a separate
`ajv` pass can feed a richer error pane. Sort findings into _fatal_ (syntax / compile
/ runtime errors that suppress the chart) and _advisory_ (schema-validation warnings
that don't). Vega-Lite emits plenty of benign warnings; treating them as fatal hides
specs that render fine.
- **ajv has its own gotchas:** construct it with `strict: false`, add the draft-06
meta-schema (the VL schema is draft-06; ajv 8 defaults newer), register a no-op
`color-hex` format, and **compile the validator once at module load** — the schema
is multi-megabyte and compiling per keystroke is a real perf sink.
---
## The short version
If you skim one thing, skim this:
| Trap | Fix |
| --------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
| Re-embedding leaks the old view | `view.finalize()` before every re-embed and on unmount |
| `width:"container"` collapses to ~0 | Inner host + out-specify `.vega-embed { inline-block }` with a 2-class selector |
| Chart won't follow a resize | `ResizeObserver``window.dispatchEvent(new Event('resize'))`, not `view.resize()` |
| Custom font lays out wrong | `document.fonts.load(...)` (allSettled + timeout) before embed; metrics are measured regardless of renderer |
| Many-mark SVG freezes the tab | Switch to `renderer: 'canvas'`; probe size first — canvas fails silently past ~32k px |
| Export looks soft on Retina | Multiply scale by `devicePixelRatio` |
| Export is transparent / loses fonts | Composite a bg color; embed `@font-face` (CDATA) in the SVG |
| Bare scheme name blanks the chart | Use `range: { category: { scheme: "…" } }`, not a bare string |
| Dotted column names misread | Escape `.[]` in every data-derived `field:` |
| Stale async render clobbers a fresh one | Render-generation token; only the latest wins |
| Rendering mutates the user's spec | Transform on a `structuredClone` copy |
None of these are exotic. They're the gap between "it works in the demo" and "it
holds up under a 10k-row dataset, a custom font, a dragged pane, and a Retina
export." Vega-Lite is excellent; it just expects you to know where its runtime edges
are. Now you do.
+1
View File
@@ -15,3 +15,4 @@ snapshots, not live numbers.
- `chart-builder-enhancement-scope.md` — consolidated Tier-B → Tier-C forward plan for the Chart Builder. - `chart-builder-enhancement-scope.md` — consolidated Tier-B → Tier-C forward plan for the Chart Builder.
- `chart-theming-scope.md` — chart theming plan and slice breakdown. - `chart-theming-scope.md` — chart theming plan and slice breakdown.
- `monetization-and-sync-exploration.md` — monetization + BYO-cloud-sync direction memo. - `monetization-and-sync-exploration.md` — monetization + BYO-cloud-sync direction memo.
- `ai-augmentation-exploration.md` — why Astrolabe stays AI-free, and the key-storage security analysis behind it.
@@ -0,0 +1,53 @@
# AI Augmentation — Exploration
> **Status:** Exploration, not a commitment. Captured 2026-06-27 from a strategy
> conversation. The conclusion is folded into [`SOUL.md`](../../SOUL.md) (_Local-Only by
> Default_ and _Not an AI tool_); this memo keeps the _reasoning_ — including the security
> analysis behind rejecting browser-stored keys — so a future "should we add AI?" session
> doesn't re-derive it.
>
> **Question:** Most tools shipping in 2026 carry some AI/LLM augmentation. Should
> Astrolabe?
>
> **Short answer:** No — and the user benefit, not purity, is the reason. With no server,
> no account, and no AI, nothing the user makes is handled by a third party, so Astrolabe
> is safe for confidential and work data from the first chart. "Everyone ships AI in 2026"
> is the weakest possible reason to add it: ubiquity makes AI table-stakes noise, not
> differentiation, and a tool that demonstrably keeps your data on your machine is
> differentiated _because_ it resists the trend.
---
## 1. Where AI would genuinely fit, if ever
Two spots where Vega-Lite is actually painful and rules can't help but a model could:
natural-language authoring (NL → spec) and explaining/decoding the editor's opaque
validation errors. The obvious third — "recommend a chart from my data" — is **already
solved deterministically** by the Chart Builder's rule-based inference, and the rule-based
version is better here because it's explainable and runs locally. So the genuine surface is
narrow.
## 2. Why browser-stored BYO keys were rejected
The only AI model consistent with "no server, no account" is bring-your-own-key, called
direct browser → provider (never proxied through a server we run, which would put us back in
the data-custody business). The blocker is key storage:
- A browser has **no secure vault for a secret you must read back**. localStorage,
IndexedDB, cookies — all readable by any same-origin JS, devtools, and extensions. Client
encryption only helps if the unlock secret isn't _also_ stored, i.e. a passphrase typed
each session; a key kept beside its ciphertext is theater.
- The dominant threat is therefore **same-origin script execution (XSS)**, and Astrolabe is
unusually exposed to it: it renders arbitrary user specs through vega-embed, whose
expression evaluator and data loader are a real script-execution / exfiltration surface.
Holding a secret in that origin upgrades any spec-driven bug from "annoying" to "steals
the user's key." **Introducing a stored secret raises the threat level of the whole app,
the chart renderer included** — the opposite of what the privacy posture exists to do.
- The one mitigant: LLM keys are revocable and spend-cappable, so the blast radius is "bill
abuse until you rotate it," not data loss. That's why the industry tolerates browser BYOK
at all — but it doesn't undo the origin-coupling above.
**If AI is ever revisited:** the only acceptable form is a **session-only** key (held in
memory, never persisted, re-entered each session) called browser → provider direct, with the
core staying fully functional and offline for anyone who never engages it. Persisting the
key is the specific part that compromises the posture.
@@ -0,0 +1,115 @@
# Multi-view data model — scope & plan
Astrolabe authors arbitrary Vega-Lite, including **composed** specs (`layer`,
`hconcat`/`vconcat`/`concat`, `facet`, `repeat`). Most of the spec-structure
machinery already handles composition; the data-facing features carried
single-view assumptions. This memo records the assessment, the data-model
contract that anchors the work, and the milestone plan to make multi-view support
durable. The guiding constraint: **extend Vega-Lite, never break it** — every
native data form must keep working.
## The data-model contract
A dataset reference is exactly Vega-Lite **named data**: a `data` block with a
string `name` and no `values`/`url`/generator key, whose name the spec does not
self-define via top-level `datasets`. This mirrors Vega-Lite's `isNamedData`
(`reference/vega-lite/src/data.ts`). The classification is owned by
`core/spec-data` (`classifyData`, `libraryRefName`); reference extraction
(`spec-refs`), rename (`spec-refs`), and render-time resolution (`rendering`) all
route through it. See `docs/architecture/07` §3.1.
Library references resolve to inline data before embedding
(`core/rendering``prepareSpecForRender`); a self-defined `datasets` name is
left for Vega-Lite to resolve natively.
## Assessment: already multi-view vs. single-view assumptions
**Already composition-aware** (recurse all view operators): ref extraction/rename
(`spec-refs`), reference resolution + fit-mode (`rendering`), structural wrap/
unwrap/add-view (`spec-transforms`, `spec-cursor`, `spec-insert`), derived-field
collection (`spec-fields`), config baking (`spec-config`), standalone export
(`chart-export`, reuses `prepareSpecForRender`).
**Single-view assumptions** (the work):
- **Editor data context** (`app/services/active-dataset`) resolves _one_ dataset
for the whole draft (first ref, or first inline data), with no notion of which
view the cursor sits in. Completion/hover/inlay (`spec-dataset-hints`) and the
facet/repeat field defaults (`spec-transform-actions`) therefore offer the wrong
view's columns in a composition whose views bind different datasets.
- **Extract inline data** (`app/stores/ExtractStore`) lifts only the top-level
`data` block.
- **Data inspector** (`DataInspector`) surfaces one input + one resolved table; a
composition produces several `source_<n>`/`data_<n>`.
- **`deriveSnippetName`** (`core/snippet`) reads top-level `mark`/`encoding` only,
so a composed spec falls back to the default name (graceful, not a bug).
- **Chart builder** is single-view by design; its strict round-trip hydration
returns `null` for composed specs, so they stay Monaco-only (correct).
## Vega-Lite fidelity clashes
1. **Named inline/url data misread as a reference** — classifying on "has a
string `name`" alone caught named-inline (`{ name, values }`) and named-url,
breaking valid specs (spurious `DatasetNotFoundError`, or clobbered inline
values). _Resolved_ by the `core/spec-data` classifier (M1).
2. **Shadowing** — a library dataset whose name equals a self-defined `datasets`
key is silently ignored (self-defined wins). Documented precedence; candidate
for a user-facing note, no code change required.
3. **Case-rule split** — library matching is case-insensitive (`naming.ts`);
self-defined exclusion and Vega-Lite's own named-data lookup are case-sensitive.
These are distinct namespaces, so the split is defensible; minor.
4. **Runtime-injected named data** — Vega-Lite allows binding `{ name }` at
runtime; Astrolabe always pre-resolves, so an imported spec relying on runtime
injection won't render. Out of scope.
## Milestone plan
- **M1 — data-model foundation** ✅ — `core/spec-data` classifier mirroring
`isNamedData`; `spec-refs` + `rendering` routed through it. Closes clash 1.
- **M2 — view-scoped editor context** ✅ — `dataBindingAtPath` (climb the cursor's
JSON path to the nearest enclosing `data`, honoring Vega-Lite's parent→child
inheritance) + `derivedFieldNamesAtPath` (ancestor-chain `as` outputs).
`active-dataset` is cursor-scoped (`dataInfoAt(text, offset)`), resolving columns
for every form (library ref case-insensitive, inline, named-inline, self-defined
`datasets`, url/generator → none); the three Monaco providers and the
facet/repeat defaults pass the cursor offset.
- **M4 — multi-view inspection** ✅ (pulled ahead of M3) — `core/inspect-views`
enumerates the distinct tables the marks draw (from the compiled Vega spec's
`from.data` + `data[].source` lineage), each with its input + resolved ends;
`RenderHandle.inspectData()` returns those tables with rows; `DataInspector` adds
a `SelectControl` view picker (hidden for the single-table case), labels via
`inspectViewLabel` (named dataset or "View N", never compiler names) + a
`columns · rows` cue. Enumerating by drawn table (not authored view) is forced by
Vega-Lite desugaring (a `point: true` line compiles to two layers). Replaced the
single-pair `core/result-data`.
- **M5 — live / interactive inspection** ✅ — the inspector reacts to interactive
selections. `RenderHandle.onDataChange` attaches a debounced `view.addDataListener`
to each drawn table's resolved + input names; a selection-as-**filter**
(`filter: {param}`) recomputes a downstream view's `data_N`, so its listener fires
and `LivePreview` bumps a `liveEpoch` that re-reads the table ("what am I
visualizing _now_"); a selection-as-**highlight** (a `condition` encoding) changes
no data, so nothing fires. The watcher is gated on the inspector being open
(a collapsed one costs nothing) and is **always live, no toggle** — the table just
tracks the brush; the ~120ms debounce coalesces a drag's continuous pulses.
Selection `*_store` tables are not drawn, so the M4 enumeration already ignores them.
- **M3 — view-scoped extract** ✅ — Extract is scoped to the view at the cursor.
`services/extract-action` resolves the focused view's data binding
(`dataBindingAtPath`) and lifts whichever of two embedded-data shapes it carries:
a view's inline **`data.values`** (`inlineValuesOf` → rewrite that view's `data`
block at its anchor path), or a **`{ name }` reference to a self-defined
`datasets` entry** (`selfDefinedPayloadOf``promoteSelfDefinedDataset`: drop the
`datasets` entry, and the map when it empties, so the same reference resolves to
the new library dataset; rename refs when the name changes, pre-filled with the
existing name). The toolbar offers Extract whenever any view carries either shape
(`specHasExtractableData`); a cursor in a view with neither (a library ref, url,
generator) gets a guide toast. A single-view spec resolves to the root binding
from any cursor, so the common case is unchanged. Confirm re-serializes in the
app's house style. A `lookup` transform's inline `from.data` is covered incidentally
`dataBindingAtPath` finds it like any view binding (which also means the editor
_hints_ read the lookup table's columns when the cursor sits inside the transform;
acceptable for now, noted). The orphan case — a `datasets` entry no view references
— is out of scope (no view to scope the cursor to; it is dead data to delete).
Delivery is incremental, one milestone per commit, verified against real behavior.
The durable contract is recorded in `docs/architecture` 05 (live inspection) and 07
(reference detection + extraction, §3.13.2); this memo stays the point-in-time record.
@@ -0,0 +1,168 @@
# Visual Composition Editing — Exploration
> **Status:** research recorded 2026-06-29. Point-in-time record of a feasibility study for a
> **visual, drag-editable view of multi-view composition** (`vconcat`/`hconcat`/`concat`/
> `layer`/`facet`/`repeat`). Two surfaces were weighed: a **schematic wireframe panel** and an
> **on-chart overlay** aligned to the real rendered chart.
>
> **Decision (2026-06-29):** build the **schematic wireframe first** — it carries ~90% of the
> value with a deterministic spec↔box mapping and no coupling to Vega runtime internals. The
> **on-chart overlay** is a feasible later "geometry skin" over the same edit core, deferred
> because its risk (compiled-name↔source-path correlation, and an edit-vs-interact pointer
> conflict) is real and isolated. First build step: a **read-only Phase A spike**
> `viewTree(spec)` + a static nested-box renderer with click-to-cursor sync.
>
> **Shipped (2026-06-29):** Phases AC — the wireframe, mark-type leaf glyphs, in-container
> reorder (drag + `Alt+↑/↓`), and cross-container drag-to-restructure (`core/spec-restructure`
> `wrapViews`). The live contract is now arch 08 (transforms) + arch 10 (interaction). Phase D
> (on-chart overlay) and Phase E (size editing) remain deferred — size deferred by choice.
---
## 1. The brief
A visual, interactive representation of a spec's multi-view structure — boxes for the views,
showing arrangement and nesting (what contains what), draggable to rearrange, with the spec
reacting. Explicitly **not** a chart preview: a wireframe of blocks. Reference feel: a Tableau
dashboard's GUI.
## 2. The framing realization: a tree of tiled containers, not a canvas
Vega-Lite composition is a **nested tree**, not a free 2D plane:
- `layer` / `hconcat` / `vconcat` / `concat` hold an **array** of child views.
- `facet` / `repeat` hold a **single, data-generated** child (`spec`), not an array.
So the apt analogy is Tableau's **tiled containers** (nested horizontal/vertical), not Tableau's
**floating** layout. Every drag must resolve to a discrete tree operation — reorder within a
container, move across containers, wrap siblings into a new container, flip orientation, unwrap —
never an arbitrary `(x, y)` drop. Communicating that constraint _as_ the design (snap-to-zones,
not free placement) is the central UX problem.
## 3. Two surfaces, one edit core
Both surfaces feed the **same mutation core** and differ only in where the boxes come from.
| | **Schematic wireframe** | **On-chart overlay** |
| -------------------------------- | ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
| Box geometry | Computed from the source tree | Read from Vega's rendered scenegraph |
| Vega-runtime dependency | None | Hard (`view.scenegraph()`) |
| Path ↔ box mapping | **Deterministic** (we own every path) | **Brittle** — correlate compiled group names (`concat_0_group`, `child__a_group`) back to source paths |
| Reflects true rendered sizes | No (schematic; equal-weight unless explicit `width`/`height`) | Yes (pixel-accurate) |
| Works on mid-edit / invalid JSON | Yes (error-tolerant parse) | No (needs a successful render) |
**Geometry sync fidelity (overlay).** High. `view.scenegraph()` exposes each sub-view as a
`SceneGroup` with exact `bounds`/`width`/`height`; the default **SVG** renderer also yields real
DOM `<g name="…">` nodes measurable via `getBoundingClientRect()`. The app already holds the live
`view` handle (the Inspector reads live data from it), so exposing the scenegraph is a small
`RenderHandle` extension. The fidelity ceiling is not geometry — it is the three overlay risks in
§6.
## 4. Existing infrastructure vs. new work
**Reusable today** (much of it from the cursor-scoped CodeLens work):
- Mutations & paths: `compositionTargetAt` / `insertView` / `moveView` / `elementOffset`
(`core/spec-insert`), wrap/unwrap with property-partition rules (`core/spec-transforms`:
`SHARED_TOP`/`LAYER_TOP`, `placeholderView`, `unwrapSingleton`, `ARRAY_COMPOSITIONS`).
- Data model: `dataBindingAtPath` (`core/spec-data`) for data inheritance.
- Write-back: the whole-document reformat + paired `pushUndoStop` path (`spec-transform-actions`),
so a drag is one ⌘Z.
- UI scaffolding: pointer-drag hook `useResizeDrag` (no DnD library in use), resizable-panel
scaffolding (`PanesStore`/`AppStore`), the preview pane's stacked layout (header / chart /
`DataInspector`).
- Render handle: `chart-renderer` keeps `result.view`; SVG by default.
**New work, roughly in build order:**
- **`viewTree(spec)`** — recursive source-spec → tree of `{ kind, path, label, children, sizeHint }`.
Small, pure core. No tree builder exists today; `inspectableViews` walks the _compiled_ vg spec
for data tables, which is the wrong layer for structure.
- **Cross-container mutations**`moveViewTo(from, toContainer, index)`, `wrapSiblings(...)`
(drop-creates-container), `removeView` + collapse. **Medium-large and correctness-sensitive**
the real cost and the subtle bugs live here (§6).
- **Wireframe renderer** — nested flex boxes from `viewTree`, plus drag + drop-zones +
create-container zones, and selection↔cursor sync (reuses cursor plumbing).
- **(Overlay only)** scenegraph→path correlation, an absolutely-positioned overlay with
coordinate transforms, re-sync on every render, and an edit⇄interact mode toggle.
## 5. Effort & phasing
- **Phase A — read-only wireframe.** `viewTree` + static nested-box renderer + click-to-cursor
sync. Small. De-risks the model with zero mutation risk.
- **Phase B — reorder within a container.** Drag → `moveView` (exists). Small.
- **Phase C — cross-container move + wrap-on-drop + delete/collapse.** Medium-large. The core
value and the correctness work.
- **Phase D — on-chart overlay (optional).** Geometry skin over the proven core. Medium; risk
isolated to correlation + mode conflict.
- **Phase E — resize handles → `width`/`height` (optional).** Medium.
## 6. Edge cases & hidden problems
**Composition model**
- **Facet/repeat cells are data-generated** — count depends on data cardinality (unknown without
running), and individual cells are not arrangeable (they do not exist in the source). Render as
one "grid" placeholder with a badge.
- **`layer` is z-order, not spatial** — children coincide in one box; needs a depth/stack
metaphor, and in the overlay the layer rectangles overlap (ambiguous hit-testing).
- **`concat` + `columns: N`** is a wrap-grid — a third layout mode beside pure h/v.
- Deep nesting → tiny boxes (min-size + zoom/scroll); mixed orientations recurse.
**Mutation correctness (the subtle traps)**
- **Property migration across container types.** `width`/`height` live on the _child_ in concat
but on the _wrapper_ in layer; `data`/`resolve` on the wrapper. A cross-type move must relocate
these or the view silently renders wrong. The partition rules exist for wrap; cross-move needs
the analogue.
- **Data-inheritance breakage.** A child with no explicit `data` inherits its nearest ancestor's.
Moved under a different data source it silently rebinds; detect via `dataBindingAtPath` and
pin the effective data onto the moved view (a real decision, not free).
- **Degenerate drops** — onto itself, into its own descendant (cycle), or a move that empties /
single-childs a container (collapse via `unwrapSingleton`, which can strand a `resolve`/`spacing`
that no longer has a composition to apply to).
**Round-trip & sync**
- Specs are **plain JSON; reformat strips comments** and rewrites the whole document — already
true of the existing transforms, so consistent.
- Source of truth is the draft text; wireframe and editor both mutate it → reuse the atomic
write-back + undo-stop path.
- Mid-edit invalid JSON: wireframe degrades to last-valid; overlay has no fresh render to track.
**Overlay-specific**
- **Compiled-name ↔ source-path correlation is an undocumented compiler contract** — can shift
across Vega-Lite versions and is ambiguous for layers and facet internals. The overlay's biggest
risk.
- **Edit-overlay vs. the chart's own interactivity.** With `params`/brush/pan-zoom selections, an
editing overlay steals the pointer events those selections need → requires an explicit
**edit ⇄ interact** mode toggle, an interaction split the wireframe avoids.
- The view is **finalized and recreated each render**, so the overlay re-measures every time (brief
flicker) and must track scroll/resize/DPR and `autosize`-container coordinate transforms.
**Accessibility**
- Drag-and-drop needs a keyboard path (Move up/down exist; cross-container needs a keyboard
equivalent), per the WAI-ARIA APG drag-and-drop pattern, plus reduced-motion. A `/council`
item before the interaction is built.
## 6a. Deferred polish (Phase A follow-ons)
- **Mark-type glyph per leaf.** _(Shipped.)_ A simplified glyph of each unit view's mark inside
its box (the `mark-*` icon sub-family), so which-is-which reads at a glance.
- **A more legible `layered` primitive.** _(Shipped.)_ A layer renders as **one frame** holding
its child marks as a row of glyphs, badged as layered (the `layers` glyph) — not separate boxes,
so it reads as one space and stays distinct from a concat (which is box-per-view). The
overlapping/stacked-planes options weighed here were rejected: at glyph scale, overlapping
line-art muddies the very marks the glyphs exist to show; legibility beat the z-order-depth cue.
- **Hide the affordance for single-view specs.** _(Shipped.)_ The toolbar glyph appears only when
the spec has a composition.
## 7. Recommendation
Build the schematic wireframe (Phase A → C) first: deterministic mapping, no Vega-internal
coupling, works mid-edit, no pointer conflict with chart interactivity. Treat the on-chart overlay
as an optional later skin over the same proven edit core. Start with the **Phase A spike**
(`viewTree` + static nested boxes + click-to-cursor) to make the model concrete before committing
to the mutation work.
+12 -5
View File
@@ -13,6 +13,13 @@ The editor presents the active snippet's spec as formatted JSON with full code-e
- The editor's appearance and behavior — font size, editor theme, minimap visibility, word wrap, line numbers, and tab size — are configurable and read from _Settings_; this section does not redefine their defaults. - The editor's appearance and behavior — font size, editor theme, minimap visibility, word wrap, line numbers, and tab size — are configurable and read from _Settings_; this section does not redefine their defaults.
- The editor always edits a single active snippet. Selecting a different snippet in the _Snippet Library_, or toggling the Draft/Published view, replaces the editor content with the corresponding spec. - The editor always edits a single active snippet. Selecting a different snippet in the _Snippet Library_, or toggling the Draft/Published view, replaces the editor content with the corresponding spec.
### Scaffolding assistance
Beyond the schema's own suggestions, the editor scaffolds common Vega-Lite structures as ready-to-fill skeletons, seeded from the data actually in scope at the cursor. An inserted skeleton arrives with editable placeholders (Tab moves between them), pre-filled with type-appropriate values where the data allows and descriptive names otherwise. Scaffolding acts on the draft only — the published view is a read-only reference and offers none of it.
- **Data transforms.** With the cursor in a view, inline actions above the code offer the pipeline: _Add transform_ when the view has none, the common steps (filter, aggregate, calculate, bin, timeUnit) on an existing `transform` array. Inside a `transform[]` element slot, typing offers the full step catalog as completions, each seeded with a matching column (a numeric field for aggregate, a temporal one for timeUnit). A step added on a composition parent notes that it applies to every child view below it.
- **Parameters.** The same affordance for `params`, split by where Vega-Lite allows each family: **variable** widgets (slider, dropdown — an input control bound to a name the spec can reference) are offered at the spec's top level, the only place they are legal; **selection** parameters (point, interval — interaction on the chart's marks) on the unit view the cursor is in; in a single-view spec, where the top level is the unit, both appear together. Inside a `params[]` element slot, completions offer the catalog — both families in the top-level array, selections only in a nested view's. Defaults are data-seeded where possible: a slider's min/max/step from the numeric field's actual range, a point selection's field from a categorical column.
## B. Auto-Save of the Draft ## B. Auto-Save of the Draft
Edits persist automatically so the user never loses work and never needs an explicit "save" action for ordinary editing. Edits persist automatically so the user never loses work and never needs an explicit "save" action for ordinary editing.
@@ -57,14 +64,14 @@ Every snippet carries two versions of its spec: a **published** (stable) version
- On confirmation, the editor reloads with the published spec and a toast confirms the draft was reverted. - On confirmation, the editor reloads with the published spec and a toast confirms the draft was reverted.
- Revert is unavailable when no snippet is active. - Revert is unavailable when no snippet is active.
## E. Inline Error Surface ## E. Error Surface
When the spec cannot be parsed or cannot be rendered, the editor pane shows the problem clearly while keeping the user in place to fix it. When the spec cannot be parsed or cannot be rendered, the problem is shown clearly while keeping the user in place to fix it.
- When the spec is invalid JSON, or is valid JSON but fails to render as Vega-Lite (including an unresolved dataset reference), a clear, readable error message appears in the editor pane, near the editor area. - When the spec is invalid JSON, or is valid JSON but fails to render as Vega-Lite (including an unresolved dataset reference or a malformed Vega expression), a clear, readable error message appears in the **preview pane, in place of the chart** — a spec either renders or shows its error, never both.
- The error message is plainly legible (monospaced, distinct from normal content) and conveys what went wrong. - The message is plainly legible (monospaced, distinct from normal content) and leads with the location or the problem, then the detail — for example `Line 14 · Unexpected end of input` or `Dataset "sales" not found · create it from Datasets`.
- In the **editor**, the offending spot is marked with an inline squiggle — a JSON syntax error where it occurs, a malformed expression on its own string — so the cause is locatable without leaving the code.
- The editor remains fully usable while an error is shown, so the user can edit to fix it; the error clears automatically once a subsequent edit renders successfully. - The editor remains fully usable while an error is shown, so the user can edit to fix it; the error clears automatically once a subsequent edit renders successfully.
- This is the editor-side error affordance only; how a valid spec is drawn lives in _Live Preview_.
## F. Extract Inline Data to a Dataset ## F. Extract Inline Data to a Dataset
+1 -1
View File
@@ -1,6 +1,6 @@
# 09 · Data Model & Persistence # 09 · Data Model & Persistence
This section defines the persistent entities of Astrolabe and how they relate. It is the authoritative data contract: an implementer recreating the app should store equivalent records with these fields and meanings. Types are given abstractly (string, number, boolean, ISO-timestamp string, string[], object, "JSON value") so they map onto any stack. "JSON value" means any valid JSON shape — object, array, string, number, boolean, or null. This section defines the persistent entities of Astrolabe and how they relate. It is the data record of the shipped app: an implementer recreating the app should store equivalent records with these fields and meanings. Types are given abstractly (string, number, boolean, ISO-timestamp string, string[], object, "JSON value") so they map onto any stack. "JSON value" means any valid JSON shape — object, array, string, number, boolean, or null.
All data lives entirely in the browser. There is no server, account, or sync. Records survive page reload and remain available offline (see _Application Shell & Navigation_). To move data between browsers or devices, use _Import & Export_. All data lives entirely in the browser. There is no server, account, or sync. Records survive page reload and remain available offline (see _Application Shell & Navigation_). To move data between browsers or devices, use _Import & Export_.
+1
View File
@@ -8,6 +8,7 @@ A UX/behavioral specification of **Astrolabe**, a browser-based snippet manager
- Each subsequent file is one feature area and can be read on its own; they cross-reference each other by title. - Each subsequent file is one feature area and can be read on its own; they cross-reference each other by title.
- Every section describes intended behavior plus testable acceptance points ("The user can…", "When X, the system…"). - Every section describes intended behavior plus testable acceptance points ("The user can…", "When X, the system…").
- Section numbers and lettered headings (e.g. `§03G`, `§09B`) are **stable anchors** — code comments reference them. Extend by appending the next letter/number; never renumber existing ones. - Section numbers and lettered headings (e.g. `§03G`, `§09B`) are **stable anchors** — code comments reference them. Extend by appending the next letter/number; never renumber existing ones.
- The spec is **descriptive**: it records what the shipped app does. The code leads — when the app and a section here disagree, the section is stale; rewrite it to match (deliberately) rather than treating it as a veto on the code.
## What this spec deliberately omits ## What this spec deliberately omits
+18
View File
@@ -8,6 +8,13 @@ record the resolution into the contract (`docs/architecture/09`+`10` and the rel
## Open ## Open
- **Extract-to-Dataset has no keyboard accelerator** — its sibling editor actions (wrap /
config, in `spec-transform-actions` / `spec-config-actions`) register an F1-palette command
and a lightbulb; Extract is toolbar-only (`runExtract` in `services/extract-action.ts`),
because it opens a modal rather than making an in-place undoable edit, so the palette/lightbulb
fit awkwardly. Decide whether to add a palette command anyway for parity (a keyboard path to
open the modal at the cursor), or leave toolbar-only.
- **Storage-full copy implies a per-tier budget, but quota is whole-origin** — the messages - **Storage-full copy implies a per-tier budget, but quota is whole-origin** — the messages
say "snippet storage is full" / "dataset storage is full" and tell the user to delete that say "snippet storage is full" / "dataset storage is full" and tell the user to delete that
entity's items, yet IndexedDB quota is shared across the whole origin. Per-tier framing is entity's items, yet IndexedDB quota is shared across the whole origin. Per-tier framing is
@@ -17,6 +24,17 @@ record the resolution into the contract (`docs/architecture/09`+`10` and the rel
`storageErrorNotification` and `entityStorageErrorNotification` in `services/storage-errors.ts` `storageErrorNotification` and `entityStorageErrorNotification` in `services/storage-errors.ts`
and the import-quota copy in `services/transfer.ts`. and the import-quota copy in `services/transfer.ts`.
- **"Row" vs "column" naming differs between the wireframe's pull-out and pair drags** — the
frame-margin pull-out chip/announcement (`pullLabel`, `commitDrop`) name the new full-span band
by its _spatial_ shape (a `vconcat` slot is "a new row above"; an `hconcat` slot "a new column
left"), while the pair-into-split chip/announcement use the _container_ convention (`hconcat` =
"row", `vconcat` = "column"). Both describe the same axis — pulling a view above a row and
pairing two views vertically are both a `vconcat` — yet one calls it a row and the other a
column. Each reading is locally sensible (a pulled-out band reads as a row; a 2-cell vertical
split reads as a column) but the divergence can confuse. Decide: unify on one vocabulary, or keep
the gesture-specific framing. In `components/CompositionWireframe.tsx` (`pullLabel`, the
`'row'`/`'column'` ternaries in `resolveDrop`/`commitDrop`).
## Deferred (not design debts, revisit on demand) ## Deferred (not design debts, revisit on demand)
- **Drag-and-drop field assignment** — chips are click/keyboard-first by design; drag would - **Drag-and-drop field assignment** — chips are click/keyboard-first by design; drag would
+3 -3
View File
@@ -44,10 +44,10 @@ export default tseslint.config(
languageOptions: { globals: { ...globals.node } }, languageOptions: { globals: { ...globals.node } },
}, },
// Plain JS config files (this file, etc.) are not part of the TS project — // Plain JS config files and Node scripts (this file, scripts/*.mjs, etc.) are
// run them through the untyped ruleset only. // not part of the TS project — run them through the untyped ruleset only.
{ {
files: ['**/*.js'], files: ['**/*.{js,mjs}'],
extends: [tseslint.configs.disableTypeChecked], extends: [tseslint.configs.disableTypeChecked],
languageOptions: { globals: { ...globals.node } }, languageOptions: { globals: { ...globals.node } },
}, },
+4
View File
@@ -6,6 +6,10 @@
<link rel="mask-icon" href="/icon-mono.svg" color="#0e7490" /> <link rel="mask-icon" href="/icon-mono.svg" color="#0e7490" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" /> <link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
name="description"
content="A local-first studio for Vega-Lite charts. Author specs as JSON, see them render live, and keep a searchable library in your browser. No account, no server, no AI — with no backend to send it to, your data stays on your device, so confidential work is safe here from the first chart."
/>
<title>Astrolabe — a local Vega-Lite studio</title> <title>Astrolabe — a local Vega-Lite studio</title>
</head> </head>
<body> <body>
+8
View File
@@ -22,6 +22,7 @@
"@fontsource/space-mono": "^5.2.9", "@fontsource/space-mono": "^5.2.9",
"@fontsource/spectral": "^5.2.8", "@fontsource/spectral": "^5.2.8",
"json-stringify-pretty-compact": "^4.0.0", "json-stringify-pretty-compact": "^4.0.0",
"jsonc-parser": "^3.3.1",
"marked": "^18.0.5", "marked": "^18.0.5",
"monaco-editor": "^0.54.0", "monaco-editor": "^0.54.0",
"react": "^19.2.7", "react": "^19.2.7",
@@ -39,6 +40,7 @@
"@types/react": "^19.2.16", "@types/react": "^19.2.16",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.2.0", "@vitejs/plugin-react": "^5.2.0",
"ajv": "^8.20.0",
"eslint": "^10.4.1", "eslint": "^10.4.1",
"eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2", "eslint-plugin-react-refresh": "^0.5.2",
@@ -6148,6 +6150,12 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/jsonc-parser": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz",
"integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==",
"license": "MIT"
},
"node_modules/jsonfile": { "node_modules/jsonfile": {
"version": "6.2.1", "version": "6.2.1",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz",
+3 -1
View File
@@ -6,7 +6,7 @@
"description": "A browser-based snippet manager for Vega-Lite visualizations.", "description": "A browser-based snippet manager for Vega-Lite visualizations.",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc --noEmit && vite build", "build": "tsc --noEmit && vite build && node scripts/check-light-entries.mjs",
"preview": "vite preview", "preview": "vite preview",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"test": "vitest run", "test": "vitest run",
@@ -38,6 +38,7 @@
"@fontsource/space-mono": "^5.2.9", "@fontsource/space-mono": "^5.2.9",
"@fontsource/spectral": "^5.2.8", "@fontsource/spectral": "^5.2.8",
"json-stringify-pretty-compact": "^4.0.0", "json-stringify-pretty-compact": "^4.0.0",
"jsonc-parser": "^3.3.1",
"marked": "^18.0.5", "marked": "^18.0.5",
"monaco-editor": "^0.54.0", "monaco-editor": "^0.54.0",
"react": "^19.2.7", "react": "^19.2.7",
@@ -55,6 +56,7 @@
"@types/react": "^19.2.16", "@types/react": "^19.2.16",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.2.0", "@vitejs/plugin-react": "^5.2.0",
"ajv": "^8.20.0",
"eslint": "^10.4.1", "eslint": "^10.4.1",
"eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2", "eslint-plugin-react-refresh": "^0.5.2",
+10
View File
@@ -0,0 +1,10 @@
# Cloudflare Pages header rules (copied into dist/ by Vite's public/ passthrough).
#
# Everything under /assets/ is content-hashed by the build (JS, CSS, and the
# font files), so it is safe to cache forever — a changed file gets a new URL.
# Without this rule CF Pages serves its default `max-age=14400, must-revalidate`,
# making every returning visitor revalidate multi-MB vendor chunks every 4 hours.
# HTML keeps the platform default (max-age=0, must-revalidate) so deploys
# propagate instantly; icons live at the root un-hashed and keep the default too.
/assets/*
Cache-Control: public, max-age=31536000, immutable
+47
View File
@@ -0,0 +1,47 @@
// Post-build gate: the marketing entries stay light. The landing (/) and the
// learning section (/learn/) must never gain a static edge into the heavy
// vendor chunks — this shipped once (Vite's preload helper emitted inside the
// monaco chunk chained every lazy import() to it; a vega-scale import in
// theme-controls was reachable from Landing). Vite lists an entry's full
// static graph as modulepreload links / module scripts in its HTML, so
// grepping the emitted HTML catches any regression regardless of cause.
//
// Runs as part of `npm run build` (after `vite build`). Exit 1 on violation.
import { existsSync, readFileSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
const pages = ['dist/index.html', 'dist/learn/index.html'];
if (existsSync('dist/learn')) {
for (const entry of readdirSync('dist/learn', { withFileTypes: true })) {
if (entry.isDirectory()) pages.push(join('dist/learn', entry.name, 'index.html'));
}
}
const HEAVY = /vendor-(?:monaco|vega)-[^"']*\.js/;
const missing = pages.filter((p) => !existsSync(p));
if (missing.length > 0) {
console.error(
`check-light-entries: expected pages missing from dist/:\n ${missing.join('\n ')}`,
);
process.exit(1);
}
// The gate bans chunks by name, so it must not fail open: if the names minted in
// vite.config.ts (manualChunks) ever change, this makes the rename update the gate
// instead of silently disarming it.
const assets = readdirSync('dist/assets');
for (const chunk of ['vendor-monaco', 'vendor-vega']) {
if (!assets.some((f) => f.startsWith(`${chunk}-`) && f.endsWith('.js'))) {
console.error(
`check-light-entries: no ${chunk}-*.js in dist/assets — chunk naming changed; update vite.config.ts manualChunks and this gate together.`,
);
process.exit(1);
}
}
const offenders = pages.filter((p) => HEAVY.test(readFileSync(p, 'utf8')));
if (offenders.length > 0) {
console.error(
`check-light-entries: light entries reference heavy vendor chunks:\n ${offenders.join('\n ')}`,
);
process.exit(1);
}
console.log(`check-light-entries: OK (${pages.length} pages clean of vendor-monaco/vendor-vega)`);
+93
View File
@@ -0,0 +1,93 @@
/**
* Generate one static `learn/<slug>/index.html` shell per lesson, so each lesson is
* its own indexable URL (`/learn/<slug>/`) carrying lesson-specific `<title>` and
* `<meta description>`. The shared `src/learn` entry renders the right view from the
* path. Driven by the lesson `.md` frontmatter adding a lesson stays "drop a
* file" and run from `vite.config.ts` at load (dev and build) so the shells stay
* in sync; the generated dirs are git-ignored.
*
* Plain content-shells only (per-URL metadata, client-rendered body). Prerendering
* the prose into the HTML would help non-JS crawlers but is deliberately deferred.
*/
import { mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
const ROOT = new URL('../', import.meta.url);
const LESSONS_DIR = fileURLToPath(new URL('src/learn/lessons/', ROOT));
const LEARN_DIR = fileURLToPath(new URL('learn/', ROOT));
interface LessonMeta {
slug: string;
title: string;
tagline: string;
}
// A minimal frontmatter reader, separate from `core/lesson-parse`'s `parseLesson`:
// this runs at vite-config load time, before the `@core` alias is registered, and
// only needs the three header fields (not the parsed body). The two must agree on
// the frontmatter shape — both read `slug`/`title`/`tagline` from `--- … ---`.
/** Pull `slug`/`title`/`tagline` from a lesson's `--- … ---` frontmatter. */
function frontmatter(md: string): LessonMeta | null {
const block = md.match(/^---\n([\s\S]*?)\n---/);
if (!block) return null;
const field = (key: string): string | undefined =>
block[1].match(new RegExp(`^${key}:\\s*(.+)$`, 'm'))?.[1].trim();
const slug = field('slug');
const title = field('title');
const tagline = field('tagline');
return slug && title && tagline ? { slug, title, tagline } : null;
}
/** Lesson metadata for every `src/learn/lessons/*.md`, sorted by slug. */
function readLessons(): LessonMeta[] {
return readdirSync(LESSONS_DIR)
.filter((f) => f.endsWith('.md'))
.map((f) => frontmatter(readFileSync(LESSONS_DIR + f, 'utf8')))
.filter((l): l is LessonMeta => l !== null)
.sort((a, b) => a.slug.localeCompare(b.slug));
}
/** Rollup inputs (one per lesson) → the generated `learn/<slug>/index.html` shells. */
export function lessonInputs(): Record<string, string> {
const input: Record<string, string> = {};
for (const { slug } of readLessons()) input[`learn-${slug}`] = `${LEARN_DIR}${slug}/index.html`;
return input;
}
const escapeHtml = (s: string): string =>
s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
function shell({ title, tagline }: LessonMeta): string {
return `<!doctype html>
<html lang="en" data-theme="light">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="mask-icon" href="/icon-mono.svg" color="#0e7490" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="${escapeHtml(tagline)}" />
<title>${escapeHtml(title)} Astrolabe</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/learn/main.tsx"></script>
</body>
</html>
`;
}
/** Write a shell per lesson and prune dirs for lessons that no longer exist. */
export function generateLearnPages(): void {
const lessons = readLessons();
const wanted = new Set(lessons.map((l) => l.slug));
for (const lesson of lessons) {
mkdirSync(`${LEARN_DIR}${lesson.slug}/`, { recursive: true });
writeFileSync(`${LEARN_DIR}${lesson.slug}/index.html`, shell(lesson));
}
for (const entry of readdirSync(LEARN_DIR, { withFileTypes: true })) {
if (entry.isDirectory() && !wanted.has(entry.name)) {
rmSync(`${LEARN_DIR}${entry.name}`, { recursive: true, force: true });
}
}
}
+16 -11
View File
@@ -4,7 +4,7 @@
* Rendered inside ModalShell no backdrop, close button, or focus trap here; * Rendered inside ModalShell no backdrop, close button, or focus trap here;
* the shell owns all of that (docs/architecture/03 Layer 3). This component * the shell owns all of that (docs/architecture/03 Layer 3). This component
* is pure content: app identity, keyboard shortcuts (§01D), privacy posture * is pure content: app identity, keyboard shortcuts (§01D), privacy posture
* (SOUL.md local-only, no accounts, no telemetry), and acknowledgements of * (SOUL.md local-only, no accounts, no telemetry, no AI), and acknowledgements of
* the projects Astrolabe is built on and shaped by. * the projects Astrolabe is built on and shaped by.
*/ */
@@ -47,9 +47,8 @@ export function AboutModal() {
v<span className={styles.version}>{__APP_VERSION__}</span> v<span className={styles.version}>{__APP_VERSION__}</span>
</p> </p>
<p className={styles.body}> <p className={styles.body}>
A local-first workspace for authoring, organizing, and previewing Vega-Lite charts. Edit A local-first workspace for authoring and organizing Vega-Lite charts. Edit the JSON,
JSON, see the chart update live, and keep a personal library of snippets with no watch it render live, and keep a personal library of snippets.
account, no server, and full offline support.
</p> </p>
</section> </section>
@@ -74,17 +73,23 @@ export function AboutModal() {
<section className={styles.section}> <section className={styles.section}>
<h3 className={styles.heading}>Privacy</h3> <h3 className={styles.heading}>Privacy</h3>
<p className={styles.body}> <p className={styles.body}>
Astrolabe runs entirely in your browser. Your snippets, datasets, and settings are stored Astrolabe runs in your browser. Your snippets, datasets, and settings are stored locally
locally and never leave your machine. on your device there&rsquo;s no server to send them to. Work that has to stay
confidential is safe here.
</p> </p>
<ul className={styles.list}> <ul className={styles.list}>
<li>No account, no sign-in, no server-side storage.</li> <li>No account or sign-in.</li>
<li>The app runs no analytics or tracking no cookies, no telemetry, no profiling.</li>
<li> <li>
The only outbound network requests are ones you create: URL-sourced datasets you add No AI. No model authors, edits, or critiques your charts, and nothing is sent to one.
yourself. The chart builder&rsquo;s suggestions are computed locally from your data.
</li> </li>
<li>After the first load, the app works fully offline.</li> <li>
The app itself runs no analytics or tracking. Its host (Cloudflare) records standard,
aggregate traffic like any web server not your library or the charts you build, which
stay in your browser.
</li>
<li>The only outbound requests are ones you make: datasets you load from a URL.</li>
<li>After the first load, the app works offline.</li>
<li> <li>
Use the header&rsquo;s import / export buttons to move your library between devices. Use the header&rsquo;s import / export buttons to move your library between devices.
</li> </li>
@@ -25,7 +25,12 @@ vi.mock('../services/chart-renderer', () => {
} }
return { return {
renderSpec: vi.fn(() => renderSpec: vi.fn(() =>
Promise.resolve({ destroy() {}, resize() {}, inspectData: () => null }), Promise.resolve({
destroy() {},
resize() {},
inspectData: () => null,
onDataChange: () => () => {},
}),
), ),
ChartTooLargeError, ChartTooLargeError,
}; };
@@ -21,6 +21,7 @@ vi.mock('../services/chart-renderer', () => ({
resize() {}, resize() {},
toImageURL: () => Promise.resolve(''), toImageURL: () => Promise.resolve(''),
inspectData: () => null, inspectData: () => null,
onDataChange: () => () => {},
}), }),
), ),
})); }));
+2 -7
View File
@@ -20,13 +20,8 @@
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import type { JsonObject } from '@core/spec-config'; import type { JsonObject } from '@core/spec-config';
import { import { type ConfigPath, countSet, getConfigValue, schemesByKind } from '@core/theme-controls';
type ConfigPath, import { schemeColors } from '@core/scheme-colors';
countSet,
getConfigValue,
schemeColors,
schemesByKind,
} from '@core/theme-controls';
import { Button } from './Button'; import { Button } from './Button';
import { ColorField } from './ColorField'; import { ColorField } from './ColorField';
import { Icon } from './Icon'; import { Icon } from './Icon';
@@ -0,0 +1,313 @@
.wrap {
display: inline-flex;
}
/* Disclosure panel portaled to body, positioned `fixed` by usePopover. The shared
disclosure-popover surface (arch 10): elevated --layer-01, hairline border, --radius. */
.pop {
position: fixed;
z-index: 1000;
width: 300px;
max-height: 60vh;
overflow: auto;
padding: var(--space-4);
background: var(--layer-01);
border: var(--border-width) solid var(--border);
border-radius: var(--radius);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
}
.title {
margin: 0 0 var(--space-3);
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-secondary);
}
.tree {
font-size: 12px;
}
/* Bare wireframe: a crisp box, fill only the canvas. Passive structure, so the
boxes carry the function (drop targets, later) `--border-strong`, no chrome. */
.node {
border: 1px solid var(--border-strong);
background: var(--bg);
}
/* The padding is the *pull-out margin*: a frame's gutter, between its border and its
child boxes, is the drop zone that lifts a view out into a new full-span row/column
(vs. a box's own edge, which pairs or reorders). Sized for a comfortable target. */
.container {
--pull-gutter: var(--space-5);
--reveal-tint: color-mix(in srgb, var(--accent) 7%, transparent);
position: relative;
padding: var(--pull-gutter);
cursor: pointer;
}
.leaf {
min-height: 48px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
/* The mark glyph is a quiet identity hint, not chrome — muted, inherits theme. */
.markIcon {
color: var(--text-secondary);
}
.node:hover {
border-color: var(--text-secondary);
}
/* Draggable boxes (draft only) advertise the drag with a grab cursor; the body
cursor flips to grabbing for the duration of an active drag (set in JS). */
.node[data-draggable] {
cursor: grab;
}
.node[data-dragging] {
opacity: 0.4;
}
/* Reorder (move) indicator (arch 10 §5): a 3px accent line on the target box's edge
marks where the dragged view lands in the sequence. */
.node[data-drop-mode='move'][data-drop-edge='left'] {
box-shadow: inset 3px 0 0 var(--accent);
}
.node[data-drop-mode='move'][data-drop-edge='right'] {
box-shadow: inset -3px 0 0 var(--accent);
}
.node[data-drop-mode='move'][data-drop-edge='top'] {
box-shadow: inset 0 3px 0 var(--accent);
}
.node[data-drop-mode='move'][data-drop-edge='bottom'] {
box-shadow: inset 0 -3px 0 var(--accent);
}
/* Pair (wrap) indicator: the two views split into a new row/column. The target box is
outlined, and the half the incoming view will take is shaded with a seam line at the
split so it previews the split, not just "drop here". */
.node[data-drop-mode='wrap'] {
position: relative;
outline: 2px solid var(--accent);
outline-offset: -2px;
}
.node[data-drop-mode='wrap']::after {
content: '';
position: absolute;
background: var(--accent-soft);
pointer-events: none;
}
.node[data-drop-mode='wrap'][data-drop-edge='top']::after {
inset: 0 0 50% 0;
border-bottom: 2px solid var(--accent);
}
.node[data-drop-mode='wrap'][data-drop-edge='bottom']::after {
inset: 50% 0 0 0;
border-top: 2px solid var(--accent);
}
.node[data-drop-mode='wrap'][data-drop-edge='left']::after {
inset: 0 50% 0 0;
border-right: 2px solid var(--accent);
}
.node[data-drop-mode='wrap'][data-drop-edge='right']::after {
inset: 0 0 0 50%;
border-left: 2px solid var(--accent);
}
/* Pull-out indicator: the dragged view would lift out into a new full-span row/column
here. A shaded band fills the frame's margin on the resolved side, capped by a solid
accent line the "shaded drop-zone" feedback (vs. the box-edge line of a pair/move). */
.node[data-drop-pull]::after {
content: '';
position: absolute;
background: var(--accent-soft);
pointer-events: none;
}
.node[data-drop-pull='top']::after {
inset: 0 0 auto 0;
height: var(--pull-gutter);
border-top: 2px solid var(--accent);
}
.node[data-drop-pull='bottom']::after {
inset: auto 0 0 0;
height: var(--pull-gutter);
border-bottom: 2px solid var(--accent);
}
.node[data-drop-pull='left']::after {
inset: 0 auto 0 0;
width: var(--pull-gutter);
border-left: 2px solid var(--accent);
}
.node[data-drop-pull='right']::after {
inset: 0 0 0 auto;
width: var(--pull-gutter);
border-right: 2px solid var(--accent);
}
/* Reveal-on-drag: while a drag is live, every frame's pull-out margins glow faintly so
the targets are discoverable without hunting across the container's own axis (a
row's margins are top/bottom, a column's left/right). The active target reads
stronger via the band above. */
.tree[data-drag-active] .container[data-orientation='horizontal'] {
box-shadow:
inset 0 var(--pull-gutter) 0 var(--reveal-tint),
inset 0 calc(-1 * var(--pull-gutter)) 0 var(--reveal-tint);
}
.tree[data-drag-active] .container[data-orientation='vertical'] {
box-shadow:
inset var(--pull-gutter) 0 0 var(--reveal-tint),
inset calc(-1 * var(--pull-gutter)) 0 0 var(--reveal-tint);
}
/* Selection mirrors the library's active-row language (arch 09 §4). */
.node.selected {
border-color: var(--accent);
box-shadow: inset 0 0 0 1px var(--accent);
}
.leaf.selected {
background: var(--accent-soft);
}
.node:focus-visible {
outline: 2px solid var(--focus);
outline-offset: 1px;
}
.children {
gap: var(--space-2);
}
.row {
display: flex;
flex-direction: row;
}
.row > * {
flex: 1 1 0;
min-width: 0;
}
.col {
display: flex;
flex-direction: column;
}
/* General concat wraps into a grid (honors `columns`). */
.grid {
display: flex;
flex-flow: row wrap;
}
.grid > * {
flex: 1 1 84px;
}
/* Layer: one plotting space holding several marks (z-order). Render it as a single
frame a row of the child mark glyphs, badged as layered not as separate boxes,
so it reads as one space and the marks stay legible (vs. the concat box-per-view). */
.layerBox {
position: relative;
}
.layered {
display: flex;
flex-flow: row wrap;
align-items: center;
justify-content: center;
gap: var(--space-3);
min-height: 40px;
}
/* A layer's mark is a bare glyph, not a boxed leaf the frame is the box. The
transparent border keeps the hover/selection outline (from `.node`) consistent. */
.layerMark {
min-height: 0;
padding: var(--space-2) var(--space-3);
border-color: transparent;
background: transparent;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.layerBadge {
position: absolute;
top: var(--space-2);
right: var(--space-2);
color: var(--text-placeholder);
pointer-events: none;
}
/* Facet / repeat: one authored child stands for many generated cells a card
peeking out behind hints at the multiples. */
.generated {
position: relative;
}
.generated::before {
content: '';
position: absolute;
inset: 5px -5px -5px 5px;
border: 1px solid var(--border);
z-index: -1;
}
.caption {
margin: var(--space-3) 0 0;
min-height: 1.4em;
color: var(--text-secondary);
font-size: 11px;
}
.caption code {
font-family: var(--font-mono);
color: var(--text);
}
.muted {
color: var(--text-placeholder);
}
/* Drag chip: follows the cursor during a drag, naming the pending action ("New row
above", "Pair into a column", "Reorder"). Inverted ink-on-bg for contrast on any
theme; above the popover (z 1000) since it portals to the body. */
.dragChip {
position: fixed;
z-index: 1100;
pointer-events: none;
padding: 3px 8px;
font-size: 11px;
font-weight: 600;
color: var(--bg);
background: var(--text);
border-radius: var(--radius);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.35);
white-space: nowrap;
}
.dragChip[data-empty] {
opacity: 0.6;
}
/* Redundant single-child wrapper: a frame holding one view adds no structure. A soft
warning hairline marks it as cleanable (not broken); the prompt below offers the fix. */
.node[data-degenerate] {
border-style: dashed;
border-color: var(--support-warning-fg);
}
.warning {
display: flex;
align-items: center;
gap: var(--space-3);
margin-top: var(--space-3);
padding: var(--space-2) var(--space-3);
font-size: 11px;
color: var(--support-warning-fg);
background: color-mix(in srgb, var(--support-warning) 12%, var(--bg));
border: var(--border-width) solid color-mix(in srgb, var(--support-warning) 40%, var(--bg));
border-radius: var(--radius);
}
.warning span {
flex: 1;
}
.simplify {
flex-shrink: 0;
font: inherit;
font-weight: 600;
color: var(--accent);
background: none;
border: none;
padding: 2px 4px;
cursor: pointer;
}
.simplify:hover {
text-decoration: underline;
}
.simplify:focus-visible {
outline: 2px solid var(--focus);
outline-offset: 1px;
}
@@ -0,0 +1,373 @@
/**
* CompositionWireframe the read-only structure tree (arch 08 / arch 10 §5).
*
* Guards the load-bearing behavior: the affordance is hidden for a single-view
* spec (nothing to schematize), the disclosed panel is an APG tree with one
* roving tab stop, arrow keys walk it in document order, and activating a box
* asks the editor to reveal that view's exact source range. The viewTree model
* itself is covered in core (spec-view-tree.test.ts).
*/
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { useAppStore } from '../stores/AppStore';
import { usePopoverStore } from '../stores/PopoverStore';
import { useSnippetStore } from '../stores/SnippetStore';
import { CompositionWireframe } from './CompositionWireframe';
import { markIconName } from './mark-icon';
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const COMPOSED = JSON.stringify({ vconcat: [{ mark: 'point' }, { mark: 'bar' }] }, null, 2);
const LAYERED = JSON.stringify({ layer: [{ mark: 'bar' }, { mark: 'line' }] }, null, 2);
let container: HTMLDivElement;
let root: Root;
const setSpec = (text: string) => {
useSnippetStore.getState().reset();
useSnippetStore.setState({ draftText: text });
};
/** Like `setSpec`, but with an active draft so reorder affordances are enabled. */
const setEditableSpec = (text: string) => {
useSnippetStore.getState().reset();
useSnippetStore.setState({ draftText: text, activeSnippetId: 'snip', editorView: 'draft' });
};
const key = (e: KeyboardEventInit) => new KeyboardEvent('keydown', { bubbles: true, ...e });
const treeItems = () =>
Array.from(document.body.querySelectorAll<HTMLElement>('[role="treeitem"]'));
const item = (key: string) => document.body.querySelector<HTMLElement>(`[data-key="${key}"]`)!;
async function renderOpen() {
await act(async () => {
root.render(<CompositionWireframe />);
await Promise.resolve();
});
await act(async () => {
usePopoverStore.getState().show('composition-wireframe');
await Promise.resolve();
});
}
beforeEach(() => {
usePopoverStore.setState({ openId: null });
useAppStore.setState({ revealTarget: null, composeRequest: null });
setSpec(COMPOSED);
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
usePopoverStore.setState({ openId: null });
useAppStore.setState({ revealTarget: null, composeRequest: null });
useSnippetStore.getState().reset();
});
describe('CompositionWireframe', () => {
test('renders nothing for a single-view spec', () => {
setSpec('{"mark":"point"}');
act(() => root.render(<CompositionWireframe />));
expect(container.querySelector('button')).toBeNull();
});
test('shows a structure trigger for a composed spec', () => {
act(() => root.render(<CompositionWireframe />));
expect(container.querySelector('button[aria-controls="composition-wireframe"]')).not.toBeNull();
});
test('discloses an APG tree: the vconcat root and its two leaf views', async () => {
await renderOpen();
expect(document.body.querySelector('[role="tree"]')).not.toBeNull();
expect(treeItems().map((el) => el.dataset.key)).toEqual(['root', 'vconcat|0', 'vconcat|1']);
});
test('roving tabindex: exactly one treeitem is in the tab order', async () => {
await renderOpen();
expect(treeItems().filter((el) => el.tabIndex === 0)).toHaveLength(1);
expect(item('root').tabIndex).toBe(0); // the root holds it on open
});
test('ArrowDown moves the roving tab stop in document order', async () => {
await renderOpen();
act(() => {
item('root').dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
});
expect(item('root').tabIndex).toBe(-1);
expect(item('vconcat|0').tabIndex).toBe(0);
});
test('activating a box asks the editor to reveal that views source range', async () => {
await renderOpen();
act(() => item('vconcat|1').click());
const target = useAppStore.getState().revealTarget!;
expect(COMPOSED.slice(target.offset, target.offset + target.length)).toContain('"bar"');
});
test('a layer discloses its marks as reorderable treeitems (z-order)', async () => {
// The layer renders as one frame of mark glyphs, but each mark stays a treeitem
// so selection and Alt+arrow z-order reorder keep working.
setEditableSpec(LAYERED);
await renderOpen();
expect(treeItems().map((el) => el.dataset.key)).toEqual(['root', 'layer|0', 'layer|1']);
act(() => {
item('root').dispatchEvent(key({ key: 'ArrowDown' }));
});
act(() => {
item('layer|0').dispatchEvent(key({ key: 'ArrowDown', altKey: true }));
});
expect(useAppStore.getState().composeRequest).toMatchObject({
kind: 'move',
arrayPath: ['layer'],
from: 0,
to: 1,
});
});
test('Alt+ArrowDown asks the editor to reorder the focused view down', async () => {
setEditableSpec(COMPOSED);
await renderOpen();
// Move the roving focus onto the first leaf, then reorder it down.
act(() => {
item('root').dispatchEvent(key({ key: 'ArrowDown' }));
});
act(() => {
item('vconcat|0').dispatchEvent(key({ key: 'ArrowDown', altKey: true }));
});
const req = useAppStore.getState().composeRequest!;
expect(req).toMatchObject({ arrayPath: ['vconcat'], from: 0, to: 1 });
});
test('Alt+ArrowUp at the start does not reorder', async () => {
setEditableSpec(COMPOSED);
await renderOpen();
act(() => {
item('root').dispatchEvent(key({ key: 'ArrowDown' }));
});
act(() => {
item('vconcat|0').dispatchEvent(key({ key: 'ArrowUp', altKey: true }));
});
expect(useAppStore.getState().composeRequest).toBeNull();
});
test('reorder is inert on a read-only (non-draft) view', async () => {
// Default COMPOSED is set with no active snippet → not editable.
await renderOpen();
act(() => {
item('root').dispatchEvent(key({ key: 'ArrowDown' }));
});
act(() => {
item('vconcat|0').dispatchEvent(key({ key: 'ArrowDown', altKey: true }));
});
expect(useAppStore.getState().composeRequest).toBeNull();
});
});
describe('CompositionWireframe — drag to restructure', () => {
// happy-dom has no layout, so the drop hit-test (which reads box rects) needs them
// stubbed. A vertical stack of two equal halves, keyed by the box's data-key.
const RECTS: Record<string, { x: number; y: number; w: number; h: number }> = {
root: { x: 0, y: 0, w: 200, h: 200 },
'vconcat|0': { x: 0, y: 0, w: 200, h: 100 },
'vconcat|1': { x: 0, y: 100, w: 200, h: 100 },
};
beforeEach(() => {
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (
this: HTMLElement,
): DOMRect {
const r = (this.dataset?.key && RECTS[this.dataset.key]) || { x: 0, y: 0, w: 0, h: 0 };
return {
left: r.x,
top: r.y,
right: r.x + r.w,
bottom: r.y + r.h,
width: r.w,
height: r.h,
x: r.x,
y: r.y,
toJSON: () => ({}),
};
});
});
afterEach(() => {
vi.restoreAllMocks();
});
const drag = (fromKey: string, to: { x: number; y: number }, shift = false) => {
act(() => {
item(fromKey).dispatchEvent(
new MouseEvent('pointerdown', { bubbles: true, clientX: 100, clientY: 150 }),
);
});
act(() => {
window.dispatchEvent(
new MouseEvent('pointermove', { clientX: to.x, clientY: to.y, shiftKey: shift }),
);
});
act(() => {
window.dispatchEvent(new MouseEvent('pointerup', {}));
});
};
test('dropping onto a views far cross edge pairs the two into a row', async () => {
setEditableSpec(COMPOSED);
await renderOpen();
drag('vconcat|1', { x: 190, y: 50 }); // right edge of the top view (cross axis)
expect(useAppStore.getState().composeRequest).toMatchObject({
kind: 'wrap',
targetPath: ['vconcat', 0],
sourcePath: ['vconcat', 1],
axis: 'horizontal',
side: 'after',
});
});
test('dropping along the container reorders within it', async () => {
setEditableSpec(COMPOSED);
await renderOpen();
drag('vconcat|1', { x: 100, y: 8 }); // top edge of the top view
expect(useAppStore.getState().composeRequest).toMatchObject({
kind: 'move',
arrayPath: ['vconcat'],
from: 1,
to: 0,
});
});
});
describe('CompositionWireframe — pull a view out via the frame margin', () => {
const HCON = JSON.stringify({ hconcat: [{ mark: 'point' }, { mark: 'bar' }, { mark: 'line' }] });
// A row of three boxes inset inside the root frame, leaving a margin (the pull-out
// zone) all around. happy-dom has no layout, so the hit-test rects are stubbed.
const RECTS: Record<string, { x: number; y: number; w: number; h: number }> = {
root: { x: 0, y: 0, w: 300, h: 120 },
'hconcat|0': { x: 24, y: 24, w: 80, h: 72 },
'hconcat|1': { x: 110, y: 24, w: 80, h: 72 },
'hconcat|2': { x: 196, y: 24, w: 80, h: 72 },
};
beforeEach(() => {
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (
this: HTMLElement,
): DOMRect {
const r = (this.dataset?.key && RECTS[this.dataset.key]) || { x: 0, y: 0, w: 0, h: 0 };
const box = {
left: r.x,
top: r.y,
right: r.x + r.w,
bottom: r.y + r.h,
width: r.w,
height: r.h,
x: r.x,
y: r.y,
};
return { ...box, toJSON: () => ({}) };
});
});
afterEach(() => vi.restoreAllMocks());
const drag = (fromKey: string, to: { x: number; y: number }, shift = false) => {
act(() => {
item(fromKey).dispatchEvent(
new MouseEvent('pointerdown', { bubbles: true, clientX: 60, clientY: 60 }),
);
});
act(() => {
window.dispatchEvent(
new MouseEvent('pointermove', { clientX: to.x, clientY: to.y, shiftKey: shift }),
);
});
act(() => {
window.dispatchEvent(new MouseEvent('pointerup', {}));
});
};
test('dropping in the cross-axis margin pulls the view into a new full-span row', async () => {
setEditableSpec(HCON);
await renderOpen();
drag('hconcat|0', { x: 150, y: 8 }); // top margin of the row — across its axis
expect(useAppStore.getState().composeRequest).toMatchObject({
kind: 'wrap-container',
containerPath: [],
sourcePath: ['hconcat', 0],
axis: 'vertical',
side: 'before',
});
});
test('a with-axis margin reorders to that end of the row, not a pull', async () => {
setEditableSpec(HCON);
await renderOpen();
drag('hconcat|1', { x: 8, y: 60 }); // left margin (along the axis), before every box
expect(useAppStore.getState().composeRequest).toMatchObject({
kind: 'move',
arrayPath: ['hconcat'],
from: 1,
to: 0,
});
});
test('dropping over a siblings central band reorders past it', async () => {
setEditableSpec(HCON);
await renderOpen();
drag('hconcat|2', { x: 50, y: 70 }); // central band of the first box → before it
expect(useAppStore.getState().composeRequest).toMatchObject({
kind: 'move',
arrayPath: ['hconcat'],
from: 2,
to: 0,
});
});
test('dropping on a siblings top edge stacks the two into a column', async () => {
setEditableSpec(HCON);
await renderOpen();
drag('hconcat|2', { x: 130, y: 30 }); // top edge of the middle box (cross axis)
expect(useAppStore.getState().composeRequest).toMatchObject({
kind: 'wrap',
targetPath: ['hconcat', 1],
sourcePath: ['hconcat', 2],
axis: 'vertical',
side: 'before',
});
});
});
describe('CompositionWireframe — simplify redundant wrappers', () => {
const simplifyButton = () =>
Array.from(document.body.querySelectorAll('button')).find((b) => b.textContent === 'Simplify');
test('offers Simplify for a single-child composition and dispatches it', async () => {
setEditableSpec(JSON.stringify({ hconcat: [{ mark: 'point' }] }));
await renderOpen();
const btn = simplifyButton();
expect(btn).toBeTruthy();
act(() => btn!.click());
expect(useAppStore.getState().composeRequest).toMatchObject({ kind: 'simplify' });
});
test('no Simplify prompt when every composition has multiple views', async () => {
setEditableSpec(COMPOSED); // a vconcat of two
await renderOpen();
expect(simplifyButton()).toBeUndefined();
});
});
describe('markIconName', () => {
test('maps marks to glyphs, collapsing synonyms', () => {
expect(markIconName('bar')).toBe('mark-bar');
expect(markIconName('circle')).toBe('mark-point');
expect(markIconName('square')).toBe('mark-point');
expect(markIconName('trail')).toBe('mark-line');
});
test('falls back to a generic glyph for an unknown or absent mark', () => {
expect(markIconName('boxplot')).toBe('mark-generic');
expect(markIconName(undefined)).toBe('mark-generic');
});
});
+798
View File
@@ -0,0 +1,798 @@
/**
* Composition wireframe an interactive schematic of the active spec's multi-view
* structure (docs/architecture/08 editor augmentation). Nested boxes for
* `layer`/`hconcat`/`vconcat`/`concat`/`facet`/`repeat` down to the unit views;
* each leaf carries a glyph of its mark type so which-is-which reads at a glance.
* Clicking a box selects it and reveals that view's source range in the editor
* (`AppStore.requestRevealView`) the editor stays the source of truth.
*
* A disclosure popover (`usePopover`) off a glyph in the preview toolbar; the panel
* is the WAI-ARIA APG **tree** widget `tree`/`treeitem`/`group`, single-select,
* roving tabindex, arrow-key nav in logical (document) order (arch 10 §5).
*
* On the editable draft a view can be **restructured by dragging its box**. Intent is
* read from where the pointer falls against a row/column's children, not one nearest
* edge (arch 10 §5):
* - the interior **central band** reorders within the container;
* - the **cross-axis frame margin** (a row's top/bottom, a column's left/right, or
* past the block) **pulls the source out** into a new full-span row/column wrapping
* the whole container the root included;
* - a view's **far cross edge** pairs the two in a perpendicular `hconcat`/`vconcat`
* (`Shift` forces a pair from the centre); a with-axis drop moves/inserts there.
* Opaque `layer`/`facet`/`repeat`/grid boxes keep the simpler nearest-edge model, and a
* redundant single-child wrapper is flagged with a one-click **Simplify**. The keyboard
* equivalent for in-container reorder is Alt+/ (the APG rearrangeable-listbox pattern);
* cross-container restructuring stays the editor's wrap actions for keyboard users. Every
* edit is applied by the editor (which owns the undoable edit) via
* `AppStore.requestComposeMove`/`requestComposeWrap`/`requestComposeWrapContainer`/
* `requestComposeSimplify`, focus follows the affected box, and a polite live region
* announces the result.
*/
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type KeyboardEvent,
type PointerEvent as ReactPointerEvent,
type ReactNode,
} from 'react';
import { createPortal } from 'react-dom';
import { isPrefixPath, type SpecPath } from '@core/spec-insert';
import type { DropAxis } from '@core/spec-restructure';
import { ARRAY_COMPOSITIONS } from '@core/spec-transforms';
import { viewTree, type Orientation, type ViewNode } from '@core/spec-view-tree';
import { usePopover } from '../hooks/usePopover';
import { useAppStore } from '../stores/AppStore';
import { selectShownText, useSnippetStore } from '../stores/SnippetStore';
import { Icon } from './Icon';
import { IconButton } from './IconButton';
import { markIconName } from './mark-icon';
import styles from './CompositionWireframe.module.css';
const POPOVER_ID = 'composition-wireframe';
const INITIAL_FOCUS = ['[role="treeitem"]'] as const;
/** Pointer travel (px) before a press becomes a drag rather than a click. */
const DRAG_THRESHOLD = 4;
/** The concat orientations a drop can descend into (layers/grids are opaque targets). */
const DESCENDABLE: ReadonlySet<Orientation> = new Set<Orientation>(['horizontal', 'vertical']);
type Edge = 'left' | 'right' | 'top' | 'bottom';
/**
* Fraction of a view's cross-axis size, at each end, that reads as "pair here": drop a
* view onto another's far edge (a row view's top/bottom, a column view's left/right) to
* stack the two in a perpendicular split. The central band stays reorder, so an ordinary
* along-the-axis drag rearranges. (Shift pairs from the central band too.)
*/
const PAIR_BAND = 0.25;
/** A DOM-safe, unique key for a node from its path. */
const keyOf = (path: SpecPath): string => (path.length ? path.join('|') : 'root');
// TODO: vocabulary diverges from the pair labels — pull-out names a vconcat slot "a row"
// (spatial: a full-span band) and an hconcat slot "a column", while the pair-into labels and
// the commit announcements use the container convention (hconcat = "row", vconcat = "column").
// Both describe the same axis; unify or keep the gesture-specific framing (docs/ux-second-pass.md).
/** The pull-out action label for the margin a drop landed in. */
const pullLabel = (edge: Edge): string =>
edge === 'top'
? 'New row above'
: edge === 'bottom'
? 'New row below'
: edge === 'left'
? 'New column left'
: 'New column right';
/** A readable path like `vconcat[1].hconcat[0]` for the caption. */
function pathLabel(path: SpecPath): string {
if (path.length === 0) return 'root';
let out = '';
for (const seg of path) out += typeof seg === 'number' ? `[${seg}]` : out ? `.${seg}` : seg;
return out;
}
const descriptor = (n: ViewNode): string =>
n.kind === 'unit' ? (n.mark ? `${n.mark} view` : 'view') : `${n.op} · ${n.children.length} views`;
const ariaLabelOf = (n: ViewNode): string =>
n.kind === 'unit'
? n.mark
? `${n.mark} view`
: 'view'
: `${n.op}, ${n.orientation}, ${n.children.length} views`;
/** Children layout class per orientation (layered is a row of marks in one frame). */
const LAYOUT: Record<Orientation, string> = {
horizontal: styles.row,
vertical: styles.col,
grid: styles.grid,
layered: styles.layered,
};
interface Flat {
node: ViewNode;
key: string;
parentKey: string | null;
/** The composition that holds this node, or null for the root. */
parent: ViewNode | null;
}
/** Pre-order flatten — drives roving keyboard nav (next/prev/parent/first-child). */
function flatten(root: ViewNode): Flat[] {
const out: Flat[] = [];
const walk = (n: ViewNode, parentKey: string | null, parent: ViewNode | null) => {
const key = keyOf(n.path);
out.push({ node: n, key, parentKey, parent });
for (const c of n.children) walk(c, key, n);
};
walk(root, null, null);
return out;
}
/** The nearest edge of rect `r` to the point — drives the drop axis and side. */
function edgeOf(r: DOMRect, x: number, y: number): Edge {
const fx = (x - r.left) / r.width;
const fy = (y - r.top) / r.height;
const dist: Record<Edge, number> = { left: fx, right: 1 - fx, top: fy, bottom: 1 - fy };
return (Object.keys(dist) as Edge[]).reduce((a, b) => (dist[a] <= dist[b] ? a : b));
}
/** The resolved drop the wireframe will commit, and how to draw it on the target. */
interface DropResolution {
targetKey: string;
edge: Edge;
mode: 'move' | 'wrap' | 'pull';
/** A short human label for what the drop will do — shown in the drag chip. */
label: string;
commit:
| { kind: 'move'; arrayPath: SpecPath; from: number; to: number }
| {
kind: 'wrap';
targetPath: SpecPath;
sourcePath: SpecPath;
axis: DropAxis;
side: 'before' | 'after';
}
| {
kind: 'wrap-container';
containerPath: SpecPath;
sourcePath: SpecPath;
axis: DropAxis;
side: 'before' | 'after';
};
}
interface DragState {
sourceKey: string;
sourceNode: ViewNode;
resolution: DropResolution | null;
/** Live pointer position, for the drag chip that follows the cursor. */
pointer: { x: number; y: number };
}
function WireframeTree({ tree }: { tree: ViewNode }) {
const requestRevealView = useAppStore((s) => s.requestRevealView);
const requestComposeMove = useAppStore((s) => s.requestComposeMove);
const requestComposeWrap = useAppStore((s) => s.requestComposeWrap);
const requestComposeWrapContainer = useAppStore((s) => s.requestComposeWrapContainer);
const requestComposeSimplify = useAppStore((s) => s.requestComposeSimplify);
// Restructure only on the editable draft — the published view is read-only.
const editable = useSnippetStore((s) => s.editorView === 'draft' && s.activeSnippetId !== null);
const flat = useMemo(() => flatten(tree), [tree]);
const parentByKey = useMemo(() => new Map(flat.map((f) => [f.key, f.parent])), [flat]);
const rootKey = keyOf(tree.path);
// Redundant single-child wrappers — a `{hconcat: [oneView]}` is just that view (a
// layer/concat of one is the same). Flagged on the boxes and offered up for a one-click
// Simplify. (facet/repeat hold one child by design, so they're never array-compositions.)
const degenerateKeys = useMemo(() => {
const keys = new Set<string>();
for (const { node, key } of flat) {
if (node.op && ARRAY_COMPOSITIONS.includes(node.op) && node.children.length === 1)
keys.add(key);
}
return keys;
}, [flat]);
const [selectedKey, setSelectedKey] = useState<string | null>(null);
const [focusedKey, setFocusedKey] = useState<string | null>(null);
const [hoverKey, setHoverKey] = useState<string | null>(null);
const [drag, setDrag] = useState<DragState | null>(null);
const [announcement, setAnnouncement] = useState('');
// The box to focus once the tree rebuilds after a restructure (a drag leaves focus
// on the body; keyboard needs focus to follow the view to its new place — APG).
const [pendingFocusKey, setPendingFocusKey] = useState<string | null>(null);
const treeRef = useRef<HTMLDivElement>(null);
const dragRef = useRef<DragState | null>(null);
const justDraggedRef = useRef(false);
const setDragState = (d: DragState | null) => {
dragRef.current = d;
setDrag(d);
};
const effectiveFocus =
(focusedKey && flat.some((f) => f.key === focusedKey) && focusedKey) || rootKey;
const select = useCallback(
(n: ViewNode) => {
setSelectedKey(keyOf(n.path));
setFocusedKey(keyOf(n.path));
requestRevealView(n.offset, n.length);
},
[requestRevealView],
);
// Select, focus-after-rebuild, and announce the box at `key` once a restructure
// has been requested (the tree rebuilds from the edited draft text).
const settleOn = useCallback((key: string, message: string) => {
setSelectedKey(key);
setFocusedKey(key);
setPendingFocusKey(key);
setAnnouncement(message);
}, []);
const reorder = useCallback(
(arrayPath: SpecPath, from: number, to: number, node: ViewNode, count: number) => {
requestComposeMove(arrayPath, from, to);
settleOn(
keyOf([...arrayPath, to]),
`Moved ${descriptor(node)} to position ${to + 1} of ${count}`,
);
},
[requestComposeMove, settleOn],
);
// Restore focus to the affected box once it re-renders at its new position.
useEffect(() => {
if (!pendingFocusKey) return;
const el = treeRef.current?.querySelector<HTMLElement>(`[data-key="${pendingFocusKey}"]`);
if (el) {
el.focus();
setPendingFocusKey(null);
}
}, [flat, pendingFocusKey]);
// Rect of the box for `key`, read live from the DOM during a drag.
const rectOf = useCallback((key: string): DOMRect | null => {
const el = treeRef.current?.querySelector<HTMLElement>(`[data-key="${key}"]`);
return el ? el.getBoundingClientRect() : null;
}, []);
// The drop target: descend through concats (row/column) into the child under the
// pointer; stop at a leaf or an opaque container (layer/facet/repeat/grid). The
// dragged box is skipped so the pointer never targets it or its subtree.
const dropTargetNode = useCallback(
(x: number, y: number, sourceKey: string): ViewNode | null => {
let node = tree;
while (node.kind === 'composition' && node.orientation && DESCENDABLE.has(node.orientation)) {
const child = node.children.find((c) => {
if (keyOf(c.path) === sourceKey) return false;
const r = rectOf(keyOf(c.path));
return r ? x >= r.left && x <= r.right && y >= r.top && y <= r.bottom : false;
});
if (!child) break;
node = child;
}
return keyOf(node.path) === sourceKey ? null : node;
},
[tree, rectOf],
);
// TODO: the drag-math cluster (edgeOf + dropTargetNode + this zone classifier) is pure
// decision logic — given pointer, child rects, orientation and paths it returns a commit or
// null — but lives in the component, tested only through happy-dom rect stubs. Lift it into a
// core function tested without a DOM when a second draggable surface appears (eng-council).
const resolveDrop = useCallback(
(x: number, y: number, source: ViewNode, pair: boolean): DropResolution | null => {
const sourcePath = source.path;
const target = dropTargetNode(x, y, keyOf(sourcePath));
if (!target) return null;
// The row/column container we're acting within, and the child under the pointer
// (null when the pointer fell in the container's margin or an inter-child gap).
const targetIsRowCol =
target.kind === 'composition' &&
!!target.orientation &&
DESCENDABLE.has(target.orientation);
const container = targetIsRowCol ? target : (parentByKey.get(keyOf(target.path)) ?? null);
const childUnder = targetIsRowCol ? null : target;
// Inside a row/column, intent is read against the children's bounding box: reorder
// is the default for the interior central band (so a drag along the block just
// rearranges it); a pull-out needs the pointer past the children on the *cross* axis
// (the frame margin or beyond the block); dropping onto a view's *far cross edge*
// pairs the two into a nested split. (Opaque layer/grid contexts keep the
// nearest-edge model further down.)
if (container?.orientation && DESCENDABLE.has(container.orientation) && container.op) {
const horizontal = container.orientation === 'horizontal';
const arrayPath: SpecPath = [...container.path, container.op];
const kids = container.children
.map((c) => ({ node: c, rect: rectOf(keyOf(c.path)) }))
.filter((k): k is { node: ViewNode; rect: DOMRect } => k.rect != null);
if (!kids.length) return null;
const bbox = {
left: Math.min(...kids.map((k) => k.rect.left)),
top: Math.min(...kids.map((k) => k.rect.top)),
right: Math.max(...kids.map((k) => k.rect.right)),
bottom: Math.max(...kids.map((k) => k.rect.bottom)),
};
// 1) Pull-out — pointer past the children on the cross axis. Pulling a node into
// its own descendant is the one degenerate case.
const before = horizontal ? y < bbox.top : x < bbox.left;
const after = horizontal ? y > bbox.bottom : x > bbox.right;
if (before || after) {
if (isPrefixPath(sourcePath, container.path)) return null;
const edge: Edge = horizontal ? (before ? 'top' : 'bottom') : before ? 'left' : 'right';
return {
targetKey: keyOf(container.path),
edge,
mode: 'pull',
label: pullLabel(edge),
commit: {
kind: 'wrap-container',
containerPath: container.path,
sourcePath,
axis: horizontal ? 'vertical' : 'horizontal',
side: before ? 'before' : 'after',
},
};
}
// 2) Pair — drop onto a view's far cross edge (a row view's top/bottom, a column
// view's left/right) to nest the two in a perpendicular split; Shift pairs from
// the central band too. The cross half picks the order.
if (childUnder) {
const cr = rectOf(keyOf(childUnder.path));
const related =
isPrefixPath(sourcePath, childUnder.path) || isPrefixPath(childUnder.path, sourcePath);
if (cr && !related) {
const cf = horizontal ? (y - cr.top) / cr.height : (x - cr.left) / cr.width;
if (pair || cf < PAIR_BAND || cf > 1 - PAIR_BAND) {
const lead = cf < 0.5;
const axis: DropAxis = horizontal ? 'vertical' : 'horizontal';
const edge: Edge = horizontal ? (lead ? 'top' : 'bottom') : lead ? 'left' : 'right';
return {
targetKey: keyOf(childUnder.path),
edge,
mode: 'wrap',
label: `Pair into a ${axis === 'horizontal' ? 'row' : 'column'}`,
commit: {
kind: 'wrap',
targetPath: childUnder.path,
sourcePath,
axis,
side: lead ? 'before' : 'after',
},
};
}
}
}
// 3) Reorder (default) — the insertion slot from the pointer's main-axis position,
// anchored on the child whose edge marks the gap.
const mainPos = horizontal ? x : y;
let index = 0;
for (const k of kids) {
const center = horizontal
? (k.rect.left + k.rect.right) / 2
: (k.rect.top + k.rect.bottom) / 2;
if (mainPos > center) index += 1;
}
const atEnd = index >= kids.length;
const anchor = atEnd ? kids[kids.length - 1].node : kids[index].node;
const edge: Edge = horizontal ? (atEnd ? 'right' : 'left') : atEnd ? 'bottom' : 'top';
if (keyOf(sourcePath.slice(0, -1)) === keyOf(arrayPath)) {
const from = sourcePath[sourcePath.length - 1] as number;
let to = index > from ? index - 1 : index;
to = Math.max(0, Math.min(to, container.children.length - 1));
if (to === from) return null; // no-op
return {
targetKey: keyOf(anchor.path),
edge,
mode: 'move',
label: 'Reorder',
commit: { kind: 'move', arrayPath, from, to },
};
}
// From another container → insert here, as a with-axis wrap that flattens in.
if (isPrefixPath(sourcePath, anchor.path) || isPrefixPath(anchor.path, sourcePath))
return null;
return {
targetKey: keyOf(anchor.path),
edge,
mode: 'move',
label: 'Move here',
commit: {
kind: 'wrap',
targetPath: anchor.path,
sourcePath,
axis: horizontal ? 'horizontal' : 'vertical',
side: atEnd ? 'after' : 'before',
},
};
}
// Opaque container (layer/grid) or root: the simpler nearest-edge model.
const targetPath = target.path;
if (targetPath.length === 0) return null;
if (isPrefixPath(sourcePath, targetPath) || isPrefixPath(targetPath, sourcePath)) return null;
const r = rectOf(keyOf(targetPath));
if (!r) return null;
const edge = edgeOf(r, x, y);
const axis: DropAxis = edge === 'left' || edge === 'right' ? 'horizontal' : 'vertical';
const side: 'before' | 'after' = edge === 'left' || edge === 'top' ? 'before' : 'after';
const parent = parentByKey.get(keyOf(targetPath));
const tIdx = targetPath[targetPath.length - 1];
const along =
(axis === 'horizontal' && parent?.orientation === 'horizontal') ||
(axis === 'vertical' && parent?.orientation === 'vertical');
if (along && parent && typeof tIdx === 'number') {
const arrayPath = targetPath.slice(0, -1);
if (keyOf(sourcePath.slice(0, -1)) === keyOf(arrayPath)) {
const from = sourcePath[sourcePath.length - 1] as number;
const gap = side === 'before' ? tIdx : tIdx + 1;
let to = gap > from ? gap - 1 : gap;
to = Math.max(0, Math.min(to, parent.children.length - 1));
if (to === from) return null; // no-op
return {
targetKey: keyOf(targetPath),
edge,
mode: 'move',
label: 'Reorder',
commit: { kind: 'move', arrayPath, from, to },
};
}
}
return {
targetKey: keyOf(targetPath),
edge,
mode: 'wrap',
label: along ? 'Move here' : `Pair into a ${axis === 'horizontal' ? 'row' : 'column'}`,
commit: { kind: 'wrap', targetPath, sourcePath, axis, side },
};
},
[dropTargetNode, parentByKey, rectOf],
);
const commitDrop = useCallback(
(res: DropResolution, source: ViewNode) => {
if (res.commit.kind === 'move') {
const { arrayPath, from, to } = res.commit;
const count = parentByKey.get(res.targetKey)?.children.length ?? 0;
reorder(arrayPath, from, to, source, count);
} else if (res.commit.kind === 'wrap-container') {
const { containerPath, sourcePath, axis, side } = res.commit;
requestComposeWrapContainer(containerPath, sourcePath, axis, side);
// Focus/selection lands on the container's slot — now the new split holding it.
settleOn(
keyOf(containerPath),
`Pulled ${descriptor(source)} into a new ${axis === 'vertical' ? 'row' : 'column'}`,
);
} else {
const { targetPath, sourcePath, axis, side } = res.commit;
requestComposeWrap(targetPath, sourcePath, axis, side);
// Focus/selection lands on the new split at the target's slot. When the drop
// flattens to a plain insert (a with-axis cross-container drop), the target's
// path shifts, so this key no longer resolves and the focus-restore no-ops — a
// tolerated gap while cross-container wrap is pointer-only (focus matters for the
// keyboard path, which is in-container `Alt+↑/↓` reorder, where the key is exact).
settleOn(
keyOf(targetPath),
`Paired ${descriptor(source)} into a ${axis === 'horizontal' ? 'row' : 'column'}`,
);
}
},
[parentByKey, reorder, requestComposeWrap, requestComposeWrapContainer, settleOn],
);
const beginDrag = (e: ReactPointerEvent<HTMLDivElement>, source: ViewNode) => {
if (e.button !== 0 || !editable) return;
// Only the innermost view under the pointer starts the drag: every nested frame is
// itself draggable, and without this the pointerdown bubbles to each ancestor, whose
// beginDrag runs *after* (bubble order) and overwrites the source — so grabbing a
// child would drag its outer block instead.
e.stopPropagation();
const startX = e.clientX;
const startY = e.clientY;
let started = false;
// The last pointer + Shift state, so a Shift press/release re-resolves in place
// (Shift forces a pair when the pointer is over a view's central band).
let last = { x: startX, y: startY, pair: e.shiftKey };
const resolveAt = () => {
setDragState({
sourceKey: keyOf(source.path),
sourceNode: source,
resolution: resolveDrop(last.x, last.y, source, last.pair),
pointer: { x: last.x, y: last.y },
});
};
const onMove = (ev: PointerEvent) => {
if (!started) {
if (Math.hypot(ev.clientX - startX, ev.clientY - startY) < DRAG_THRESHOLD) return;
started = true;
document.body.style.cursor = 'grabbing';
document.body.style.userSelect = 'none';
}
last = { x: ev.clientX, y: ev.clientY, pair: ev.shiftKey };
resolveAt();
};
const onShift = (ev: WindowEventMap['keydown']) => {
if (started && ev.key === 'Shift') {
last = { ...last, pair: ev.type === 'keydown' };
resolveAt();
}
};
const cleanup = () => {
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onUp);
window.removeEventListener('pointercancel', onCancel);
window.removeEventListener('keydown', onShift);
window.removeEventListener('keyup', onShift);
document.body.style.cursor = '';
document.body.style.userSelect = '';
};
const onUp = () => {
const d = dragRef.current;
cleanup();
setDragState(null);
if (started) {
justDraggedRef.current = true; // swallow the click that follows a real drag
if (d?.resolution) commitDrop(d.resolution, d.sourceNode);
}
};
// A touch the browser reclaims for scrolling fires pointercancel — abort cleanly.
const onCancel = () => {
cleanup();
setDragState(null);
};
window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', onUp);
window.addEventListener('pointercancel', onCancel);
window.addEventListener('keydown', onShift);
window.addEventListener('keyup', onShift);
};
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
const i = flat.findIndex((f) => f.key === effectiveFocus);
if (i < 0) return;
// Alt+↑/↓ reorders the focused view among its siblings (APG rearrangeable list).
if (e.altKey && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) {
e.preventDefault();
const f = flat[i];
const idx = f.node.path.at(-1);
if (!editable || !f.parent || typeof idx !== 'number') return;
const count = f.parent.children.length;
const to = e.key === 'ArrowUp' ? idx - 1 : idx + 1;
if (to < 0 || to >= count) {
setAnnouncement(e.key === 'ArrowUp' ? 'Already at the start' : 'Already at the end');
return;
}
reorder(f.node.path.slice(0, -1), idx, to, f.node, count);
return;
}
const moveTo = (j: number) => {
const target = flat[j];
if (!target) return;
e.preventDefault();
setFocusedKey(target.key);
treeRef.current?.querySelector<HTMLElement>(`[data-key="${target.key}"]`)?.focus();
};
switch (e.key) {
case 'ArrowDown':
return moveTo(i + 1);
case 'ArrowUp':
return moveTo(i - 1);
case 'Home':
return moveTo(0);
case 'End':
return moveTo(flat.length - 1);
case 'ArrowRight': // first child is the next node in pre-order
return flat[i].node.children.length ? moveTo(i + 1) : undefined;
case 'ArrowLeft': {
const pk = flat[i].parentKey;
return pk ? moveTo(flat.findIndex((f) => f.key === pk)) : undefined;
}
case 'Enter':
case ' ':
e.preventDefault();
return select(flat[i].node);
}
};
const renderNode = (n: ViewNode, parent: ViewNode | null): ReactNode => {
const key = keyOf(n.path);
const container = n.kind === 'composition';
const generated = n.op === 'facet' || n.op === 'repeat';
const idx = n.path.at(-1);
const draggable = editable && parent != null && typeof idx === 'number';
const onTarget = drag?.resolution?.targetKey === key ? drag.resolution : null;
// A unit view inside a layer is a bare mark glyph in the layer's shared frame,
// not its own box — a layer is one plotting space with several marks stacked.
const compactMark = parent?.op === 'layer' && !container;
const cls = [
styles.node,
compactMark ? styles.layerMark : container ? styles.container : styles.leaf,
n.op === 'layer' ? styles.layerBox : '',
key === selectedKey ? styles.selected : '',
generated ? styles.generated : '',
]
.filter(Boolean)
.join(' ');
return (
<div
key={key}
data-key={key}
role="treeitem"
aria-label={ariaLabelOf(n)}
aria-selected={key === selectedKey}
aria-expanded={container ? true : undefined}
aria-keyshortcuts={draggable ? 'Alt+ArrowUp Alt+ArrowDown' : undefined}
tabIndex={key === effectiveFocus ? 0 : -1}
className={cls}
data-draggable={draggable || undefined}
data-dragging={drag?.sourceKey === key || undefined}
data-orientation={n.orientation}
data-degenerate={degenerateKeys.has(key) || undefined}
data-drop-edge={onTarget && onTarget.mode !== 'pull' ? onTarget.edge : undefined}
data-drop-mode={onTarget && onTarget.mode !== 'pull' ? onTarget.mode : undefined}
data-drop-pull={onTarget?.mode === 'pull' ? onTarget.edge : undefined}
onPointerDown={draggable ? (e) => beginDrag(e, n) : undefined}
onClick={(e) => {
e.stopPropagation();
if (justDraggedRef.current) {
justDraggedRef.current = false;
return;
}
select(n);
}}
onMouseEnter={(e) => {
e.stopPropagation();
setHoverKey(key);
}}
onMouseLeave={() => setHoverKey(null)}
onFocus={(e) => {
e.stopPropagation();
setFocusedKey(key);
}}
>
{container ? (
<>
{n.op === 'layer' && <Icon name="layers" className={styles.layerBadge} />}
<div
role="group"
data-orientation={n.orientation}
className={`${styles.children} ${n.orientation ? LAYOUT[n.orientation] : ''}`}
>
{n.children.map((c) => renderNode(c, n))}
</div>
</>
) : (
<Icon name={markIconName(n.mark)} size="md" className={styles.markIcon} />
)}
</div>
);
};
const captionNode =
(hoverKey ?? selectedKey) ? flat.find((f) => f.key === (hoverKey ?? selectedKey))?.node : null;
return (
<>
<div
ref={treeRef}
role="tree"
aria-label="View composition"
className={styles.tree}
data-drag-active={drag ? '' : undefined}
onKeyDown={onKeyDown}
>
{renderNode(tree, null)}
</div>
{editable && degenerateKeys.size > 0 && (
<div className={styles.warning}>
<span>
{degenerateKeys.size === 1
? 'A single-view wrapper adds no structure.'
: `${degenerateKeys.size} single-view wrappers add no structure.`}
</span>
<button
type="button"
className={styles.simplify}
onClick={() => requestComposeSimplify()}
>
Simplify
</button>
</div>
)}
<p className={styles.caption} aria-hidden="true">
{captionNode ? (
<>
<code>{pathLabel(captionNode.path)}</code> {descriptor(captionNode)}
</>
) : (
<span className={styles.muted}>
{editable
? 'Drag to reorder · onto a views edge to stack · into a frames margin to pull out · click to reveal'
: 'Hover a block to identify it · click to reveal it'}
</span>
)}
</p>
<div className="visually-hidden" role="status" aria-live="polite">
{announcement}
</div>
{/* A chip following the cursor names what the drop will do the live "what
happens" hint (pointer-only affordance, so aria-hidden; SR users get the
commit announcement above). Portaled out of the clipped popover. */}
{drag &&
createPortal(
<div
className={styles.dragChip}
data-empty={drag.resolution ? undefined : ''}
style={{ left: drag.pointer.x + 14, top: drag.pointer.y + 16 }}
aria-hidden="true"
>
{drag.resolution?.label ?? 'No change here'}
</div>,
document.body,
)}
</>
);
}
export function CompositionWireframe() {
const { open, toggle, close, triggerRef, setPopNode } = usePopover({
id: POPOVER_ID,
align: 'right',
flip: true,
initialFocus: INITIAL_FOCUS,
});
const shownText = useSnippetStore(selectShownText);
const tree = useMemo(() => viewTree(shownText), [shownText]);
const hasComposition = !!tree && tree.kind === 'composition';
// The wireframe is meaningless for a single-view spec — hide the affordance, and
// close it if it was open when the composition is unwrapped away.
useEffect(() => {
if (!hasComposition && open) close();
}, [hasComposition, open, close]);
if (!tree || tree.kind !== 'composition') return null;
return (
<div className={styles.wrap}>
<IconButton
ref={triggerRef}
label="View composition structure"
aria-expanded={open}
aria-controls={POPOVER_ID}
onClick={toggle}
>
<Icon name="structure" />
</IconButton>
{open &&
createPortal(
<div
ref={setPopNode}
id={POPOVER_ID}
className={styles.pop}
role="group"
aria-label="Composition structure"
>
<h4 className={styles.title}>Structure</h4>
<WireframeTree tree={tree} />
</div>,
document.body,
)}
</div>
);
}
+87 -16
View File
@@ -32,12 +32,38 @@ afterEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
}); });
const toggle = () => container.querySelector<HTMLButtonElement>('button[aria-expanded]')!; const toggle = () =>
Array.from(container.querySelectorAll<HTMLButtonElement>('button[aria-expanded]')).find((b) =>
b.textContent?.includes('Data'),
)!;
const text = () => container.textContent ?? ''; const text = () => container.textContent ?? '';
const viewButton = (label: string) => const viewButton = (label: string) =>
Array.from(container.querySelectorAll<HTMLButtonElement>('[role="radio"]')).find( Array.from(container.querySelectorAll<HTMLButtonElement>('[role="radio"]')).find(
(b) => b.textContent === label, (b) => b.textContent === label,
)!; )!;
/** The view picker's trigger, or null when it is not shown (single-table case). */
const picker = () => container.querySelector<HTMLButtonElement>('[aria-label^="Inspected view"]');
/** Build the inspector payload — one table per (label, input, resolved) entry. */
const tablesOf = (
...entries: Array<{
label: string;
input?: Record<string, unknown>[];
resolved: Record<string, unknown>[];
}>
): InspectedData => ({
tables: entries.map((e, i) => ({
id: `t${i}`,
label: e.label,
input: e.input ?? [],
resolved: e.resolved,
})),
});
/** The common single-table payload. */
const oneTable = (
input: Record<string, unknown>[],
resolved: Record<string, unknown>[],
): InspectedData => tablesOf({ label: 'View 1', input, resolved });
const render = (props: Partial<Parameters<typeof DataInspectorPanel>[0]> = {}) => const render = (props: Partial<Parameters<typeof DataInspectorPanel>[0]> = {}) =>
act(() => { act(() => {
@@ -68,26 +94,33 @@ describe('DataInspectorPanel', () => {
expect(container.querySelector('table')).toBeNull(); expect(container.querySelector('table')).toBeNull();
}); });
test('open with a chart that draws nothing inspectable: says so', () => {
render({ getData: () => ({ tables: [] }) });
expect(text()).toContain('no inspectable data');
expect(container.querySelector('table')).toBeNull();
});
test('defaults to the Resolved view and renders its rows', () => { test('defaults to the Resolved view and renders its rows', () => {
const data: InspectedData = { render({
input: [{ region: 'West', sales: '1204' }], getData: () =>
resolved: [{ region: 'West', total: 1204 }], oneTable([{ region: 'West', sales: '1204' }], [{ region: 'West', total: 1204 }]),
}; });
render({ getData: () => data });
// Resolved is the default — its column ("total"), not the input's ("sales"). // Resolved is the default — its column ("total"), not the input's ("sales").
const headers = Array.from(container.querySelectorAll('th')).map((th) => th.textContent); const headers = Array.from(container.querySelectorAll('th')).map((th) => th.textContent);
expect(headers).toEqual(['region', 'total']); expect(headers).toEqual(['region', 'total']);
}); });
test('switching to Input shows the source rows', () => { test('switching to Input shows the source rows', () => {
const data: InspectedData = { render({
input: [ getData: () =>
oneTable(
[
{ region: 'West', sales: '1204' }, { region: 'West', sales: '1204' },
{ region: 'East', sales: '980' }, { region: 'East', sales: '980' },
], ],
resolved: [{ region: 'West', total: 1204 }], [{ region: 'West', total: 1204 }],
}; ),
render({ getData: () => data }); });
act(() => viewButton('Input').click()); act(() => viewButton('Input').click());
const headers = Array.from(container.querySelectorAll('th')).map((th) => th.textContent); const headers = Array.from(container.querySelectorAll('th')).map((th) => th.textContent);
expect(headers).toEqual(['region', 'sales']); expect(headers).toEqual(['region', 'sales']);
@@ -96,25 +129,25 @@ describe('DataInspectorPanel', () => {
}); });
test('resolved empty: names the empty-transform signal', () => { test('resolved empty: names the empty-transform signal', () => {
render({ getData: () => ({ input: [{ a: 1 }], resolved: [] }) }); render({ getData: () => oneTable([{ a: 1 }], []) });
expect(text()).toContain('left nothing to draw'); expect(text()).toContain('left nothing to draw');
}); });
test('input empty: names the empty source', () => { test('input empty: names the empty source', () => {
render({ getData: () => ({ input: [], resolved: [] }) }); render({ getData: () => oneTable([], []) });
act(() => viewButton('Input').click()); act(() => viewButton('Input').click());
expect(text()).toContain('source data has no rows'); expect(text()).toContain('source data has no rows');
}); });
test('caps the table and reports the total', () => { test('caps the table and reports the total', () => {
const resolved = Array.from({ length: 120 }, (_, i) => ({ i })); const resolved = Array.from({ length: 120 }, (_, i) => ({ i }));
render({ getData: () => ({ input: [], resolved }) }); render({ getData: () => oneTable([], resolved) });
expect(container.querySelectorAll('tbody tr')).toHaveLength(50); expect(container.querySelectorAll('tbody tr')).toHaveLength(50);
expect(text()).toContain('first 50 of 120'); expect(text()).toContain('first 50 of 120');
}); });
test('re-reads getData when renderEpoch changes', () => { test('re-reads getData when renderEpoch changes', () => {
const getData = vi.fn((): InspectedData => ({ input: [], resolved: [{ a: 1 }] })); const getData = vi.fn(() => oneTable([], [{ a: 1 }]));
render({ getData, renderEpoch: 0 }); render({ getData, renderEpoch: 0 });
const before = getData.mock.calls.length; const before = getData.mock.calls.length;
render({ getData, renderEpoch: 1 }); render({ getData, renderEpoch: 1 });
@@ -122,7 +155,7 @@ describe('DataInspectorPanel', () => {
}); });
test('applies an explicit height when given (resizable mode)', () => { test('applies an explicit height when given (resizable mode)', () => {
render({ getData: () => ({ input: [], resolved: [{ a: 1 }] }), heightPx: 240 }); render({ getData: () => oneTable([], [{ a: 1 }]), heightPx: 240 });
const panel = container.firstElementChild as HTMLElement; const panel = container.firstElementChild as HTMLElement;
expect(panel.style.height).toBe('240px'); expect(panel.style.height).toBe('240px');
}); });
@@ -133,6 +166,44 @@ describe('DataInspectorPanel', () => {
act(() => toggle().click()); act(() => toggle().click());
expect(onToggle).toHaveBeenCalledWith(true); expect(onToggle).toHaveBeenCalledWith(true);
}); });
// ── Multi-view selector ──────────────────────────────────────────────────────
test('a single drawn table shows no view picker', () => {
render({ getData: () => oneTable([], [{ a: 1 }]) });
expect(picker()).toBeNull();
});
test('multiple drawn tables show a picker, defaulting to the first table', () => {
render({
getData: () =>
tablesOf(
{ label: 'sales', resolved: [{ region: 'W', revenue: 1 }] },
{ label: 'regions', resolved: [{ region: 'W', population: 2 }] },
),
});
// Picker present and on the first table; the grid shows that table's columns.
expect(picker()?.getAttribute('aria-label')).toBe('Inspected view: sales');
const headers = Array.from(container.querySelectorAll('th')).map((th) => th.textContent);
expect(headers).toEqual(['region', 'revenue']);
});
test('choosing another table switches the inspected data', () => {
render({
getData: () =>
tablesOf(
{ label: 'sales', resolved: [{ region: 'W', revenue: 1 }] },
{ label: 'regions', resolved: [{ region: 'W', population: 2 }] },
),
});
act(() => picker()!.click()); // open the portaled popover
const option = Array.from(document.querySelectorAll<HTMLButtonElement>('button')).find((b) =>
b.textContent?.includes('regions'),
)!;
act(() => option.click());
const headers = Array.from(container.querySelectorAll('th')).map((th) => th.textContent);
expect(headers).toEqual(['region', 'population']);
});
}); });
describe('DataInspector', () => { describe('DataInspector', () => {
+72 -24
View File
@@ -1,46 +1,67 @@
/** /**
* Data inspector input vs. resolved rows (spec §04). * Data inspector input vs. resolved rows, per drawn table (spec §04).
* *
* Shows the chart's data with a toggle between two views of the same rendered * Shows the chart's data with a toggle between two ends of a table's pipeline:
* view: **Input** (the parsed source rows, before the spec's transforms) and * **Input** (the parsed source rows, before the view's transforms) and
* **Resolved** (the rows the chart draws, after filters / calculated fields / * **Resolved** (the rows the marks draw, after filters / calculated fields /
* aggregation). Seeing input output side by side is how you answer "why is my * aggregation). Seeing input output is how you answer "why is my chart
* chart empty/wrong" look at what the transforms did to the data. Both tables * empty/wrong" look at what the transforms did to the data.
* come from the live Vega view via the renderer's `RenderHandle.inspectData()` *
* accessor, passed in as `getData` so this component never touches the view (the * A composed spec (layer/concat/facet/repeat) draws several tables, so a **view
* embedding boundary, arch 05). * picker** (`SelectControl`) lets the user choose which one to inspect "what data
* am I actually visualizing?". The picker is hidden for the common single-table
* case (council: NN/g #8 no one-option control; docs/architecture/10). Labels
* never show Vega's compiler names (`source_0`/`data_2`), only a user-authored
* dataset name or an ordinal "View N" plus a columns·rows recognition cue
* (`@core/inspect-views`; NN/g #2/#6).
*
* Tables come from the live Vega view via `RenderHandle.inspectData()`, passed in as
* `getData` so this component never touches the view (the embedding boundary, arch
* 05). Read lazily only while expanded so a collapsed inspector costs nothing.
* *
* `DataInspectorPanel` is the reusable shape (an APG disclosure, mirroring the * `DataInspectorPanel` is the reusable shape (an APG disclosure, mirroring the
* builder's source-rows preview); `DataInspector` binds it to the persisted * builder's source-rows preview); `DataInspector` binds it to the persisted
* preview-pane open state for the live-preview pane. The data is read lazily * preview-pane open state for the live-preview pane.
* only while expanded because listing the view's datasets serializes them (see
* `RenderHandle.inspectData`), so a collapsed inspector costs nothing.
*/ */
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import type { InspectedData } from '../services/chart-renderer'; import type { InspectableTable, InspectedData } from '../services/chart-renderer';
import { useAppStore } from '../stores/AppStore'; import { useAppStore } from '../stores/AppStore';
import { DataTable } from './DataTable'; import { DataTable } from './DataTable';
import { SegmentedControl, type SegmentedOption } from './SegmentedControl'; import { SegmentedControl, type SegmentedOption } from './SegmentedControl';
import { SelectControl, type SelectControlOption } from './SelectControl';
import styles from './DataInspector.module.css'; import styles from './DataInspector.module.css';
/** Rows shown before truncating — matches the builder's source-preview cap (spec §06). */ /** Rows shown before truncating — matches the builder's source-preview cap (spec §06). */
const ROW_LIMIT = 50; const ROW_LIMIT = 50;
/** Columns named in a table's picker `detail` before eliding the rest. */
const DETAIL_COLUMNS = 4;
type DataView = 'input' | 'resolved'; type DataView = 'input' | 'resolved';
/** The two views, in input → output order (the natural reading direction). */ /** The two stages, in input → output order (the natural reading direction). */
const VIEW_OPTIONS: ReadonlyArray<SegmentedOption<DataView>> = [ const VIEW_OPTIONS: ReadonlyArray<SegmentedOption<DataView>> = [
// `title` doubles as the accessible name (WCAG 2.5.3 label-in-name; leads with // `title` doubles as the accessible name (WCAG 2.5.3 label-in-name; leads with
// the visible label). // the visible label).
{ value: 'input', label: 'Input', title: 'Input — the source rows before the specs transforms' }, { value: 'input', label: 'Input', title: 'Input — the source rows before the views transforms' },
{ {
value: 'resolved', value: 'resolved',
label: 'Resolved', label: 'Resolved',
title: 'Resolved — the rows the chart draws, after its transforms', title: 'Resolved — the rows the marks draw, after the views transforms',
}, },
]; ];
/** A recognition cue for the view picker: row count + the first few columns. */
function tableDetail(table: InspectableTable): string {
const rows = table.resolved;
const count = `${rows.length.toLocaleString()} ${rows.length === 1 ? 'row' : 'rows'}`;
const columns = rows[0] ? Object.keys(rows[0]) : [];
if (columns.length === 0) return count;
const shown = columns.slice(0, DETAIL_COLUMNS).join(', ');
const more = columns.length > DETAIL_COLUMNS ? `, +${columns.length - DETAIL_COLUMNS}` : '';
return `${count} · ${shown}${more}`;
}
interface DataInspectorPanelProps { interface DataInspectorPanelProps {
/** Whether the panel is expanded. */ /** Whether the panel is expanded. */
open: boolean; open: boolean;
@@ -52,7 +73,12 @@ interface DataInspectorPanelProps {
* `renderEpoch`). * `renderEpoch`).
*/ */
getData: () => InspectedData | null; getData: () => InspectedData | null;
/** Bumps whenever a render settles, so the open table re-reads the new rows. */ /**
* Refresh trigger: bumps whenever the data to show may have changed, so the open
* table re-reads. The live-preview pane bumps it on each settled render *and* on
* an interactive selection that changes the inspected rows (live mode, M5); the
* builder bumps it on render only.
*/
renderEpoch: number; renderEpoch: number;
/** /**
* Explicit panel height (px) the live-preview pane sets this from its * Explicit panel height (px) the live-preview pane sets this from its
@@ -83,13 +109,20 @@ export function DataInspectorPanel({
id, id,
}: DataInspectorPanelProps) { }: DataInspectorPanelProps) {
const [view, setView] = useState<DataView>('resolved'); const [view, setView] = useState<DataView>('resolved');
const [selectedId, setSelectedId] = useState<string | null>(null);
// Read both tables only while open; re-read when a render settles. `renderEpoch` // Read tables only while open; re-read when a render settles. `renderEpoch` is an
// is an intentional refresh trigger — not read in the body (getData is stable and // intentional refresh trigger — not read in the body (getData is stable and always
// always reads the latest view), so exhaustive-deps sees it as unnecessary. // reads the latest view), so exhaustive-deps sees it as unnecessary.
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
const data = useMemo(() => (open ? getData() : null), [open, renderEpoch, getData]); const data = useMemo(() => (open ? getData() : null), [open, renderEpoch, getData]);
const rows = data === null ? null : data[view]; const tables = data?.tables ?? null;
// The chosen table, falling back to the first when the selection is stale (the
// spec changed under it) or unset — so a re-render never lands on a missing table.
const table = tables?.find((t) => t.id === selectedId) ?? tables?.[0] ?? null;
const rows = table ? table[view] : null;
const pickerId = `${id ?? 'preview'}-view-picker`;
return ( return (
<div <div
@@ -111,8 +144,21 @@ export function DataInspectorPanel({
</button> </button>
{open && ( {open && (
<> <>
{tables && tables.length > 1 && table && (
<SelectControl
id={pickerId}
label="Inspected view"
value={table.id}
onSelect={setSelectedId}
options={tables.map<SelectControlOption<string>>((t) => ({
value: t.id,
label: t.label,
detail: tableDetail(t),
}))}
/>
)}
<SegmentedControl <SegmentedControl
label="Data view" label="Pipeline stage"
options={VIEW_OPTIONS} options={VIEW_OPTIONS}
value={view} value={view}
onChange={setView} onChange={setView}
@@ -127,12 +173,14 @@ export function DataInspectorPanel({
</div> </div>
{open && {open &&
(rows === null ? ( (data === null ? (
<p className={styles.stateNote}>Render a chart to inspect its data.</p> <p className={styles.stateNote}>Render a chart to inspect its data.</p>
) : table === null || rows === null ? (
<p className={styles.stateNote}>This chart has no inspectable data.</p>
) : rows.length === 0 ? ( ) : rows.length === 0 ? (
<p className={styles.stateNote}> <p className={styles.stateNote}>
{view === 'resolved' {view === 'resolved'
? 'No rows — the specs filters or transforms left nothing to draw.' ? 'No rows — the views filters or transforms left nothing to draw.'
: 'The source data has no rows.'} : 'The source data has no rows.'}
</p> </p>
) : ( ) : (
+3 -3
View File
@@ -1,7 +1,7 @@
/** /**
* Extract-to-Dataset the modal body (spec §03F). * Extract-to-Dataset the modal body (spec §03F).
* *
* Shows a read-only preview of the active snippet draft's inline data and asks * Shows a read-only preview of the focused view's embedded data and asks
* for a dataset name. On confirm it saves the data as a new dataset and rewrites * for a dataset name. On confirm it saves the data as a new dataset and rewrites
* the draft to reference it by name (logic in ExtractStore), then force-closes * the draft to reference it by name (logic in ExtractStore), then force-closes
* (the commit is the user's confirmation, so no discard prompt). Cancel leaves * (the commit is the user's confirmation, so no discard prompt). Cancel leaves
@@ -38,13 +38,13 @@ export function ExtractModal() {
}; };
if (!source) { if (!source) {
return <p className={styles.muted}>This snippet has no inline data to extract.</p>; return <p className={styles.muted}>This snippet has no embedded data to extract.</p>;
} }
return ( return (
<div className={styles.extract}> <div className={styles.extract}>
<p className={styles.intro}> <p className={styles.intro}>
Save this snippet&rsquo;s inline data as a reusable dataset. The spec will be rewritten to Save this snippet&rsquo;s embedded data as a reusable dataset. The spec will be rewritten to
reference it by name. reference it by name.
</p> </p>
+113
View File
@@ -34,6 +34,21 @@ export type IconName =
| 'export' // export the workspace to a file — Carbon Download (a file comes out) | 'export' // export the workspace to a file — Carbon Download (a file comes out)
| 'info' // about / information — Carbon Information (outline) | 'info' // about / information — Carbon Information (outline)
| 'revert' // revert draft to last published — Carbon Reset | 'revert' // revert draft to last published — Carbon Reset
| 'structure' // composition-structure wireframe disclosure (preview toolbar) — nested view blocks
| 'layers' // layered-composition badge (wireframe) — two stacked planes (one shared space)
// Mark sub-family (composition wireframe leaves) — a simplified glyph of a unit
// view's mark type, so which-is-which reads at a glance. Vega-Lite mark synonyms
// collapse onto these via `markIconName` (CompositionWireframe); unknown → generic.
| 'mark-bar'
| 'mark-line'
| 'mark-area'
| 'mark-point'
| 'mark-arc'
| 'mark-rect'
| 'mark-tick'
| 'mark-rule'
| 'mark-text'
| 'mark-generic'
// Pane-toggle sub-family (spec §01A): a panel frame with one region filled, so the // Pane-toggle sub-family (spec §01A): a panel frame with one region filled, so the
// glyph shows *which* pane it controls by position (left / centre / right). // glyph shows *which* pane it controls by position (left / centre / right).
| 'pane-library' // toggle the library pane (left) | 'pane-library' // toggle the library pane (left)
@@ -150,6 +165,104 @@ const GLYPHS: Record<IconName, ReactNode> = {
<rect x="21" y="10" width="4" height="12" /> <rect x="21" y="10" width="4" height="12" />
</> </>
), ),
// Composition structure: a panel frame (matching the pane family) holding nested
// view blocks — two side by side over one wide — the wireframe in miniature.
structure: (
<>
<rect x="4" y="6" width="24" height="2" />
<rect x="4" y="24" width="24" height="2" />
<rect x="4" y="6" width="2" height="20" />
<rect x="26" y="6" width="2" height="20" />
<rect x="9" y="10" width="6" height="5" />
<rect x="17" y="10" width="6" height="5" />
<rect x="9" y="17" width="14" height="5" />
</>
),
// Two offset planes — a layer is one space with several marks stacked in z-order.
layers: (
<>
<rect x="6" y="6" width="15" height="15" fill="none" stroke="currentColor" strokeWidth={2} />
<rect
x="11"
y="11"
width="15"
height="15"
fill="none"
stroke="currentColor"
strokeWidth={2}
/>
</>
),
// Mark glyphs: simplified renderings of each Vega-Lite mark, drawn on the same
// 32-grid. Stroke-based where a line reads truer than a fill (line/rule/generic).
'mark-bar': (
<>
<rect x="5" y="14" width="5" height="14" />
<rect x="13" y="8" width="5" height="20" />
<rect x="21" y="18" width="5" height="10" />
</>
),
'mark-line': (
<polyline
points="4,22 11,13 18,17 28,6"
fill="none"
stroke="currentColor"
strokeWidth={2.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
),
'mark-area': <path d="M4,28 L4,17 L12,11 L20,16 L28,7 L28,28 Z" />,
'mark-point': (
<>
<circle cx="9" cy="20" r="2.6" />
<circle cx="16" cy="12" r="2.6" />
<circle cx="22" cy="22" r="2.6" />
<circle cx="25" cy="9" r="2.6" />
</>
),
// A three-quarter pie wedge (one quadrant empty) reads as arc/pie at a glance.
'mark-arc': <path d="M16,16 L16,4 A12,12 0 1,1 4,16 Z" />,
'mark-rect': (
<>
<rect x="5" y="9" width="6" height="6" />
<rect x="13" y="9" width="6" height="6" />
<rect x="21" y="9" width="6" height="6" />
<rect x="5" y="17" width="6" height="6" />
<rect x="13" y="17" width="6" height="6" />
<rect x="21" y="17" width="6" height="6" />
</>
),
'mark-tick': (
<>
<rect x="6" y="10" width="2" height="12" />
<rect x="12" y="10" width="2" height="12" />
<rect x="18" y="10" width="2" height="12" />
<rect x="24" y="10" width="2" height="12" />
</>
),
'mark-rule': (
<line
x1="5"
y1="24"
x2="27"
y2="8"
fill="none"
stroke="currentColor"
strokeWidth={2.5}
strokeLinecap="round"
/>
),
'mark-text': (
<>
<rect x="6" y="8" width="20" height="3" />
<rect x="6" y="15" width="14" height="3" />
<rect x="6" y="22" width="18" height="3" />
</>
),
'mark-generic': (
<rect x="5" y="6" width="22" height="20" fill="none" stroke="currentColor" strokeWidth={2} />
),
moon: ( moon: (
<path d="M13.5025,5.4136A15.0755,15.0755,0,0,0,25.096,23.6082a11.1134,11.1134,0,0,1-7.9749,3.3893c-.1385,0-.2782.0051-.4178,0A11.0944,11.0944,0,0,1,13.5025,5.4136M14.98,3a1.0024,1.0024,0,0,0-.1746.0156A13.0959,13.0959,0,0,0,16.63,28.9973c.1641.006.3282,0,.4909,0a13.0724,13.0724,0,0,0,10.702-5.5556,1.0094,1.0094,0,0,0-.7833-1.5644A13.08,13.08,0,0,1,15.8892,4.38,1.0149,1.0149,0,0,0,14.98,3Z" /> <path d="M13.5025,5.4136A15.0755,15.0755,0,0,0,25.096,23.6082a11.1134,11.1134,0,0,1-7.9749,3.3893c-.1385,0-.2782.0051-.4178,0A11.0944,11.0944,0,0,1,13.5025,5.4136M14.98,3a1.0024,1.0024,0,0,0-.1746.0156A13.0959,13.0959,0,0,0,16.63,28.9973c.1641.006.3282,0,.4909,0a13.0724,13.0724,0,0,0,10.702-5.5556,1.0094,1.0094,0,0,0-.7833-1.5644A13.08,13.08,0,0,1,15.8892,4.38,1.0149,1.0149,0,0,0,14.98,3Z" />
), ),
+41 -33
View File
@@ -1,10 +1,11 @@
/** /**
* LivePreview busy overlay guard (spec §04; arch §10.2). * LivePreview busy overlay + render serialization (spec §04; arch §10.2).
* *
* The render pipeline is integration-heavy (vega-embed, IndexedDB, Monaco); the * The render pipeline is integration-heavy (vega-embed, IndexedDB, Monaco), so
* busy OVERLAY itself is purely a function of `PreviewStore.busy`. These tests * `renderSpec` is mocked to park each embed in `H.pending` a test decides when
* set that flag directly and assert the DOM result no timing, no mocking of the * embeds settle. Render status (`error`/`busy`) is the pane's own local state, so
* async render path. * the busy overlay is driven through its real path a render left in flight past
* the ~1s timer not by poking a flag.
*/ */
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
@@ -12,7 +13,6 @@ import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client'; import { createRoot, type Root } from 'react-dom/client';
import { chartConfigForSelection } from '@core/vega-themes'; import { chartConfigForSelection } from '@core/vega-themes';
import { useAppStore } from '../stores/AppStore'; import { useAppStore } from '../stores/AppStore';
import { usePreviewStore } from '../stores/PreviewStore';
import { useSnippetStore } from '../stores/SnippetStore'; import { useSnippetStore } from '../stores/SnippetStore';
import { useDatasetStore } from '../stores/DatasetStore'; import { useDatasetStore } from '../stores/DatasetStore';
import { LivePreview } from './LivePreview'; import { LivePreview } from './LivePreview';
@@ -27,10 +27,6 @@ const H = vi.hoisted(() => ({
pending: [] as Array<() => void>, pending: [] as Array<() => void>,
destroyed: [] as number[], destroyed: [] as number[],
configs: [] as unknown[], configs: [] as unknown[],
inspected: null as {
input: ReadonlyArray<Record<string, unknown>>;
resolved: ReadonlyArray<Record<string, unknown>>;
} | null,
})); }));
vi.mock('../services/chart-renderer', () => ({ vi.mock('../services/chart-renderer', () => ({
renderSpec: (node: HTMLElement, _spec: unknown, config: unknown) => { renderSpec: (node: HTMLElement, _spec: unknown, config: unknown) => {
@@ -48,7 +44,8 @@ vi.mock('../services/chart-renderer', () => ({
H.destroyed.push(id); H.destroyed.push(id);
}, },
resize() {}, resize() {},
inspectData: () => H.inspected, inspectData: () => null,
onDataChange: () => () => {},
}); });
}); });
}); });
@@ -74,7 +71,6 @@ beforeEach(() => {
H.pending.length = 0; H.pending.length = 0;
H.destroyed.length = 0; H.destroyed.length = 0;
H.configs.length = 0; H.configs.length = 0;
usePreviewStore.setState({ error: null, busy: false });
useSnippetStore.getState().reset(); useSnippetStore.getState().reset();
useDatasetStore.getState().reset(); useDatasetStore.getState().reset();
@@ -87,11 +83,12 @@ beforeEach(() => {
afterEach(() => { afterEach(() => {
act(() => root.unmount()); act(() => root.unmount());
container.remove(); container.remove();
usePreviewStore.setState({ error: null, busy: false });
vi.clearAllMocks(); vi.clearAllMocks();
}); });
describe('LivePreview busy overlay', () => { describe('LivePreview busy overlay', () => {
const tick = (ms = 6000) => act(async () => void (await vi.advanceTimersByTimeAsync(ms)));
// The overlay is the aria-hidden element carrying the "Rendering…" label — a // The overlay is the aria-hidden element carrying the "Rendering…" label — a
// bare [aria-hidden] query would also match decorative bits of the header // bare [aria-hidden] query would also match decorative bits of the header
// controls (e.g. the chart-theme select's caret). // controls (e.g. the chart-theme select's caret).
@@ -100,33 +97,44 @@ describe('LivePreview busy overlay', () => {
/rendering/i.test(el.textContent ?? ''), /rendering/i.test(el.textContent ?? ''),
) ?? null; ) ?? null;
test('does not render the busy overlay when busy=false', () => { // Start a render and leave it parked in `H.pending`; advancing past the debounce
// The overlay element should not be in the DOM at all during normal operation. // and the ~1s busy timer flips `busy` on — the real (and only) path now that it
// is local state.
const startSlowRender = async () => {
act(() => {
useSnippetStore.setState({ draftText: '{"data":{"values":[]},"mark":"point"}' });
});
await tick();
};
test('no overlay and no aria-busy before a render is in flight', () => {
expect(overlay()).toBeNull(); expect(overlay()).toBeNull();
});
test('renders the busy overlay when PreviewStore.busy=true', () => {
act(() => usePreviewStore.setState({ busy: true }));
expect(overlay()).not.toBeNull();
});
test('the preview body carries aria-busy=true when busy', () => {
act(() => usePreviewStore.setState({ busy: true }));
// The body element has aria-busy when the store says busy.
const busyEl = container.querySelector('[aria-busy="true"]');
expect(busyEl).not.toBeNull();
});
test('aria-busy is absent when busy=false (no aria-busy="false" noise)', () => {
// aria-busy="false" is technically valid but needlessly verbose; we omit it. // aria-busy="false" is technically valid but needlessly verbose; we omit it.
expect(container.querySelector('[aria-busy]')).toBeNull(); expect(container.querySelector('[aria-busy]')).toBeNull();
}); });
test('overlay disappears when busy returns to false', () => { test('a render in flight past ~1s shows the overlay and sets aria-busy', async () => {
act(() => usePreviewStore.setState({ busy: true })); vi.useFakeTimers();
try {
await startSlowRender();
expect(overlay()).not.toBeNull(); expect(overlay()).not.toBeNull();
act(() => usePreviewStore.setState({ busy: false })); expect(container.querySelector('[aria-busy="true"]')).not.toBeNull();
} finally {
vi.useRealTimers();
}
});
test('the overlay clears once the render settles', async () => {
vi.useFakeTimers();
try {
await startSlowRender();
expect(overlay()).not.toBeNull();
act(() => H.pending[0]()); // the parked embed resolves → busy cleared on settle
await tick(0);
expect(overlay()).toBeNull(); expect(overlay()).toBeNull();
} finally {
vi.useRealTimers();
}
}); });
}); });
+62 -28
View File
@@ -7,9 +7,10 @@
* mode applied) renderSpec (vega-embed). A render-generation token guards * mode applied) renderSpec (vega-embed). A render-generation token guards
* against a slow render resolving after a newer one. * against a slow render resolving after a newer one.
* *
* The pane header carries the Fit control (4 sizing modes, §04). Render errors * The pane header carries the Fit control (4 sizing modes, §04). A render or
* are published to the shared PreviewStore so the editor pane mirrors them * parse error shows here in place of the chart (error xor chart) its single
* (§03E); the preview shows the same message in place of the chart. * message home (arch 10 §1); the editor pinpoints the cause with a squiggle
* (§03E) rather than repeating the text.
* *
* M2 scope: inline-data specs, all four fit modes. Dataset reference resolution * M2 scope: inline-data specs, all four fit modes. Dataset reference resolution
* (M3) plugs into prepareSpecForRender without changing this component. * (M3) plugs into prepareSpecForRender without changing this component.
@@ -22,6 +23,7 @@ import type { Config } from 'vega-lite';
import { referencedUploadedFonts } from '@core/chart-export'; import { referencedUploadedFonts } from '@core/chart-export';
import type { FitMode } from '@core/rendering'; import type { FitMode } from '@core/rendering';
import { DatasetNotFoundError, prepareSpecForRender } from '@core/rendering'; import { DatasetNotFoundError, prepareSpecForRender } from '@core/rendering';
import { firstExpressionError } from '@core/spec-expressions';
import { import {
chartConfigForSelection, chartConfigForSelection,
chartThemeOptions, chartThemeOptions,
@@ -33,10 +35,10 @@ import { useAppStore } from '../stores/AppStore';
import { useCustomThemeStore } from '../stores/CustomThemeStore'; import { useCustomThemeStore } from '../stores/CustomThemeStore';
import { useDatasetStore } from '../stores/DatasetStore'; import { useDatasetStore } from '../stores/DatasetStore';
import { useFontStore } from '../stores/FontStore'; import { useFontStore } from '../stores/FontStore';
import { usePreviewStore } from '../stores/PreviewStore';
import { selectShownText, useSnippetStore } from '../stores/SnippetStore'; import { selectShownText, useSnippetStore } from '../stores/SnippetStore';
import { useUserSettingsStore } from '../stores/UserSettingsStore'; import { useUserSettingsStore } from '../stores/UserSettingsStore';
import { ChartExport } from './ChartExport'; import { ChartExport } from './ChartExport';
import { CompositionWireframe } from './CompositionWireframe';
import { DataInspector } from './DataInspector'; import { DataInspector } from './DataInspector';
import { InspectorSplitHandle } from './InspectorSplitHandle'; import { InspectorSplitHandle } from './InspectorSplitHandle';
import { SegmentedControl, type SegmentedOption } from './SegmentedControl'; import { SegmentedControl, type SegmentedOption } from './SegmentedControl';
@@ -197,10 +199,11 @@ export function LivePreview() {
const renderDebounce = useUserSettingsStore((s) => s.saved.performance.renderDebounce); const renderDebounce = useUserSettingsStore((s) => s.saved.performance.renderDebounce);
// Seed with a sentinel epoch so the very first paint counts as a load (immediate). // Seed with a sentinel epoch so the very first paint counts as a load (immediate).
const lastLoadRef = useRef({ bufferEpoch: -1, editorView }); const lastLoadRef = useRef({ bufferEpoch: -1, editorView });
const error = usePreviewStore((s) => s.error); // Render status is local to this pane — it is both the only producer and the
const setError = usePreviewStore((s) => s.setError); // only consumer, so it needs no store (arch 01). `error` is the render/parse
const busy = usePreviewStore((s) => s.busy); // message (null = clean or blank); `busy` gates the >1s render overlay.
const setBusy = usePreviewStore((s) => s.setBusy); const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
// Mirrors whether `handleRef` currently holds a live view, so the per-chart // Mirrors whether `handleRef` currently holds a live view, so the per-chart
// export's image actions (which need the view) can enable/disable reactively — // export's image actions (which need the view) can enable/disable reactively —
// a ref change alone wouldn't re-render. Set true on a successful render, false // a ref change alone wouldn't re-render. Set true on a successful render, false
@@ -211,6 +214,13 @@ export function LivePreview() {
// not `chartReady` — consecutive successful renders keep `chartReady` true, but // not `chartReady` — consecutive successful renders keep `chartReady` true, but
// each one is new data the inspector must pick up. // each one is new data the inspector must pick up.
const [renderEpoch, setRenderEpoch] = useState(0); const [renderEpoch, setRenderEpoch] = useState(0);
// Bumped (debounced, inside the handle) when an interactive selection changes
// the inspected data without a re-render — the live data inspector (spec §04;
// multi-view scope doc M5). Kept separate from `renderEpoch` so a brush pulse
// re-reads the table without re-subscribing the listener; their sum is the
// inspector's single refresh trigger (each event bumps exactly one, so the sum
// is strictly monotonic — no collisions).
const [liveEpoch, setLiveEpoch] = useState(0);
// Busy-indication timer ref: if a render exceeds ~1s we surface a non-blocking // Busy-indication timer ref: if a render exceeds ~1s we surface a non-blocking
// overlay (arch §10.2 NN/g: >1s owes a busy indication; <1s shows nothing to // overlay (arch §10.2 NN/g: >1s owes a busy indication; <1s shows nothing to
@@ -245,7 +255,7 @@ export function LivePreview() {
try { try {
parsed = JSON.parse(text); parsed = JSON.parse(text);
} catch (e) { } catch (e) {
if (mine === generationRef.current) setError(`Invalid JSON: ${(e as Error).message}`); if (mine === generationRef.current) setError(`Invalid JSON · ${(e as Error).message}`);
return; return;
} }
} }
@@ -325,19 +335,25 @@ export function LivePreview() {
setChartReady(false); setChartReady(false);
setRenderEpoch((e) => e + 1); setRenderEpoch((e) => e + 1);
clearBusy(); clearBusy();
// A missing dataset reference is not a JSON/spec problem, so it gets a // All render-failure messages share a line-led / noun-led terse shape
// tailored, fixable message instead of the generic syntax hint (council: // (`<location|noun> · <detail>`, arch 10 §1). A missing dataset and a
// GOV.UK error-message + NN/g #9 — name the problem, give the real fix). // malformed expression are attributed precisely — naming the fixable cause
// beats the generic "check your JSON" hint, which is wrong when the JSON is
// valid (council: GOV.UK error-message "be specific" + name the real fix).
if (e instanceof DatasetNotFoundError) { if (e instanceof DatasetNotFoundError) {
setError( setError(
`Dataset "${e.datasetName}" not found. Create it from Datasets ` + `Dataset "${e.datasetName}" not found · create it from Datasets (⌘/Ctrl+K)`,
`(⌘/Ctrl+K), or check the dataset name in your spec.`,
); );
} else { } else {
setError( // A malformed Vega expression is located by line (the editor also
`Rendering error: ${(e as Error).message}. ` + // squiggles it) and carries the same parser message the hover shows.
`Check your JSON syntax and that the spec is valid Vega-Lite.`, // Scanned over the untrimmed buffer so the line matches the editor's.
); const exprError = firstExpressionError(shownText);
if (exprError) {
setError(`Line ${exprError.line} · ${exprError.message.replace(/\.$/, '')}`);
} else {
setError(`Render error · ${(e as Error).message}`);
}
} }
} }
} }
@@ -397,6 +413,19 @@ export function LivePreview() {
// on `renderEpoch`, so this need not depend on it (it always reads the latest handle). // on `renderEpoch`, so this need not depend on it (it always reads the latest handle).
const getInspectData = useCallback(() => handleRef.current?.inspectData() ?? null, []); const getInspectData = useCallback(() => handleRef.current?.inspectData() ?? null, []);
// Live data inspection (spec §04): while the inspector is open, re-read the table
// when an interactive selection changes the data it shows (a filtering brush). The
// handle owns the Vega listeners + debounce; we just bump `liveEpoch` on each fire.
// Re-subscribes whenever a render settles (`renderEpoch`) so it tracks the current
// handle, and only while the inspector is open so a collapsed one costs nothing.
// handleRef is a ref (read, not a dep); the cleanup unsubscribes.
useEffect(() => {
if (!inspectorOpen) return;
const handle = handleRef.current;
if (!handle) return;
return handle.onDataChange(() => setLiveEpoch((e) => e + 1));
}, [inspectorOpen, renderEpoch]);
// Re-fit the chart when its container resizes (e.g. a pane drag). Vega doesn't // Re-fit the chart when its container resizes (e.g. a pane drag). Vega doesn't
// observe the element, so we do: one observer on the stable host node for the // observe the element, so we do: one observer on the stable host node for the
// component's life. Only responsive fit modes depend on container size; // component's life. Only responsive fit modes depend on container size;
@@ -413,16 +442,14 @@ export function LivePreview() {
return () => ro.disconnect(); return () => ro.disconnect();
}, []); }, []);
// Finalize the live view on unmount, and clear the shared error + busy state so // Finalize the live view on unmount so its timers/listeners don't outlive the
// stale transient state never outlives this pane. // pane. Render status is local state and dies with the component.
useEffect( useEffect(
() => () => { () => () => {
handleRef.current?.destroy(); handleRef.current?.destroy();
handleRef.current = null; handleRef.current = null;
setChartReady(false); setChartReady(false);
if (busyTimerRef.current !== null) clearTimeout(busyTimerRef.current); if (busyTimerRef.current !== null) clearTimeout(busyTimerRef.current);
usePreviewStore.getState().setError(null);
usePreviewStore.getState().setBusy(false);
}, },
[], [],
); );
@@ -431,10 +458,12 @@ export function LivePreview() {
<div className={styles.preview}> <div className={styles.preview}>
<div className={styles.header}> <div className={styles.header}>
<FitControl /> <FitControl />
{/* Right cluster: chart theme, export this chart, then the settings gear. */} {/* Right cluster: chart theme, export this chart, the structure wireframe,
then the settings gear. */}
<div className={styles.headerEnd}> <div className={styles.headerEnd}>
<ChartThemeControl /> <ChartThemeControl />
<ChartExport chartReady={chartReady} getImageUrl={getImageUrl} /> <ChartExport chartReady={chartReady} getImageUrl={getImageUrl} />
<CompositionWireframe />
<PreviewSettings /> <PreviewSettings />
</div> </div>
</div> </div>
@@ -451,10 +480,15 @@ export function LivePreview() {
<div className={`${styles.frame} ${FIT_CLASS[fitMode]}`} hidden={error !== null}> <div className={`${styles.frame} ${FIT_CLASS[fitMode]}`} hidden={error !== null}>
<div className={styles.host} ref={hostRef} /> <div className={styles.host} ref={hostRef} />
</div> </div>
{/* Visual only no live region. The same error is announced once by the {/* The single home for a render/parse error (arch 10 §1): it sits where the
editor pane's role="alert" (one producer, two subscribers; doc §10.1), chart would be error XOR chart and is the lone live region, assertive
so adding one here would double-announce it. */} since the user just caused it. The editor pinpoints the spot via its squiggle,
{error !== null && <pre className={styles.error}>{error}</pre>} so the message lives here, not duplicated under the editor. */}
{error !== null && (
<pre className={styles.error} role="alert">
{error}
</pre>
)}
{/* {/*
* Busy overlay: non-blocking, overlays only the chart body, never the header * Busy overlay: non-blocking, overlays only the chart body, never the header
* or the editor (arch §10.2; spec §04/§10). Shown only after the ~1s threshold * or the editor (arch §10.2; spec §04/§10). Shown only after the ~1s threshold
@@ -478,7 +512,7 @@ export function LivePreview() {
<DataInspector <DataInspector
id="preview-data-inspector" id="preview-data-inspector"
getData={getInspectData} getData={getInspectData}
renderEpoch={renderEpoch} renderEpoch={renderEpoch + liveEpoch}
heightPx={inspectorOpen ? inspectorHeight : undefined} heightPx={inspectorOpen ? inspectorHeight : undefined}
/> />
</div> </div>
+7 -1
View File
@@ -18,7 +18,13 @@ import { Onboarding } from './Onboarding';
// never touches vega-embed. A resolved no-op handle is enough — Onboarding only // never touches vega-embed. A resolved no-op handle is enough — Onboarding only
// finalizes it on unmount. // finalizes it on unmount.
vi.mock('../services/chart-renderer', () => ({ vi.mock('../services/chart-renderer', () => ({
renderSpec: () => Promise.resolve({ destroy() {}, resize() {}, inspectData: () => null }), renderSpec: () =>
Promise.resolve({
destroy() {},
resize() {},
inspectData: () => null,
onDataChange: () => () => {},
}),
})); }));
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
+2 -2
View File
@@ -137,8 +137,8 @@ export function Onboarding() {
<div className={styles.inner}> <div className={styles.inner}>
<h2 className={styles.title}>Welcome to Astrolabe</h2> <h2 className={styles.title}>Welcome to Astrolabe</h2>
<p className={styles.tagline}> <p className={styles.tagline}>
A local library for your Vega-Lite charts authored as JSON, rendered live, and kept on A local library for your Vega-Lite charts write the spec as JSON, watch it render live,
your device. and keep it all on your device.
</p> </p>
<div className={styles.ctaRow}> <div className={styles.ctaRow}>
-17
View File
@@ -79,20 +79,3 @@
background: var(--bg); background: var(--bg);
pointer-events: none; pointer-events: none;
} }
/* Inline render/parse error surface (spec §03E) — monospaced, distinct. */
.error {
flex: 0 0 auto;
max-height: 30%;
overflow: auto;
margin: 0;
padding: var(--space-3) var(--space-4);
border-top: var(--border-width) solid var(--support-error);
background: var(--layer-01);
font-family: var(--font-mono);
font-size: 12px;
line-height: 1.6;
color: var(--support-error);
white-space: pre-wrap;
word-break: break-word;
}
+166 -19
View File
@@ -9,8 +9,9 @@
* *
* The pane header carries the Draft/Published toggle plus Publish and Revert * The pane header carries the Draft/Published toggle plus Publish and Revert
* (spec §03D). The published view is read-only it shows the last published * (spec §03D). The published view is read-only it shows the last published
* spec for reference; all editing happens on the draft. Render problems surface * spec for reference; all editing happens on the draft. Render/parse problems
* inline near the editor (spec §03E), mirroring the preview via PreviewStore. * surface in the preview pane (arch 10 §1); the editor marks the spot with an
* inline squiggle (spec §03E).
*/ */
import { useEffect, useMemo, useRef, type RefObject } from 'react'; import { useEffect, useMemo, useRef, type RefObject } from 'react';
@@ -26,20 +27,44 @@ import '../infrastructure/monaco-env'; // side-effect: wire workers before creat
import { configureVegaLiteJson } from '../infrastructure/monaco-schema'; import { configureVegaLiteJson } from '../infrastructure/monaco-schema';
import { configureJsonFormatter, installFormatOnPaste } from '../infrastructure/monaco-format'; import { configureJsonFormatter, installFormatOnPaste } from '../infrastructure/monaco-format';
import { parseChartSpecText } from '@core/chart-builder'; import { parseChartSpecText } from '@core/chart-builder';
import { openChartBuilderForEdit, openModal } from '../modals/ModalCoordinator'; import { openChartBuilderForEdit } from '../modals/ModalCoordinator';
import { import {
installSpecConfigActions, installSpecConfigActions,
runExtractConfig, runExtractConfig,
runExtractConfigToTheme, runExtractConfigToTheme,
runMergeChartTheme, runMergeChartTheme,
} from '../services/spec-config-actions'; } from '../services/spec-config-actions';
import {
configureSpecTransformCodeActions,
installSpecTransformActions,
installSpecTransformCodeLens,
runMoveViewTo,
runSimplifyStructure,
runUnwrap,
runWrap,
runWrapContainer,
runWrapViews,
} from '../services/spec-transform-actions';
import { configureSpecDatasetHints } from '../services/spec-dataset-hints';
import {
configureSpecExpressionHints,
installExpressionMarkers,
} from '../services/spec-expression-hints';
import {
configureSpecTransformScaffold,
installSpecTransformScaffoldCodeLens,
} from '../services/spec-transform-scaffold';
import {
configureSpecParamScaffold,
installSpecParamScaffoldCodeLens,
} from '../services/spec-param-scaffold';
import { runExtract } from '../services/extract-action';
import { useAppStore } from '../stores/AppStore'; import { useAppStore } from '../stores/AppStore';
import { confirm } from '../stores/ConfirmStore'; import { confirm } from '../stores/ConfirmStore';
import { useDatasetStore } from '../stores/DatasetStore'; import { useDatasetStore } from '../stores/DatasetStore';
import { hasInlineData } from '../stores/ExtractStore'; import { hasExtractableData } from '../stores/ExtractStore';
import { publishActiveSnippet } from '../services/snippet-actions'; import { publishActiveSnippet } from '../services/snippet-actions';
import { notify } from '../stores/NotificationStore'; import { notify } from '../stores/NotificationStore';
import { usePreviewStore } from '../stores/PreviewStore';
import { selectActiveSnippet, selectShownText, useSnippetStore } from '../stores/SnippetStore'; import { selectActiveSnippet, selectShownText, useSnippetStore } from '../stores/SnippetStore';
import { useUserSettingsStore } from '../stores/UserSettingsStore'; import { useUserSettingsStore } from '../stores/UserSettingsStore';
import { Icon } from './Icon'; import { Icon } from './Icon';
@@ -149,6 +174,17 @@ function EditorSettings() {
configureVegaLiteJson(); configureVegaLiteJson();
// Register the compact JSON formatter once (Format Document + format-on-paste, §03A). // Register the compact JSON formatter once (Format Document + format-on-paste, §03A).
configureJsonFormatter(); configureJsonFormatter();
// Register the structural-transform refactors (the lightbulb) once, globally for
// JSON — like the schema/formatter, not per editor (docs/architecture/08).
configureSpecTransformCodeActions();
// Register the dataset-aware completion/hover/inlay providers once (docs/architecture/08).
configureSpecDatasetHints();
// Register the expression completion/signature-help/hover providers once (docs/architecture/08).
configureSpecExpressionHints();
// Register the data-transform step-scaffold completion once (docs/architecture/08).
configureSpecTransformScaffold();
// Register the parameter-scaffold completion once (docs/architecture/08).
configureSpecParamScaffold();
/** The two specconfig operations, surfaced as an overflow menu (council: /** The two specconfig operations, surfaced as an overflow menu (council:
* Carbon menu-buttons overflow for additional options under space * Carbon menu-buttons overflow for additional options under space
@@ -174,6 +210,26 @@ const CONFIG_ACTIONS = [
type ConfigActionId = (typeof CONFIG_ACTIONS)[number]['value']; type ConfigActionId = (typeof CONFIG_ACTIONS)[number]['value'];
/** Composition restructures, surfaced as a sibling menu to Config (the discoverable
* home; the lightbulb and F1 palette are the accelerators see
* services/spec-transform-actions). They act on the selection, else the whole
* spec. Named "Compose" so it reads distinctly from a Vega-Lite data `transform`
* (the pipeline scaffolded by the editor completion services/spec-transform-scaffold). */
const COMPOSE_ACTIONS = [
{ value: 'layer', label: 'Wrap in layer', detail: 'Overlay marks on shared scales' },
{ value: 'hconcat', label: 'Wrap in horizontal concat', detail: 'Place views side by side' },
{ value: 'vconcat', label: 'Wrap in vertical concat', detail: 'Stack views top to bottom' },
{ value: 'facet', label: 'Wrap in facet', detail: 'Small multiples across a field' },
{ value: 'repeat', label: 'Wrap in repeat', detail: 'Repeat the chart across fields' },
{
value: 'simplify',
label: 'Simplify composition',
detail: 'Collapse a single-child layer/concat back to a unit',
},
] as const;
type ComposeActionId = (typeof COMPOSE_ACTIONS)[number]['value'];
function EditorToolbar({ function EditorToolbar({
editorRef, editorRef,
}: { }: {
@@ -190,10 +246,11 @@ function EditorToolbar({
const draft = s.editorView === 'draft' ? s.draftText : active.draftSpec; const draft = s.editorView === 'draft' ? s.draftText : active.draftSpec;
return draft !== active.spec; return draft !== active.spec;
}); });
// Offer Extract only when the live draft carries top-level inline data to lift // Offer Extract only when the live draft carries data to lift out — inline
// out (spec §03F → hidden when the spec has no inline data). // `values` in any view, or a reference to a self-defined `datasets` entry (spec
// §03F → hidden when there is nothing extractable).
const canExtract = useSnippetStore( const canExtract = useSnippetStore(
(s) => s.activeSnippetId !== null && hasInlineData(s.draftText), (s) => s.activeSnippetId !== null && hasExtractableData(s.draftText),
); );
// Offer "Open in builder" only when the active snippet's published spec is // Offer "Open in builder" only when the active snippet's published spec is
@@ -216,6 +273,14 @@ function EditorToolbar({
if (snippet) openChartBuilderForEdit(snippet); if (snippet) openChartBuilderForEdit(snippet);
}; };
// Extract is scoped to the view at the cursor (services/extract-action), so it
// goes through the editor handle like the wrap/config actions, not a bare
// openModal — the service captures the focused binding before opening the modal.
const handleExtract = () => {
const editor = editorRef.current;
if (editor) runExtract(editor);
};
// Publish + its success toast live in one place (services/snippet-actions) so // Publish + its success toast live in one place (services/snippet-actions) so
// the button and the Cmd/Ctrl+S shortcut (EventRouter) behave identically. // the button and the Cmd/Ctrl+S shortcut (EventRouter) behave identically.
const handlePublish = publishActiveSnippet; const handlePublish = publishActiveSnippet;
@@ -228,6 +293,13 @@ function EditorToolbar({
else runExtractConfigToTheme(editor); else runExtractConfigToTheme(editor);
}; };
const handleComposeAction = (action: ComposeActionId) => {
const editor = editorRef.current;
if (!editor) return;
if (action === 'simplify') runUnwrap(editor);
else runWrap(editor, action);
};
const handleRevert = async () => { const handleRevert = async () => {
const ok = await confirm({ const ok = await confirm({
title: 'Revert draft', title: 'Revert draft',
@@ -276,14 +348,24 @@ function EditorToolbar({
{canExtract && ( {canExtract && (
<Button <Button
className={styles.collapsible} className={styles.collapsible}
onClick={() => openModal('extract')} onClick={handleExtract}
title="Extract inline data into a reusable dataset" title="Extract embedded data into a reusable dataset"
aria-label="Extract to Dataset" aria-label="Extract to Dataset"
> >
<Icon name="dataset" className={styles.actionIcon} /> <Icon name="dataset" className={styles.actionIcon} />
<span className={styles.actionLabel}>Extract to Dataset</span> <span className={styles.actionLabel}>Extract to Dataset</span>
</Button> </Button>
)} )}
<SelectControl
id="editor-compose-actions"
label="Spec composition actions"
heading="Compose"
options={COMPOSE_ACTIONS}
onSelect={handleComposeAction}
triggerContent="Compose"
triggerTitle="Composition restructures — wrap the focused view in a layer/concat/facet/repeat, or simplify one"
disabled={activeId === null || editorView === 'published'}
/>
<SelectControl <SelectControl
id="editor-config-actions" id="editor-config-actions"
label="Spec config actions" label="Spec config actions"
@@ -325,7 +407,8 @@ export function SpecEditor() {
const editorView = useSnippetStore((s) => s.editorView); const editorView = useSnippetStore((s) => s.editorView);
const bufferEpoch = useSnippetStore((s) => s.bufferEpoch); const bufferEpoch = useSnippetStore((s) => s.bufferEpoch);
const uiTheme = useAppStore((s) => s.uiTheme); const uiTheme = useAppStore((s) => s.uiTheme);
const error = usePreviewStore((s) => s.error); const revealTarget = useAppStore((s) => s.revealTarget);
const composeRequest = useAppStore((s) => s.composeRequest);
// Editor preferences (spec §07 → Editor); applied live below as they change. // Editor preferences (spec §07 → Editor); applied live below as they change.
const editorPrefs = useUserSettingsStore((s) => s.saved.editor); const editorPrefs = useUserSettingsStore((s) => s.saved.editor);
@@ -376,6 +459,28 @@ export function SpecEditor() {
// above, so the draft buffer stays in sync like any other edit. // above, so the draft buffer stays in sync like any other edit.
const configActionsSub = installSpecConfigActions(editor); const configActionsSub = installSpecConfigActions(editor);
// Structural-transform actions in the F1 palette (the lightbulb is registered
// once, globally, above; the toolbar Compose menu is the home). Per editor,
// disposed below like the config actions.
const transformActionsSub = installSpecTransformActions(editor);
// Cursor-aware composition CodeLens (add view above/below, reorder) — per
// editor, because its commands need this editor's handle to apply the edit.
const codeLensSub = installSpecTransformCodeLens(editor);
// Cursor-aware data-transform scaffold CodeLens ( Add transform / filter …)
// — per editor for the same reason: its commands drive this editor's snippet edit.
const scaffoldLensSub = installSpecTransformScaffoldCodeLens(editor);
// Cursor-aware parameter scaffold CodeLens ( slider / point …) — per editor,
// its command splices the seeded param into this editor via the snippet engine.
const paramScaffoldLensSub = installSpecParamScaffoldCodeLens(editor);
// Validate Vega expressions in the draft and squiggle the invalid ones — per
// editor, because it writes markers to this model (docs/architecture/08).
// Debounced internally; recomputes on edit and on a draft↔published toggle.
const exprMarkersSub = installExpressionMarkers(editor);
// Cmd/Ctrl+S is owned globally by the EventRouter (docs/architecture/04 → // Cmd/Ctrl+S is owned globally by the EventRouter (docs/architecture/04 →
// "bind listeners in exactly one place"), which publishes before the // "bind listeners in exactly one place"), which publishes before the
// interactive-context gate so it works while the editor has focus. Monaco // interactive-context gate so it works while the editor has focus. Monaco
@@ -385,6 +490,11 @@ export function SpecEditor() {
sub.dispose(); sub.dispose();
pasteSub.dispose(); pasteSub.dispose();
configActionsSub.dispose(); configActionsSub.dispose();
transformActionsSub.dispose();
codeLensSub.dispose();
scaffoldLensSub.dispose();
paramScaffoldLensSub.dispose();
exprMarkersSub.dispose();
editor.dispose(); editor.dispose();
editorRef.current = null; editorRef.current = null;
}; };
@@ -425,6 +535,49 @@ export function SpecEditor() {
monaco.editor.setTheme(effective === 'dark' ? 'vs-dark' : 'vs'); monaco.editor.setTheme(effective === 'dark' ? 'vs-dark' : 'vs');
}, [uiTheme, editorPrefs.theme]); }, [uiTheme, editorPrefs.theme]);
// Select + reveal a view's source range when the composition wireframe asks
// (arch 08 → composition wireframe). The nonce makes a repeat request re-fire.
useEffect(() => {
const editor = editorRef.current;
if (!editor || !revealTarget) return;
const model = editor.getModel();
if (!model) return;
const range = monaco.Range.fromPositions(
model.getPositionAt(revealTarget.offset),
model.getPositionAt(revealTarget.offset + revealTarget.length),
);
editor.setSelection(range);
editor.revealRangeInCenterIfOutsideViewport(range);
// Deliberately no focus(): the wireframe stays the active surface so the user
// can keep browsing blocks while the editor scrolls/selects to follow.
}, [revealTarget]);
// Apply a composition restructure when the wireframe asks (wireframe → editor;
// the editor owns the undoable edit). The nonce makes a repeat request re-fire.
useEffect(() => {
const editor = editorRef.current;
if (!editor || !composeRequest) return;
if (composeRequest.kind === 'move')
runMoveViewTo(editor, composeRequest.arrayPath, composeRequest.from, composeRequest.to);
else if (composeRequest.kind === 'simplify') runSimplifyStructure(editor);
else if (composeRequest.kind === 'wrap-container')
runWrapContainer(
editor,
composeRequest.containerPath,
composeRequest.sourcePath,
composeRequest.axis,
composeRequest.side,
);
else
runWrapViews(
editor,
composeRequest.targetPath,
composeRequest.sourcePath,
composeRequest.axis,
composeRequest.side,
);
}, [composeRequest]);
return ( return (
<div className={styles.editorPane}> <div className={styles.editorPane}>
<EditorToolbar editorRef={editorRef} /> <EditorToolbar editorRef={editorRef} />
@@ -432,14 +585,8 @@ export function SpecEditor() {
{activeId === null && <div className={styles.placeholder}>Select or create a snippet</div>} {activeId === null && <div className={styles.placeholder}>Select or create a snippet</div>}
<div className={styles.editor} ref={hostRef} /> <div className={styles.editor} ref={hostRef} />
</div> </div>
{/* The single live region for render/parse errors: assertive, since the {/* Render/parse errors surface in the preview pane (arch 10 §1), where the
user just caused it. The preview shows the same text visually but is chart would be; the editor pinpoints the spot with its squiggle. */}
not a live region, so the message is announced once (doc §10.1). */}
{error !== null && (
<pre className={styles.error} role="alert">
{error}
</pre>
)}
</div> </div>
); );
} }
@@ -21,6 +21,7 @@ const okHandle = () => ({
resize() {}, resize() {},
toImageURL: () => Promise.resolve(''), toImageURL: () => Promise.resolve(''),
inspectData: () => null, inspectData: () => null,
onDataChange: () => () => {},
}); });
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
@@ -22,6 +22,7 @@ vi.mock('../services/chart-renderer', () => ({
resize() {}, resize() {},
toImageURL: () => Promise.resolve(''), toImageURL: () => Promise.resolve(''),
inspectData: () => null, inspectData: () => null,
onDataChange: () => () => {},
}), }),
), ),
})); }));
+27
View File
@@ -0,0 +1,27 @@
/**
* Mark wireframe glyph ledger (composition wireframe leaves, arch 08). Maps a
* unit view's Vega-Lite mark type to its glyph in the Icon vocabulary's `mark-*`
* sub-family, collapsing synonyms (circle/square point, trail line, image
* rect) so the set stays small; anything unmapped or absent falls to `mark-generic`.
*/
import type { IconName } from './Icon';
const MARK_ICON: Record<string, IconName> = {
bar: 'mark-bar',
line: 'mark-line',
trail: 'mark-line',
area: 'mark-area',
point: 'mark-point',
circle: 'mark-point',
square: 'mark-point',
tick: 'mark-tick',
rect: 'mark-rect',
image: 'mark-rect',
arc: 'mark-arc',
rule: 'mark-rule',
text: 'mark-text',
};
export const markIconName = (mark?: string): IconName =>
(mark && MARK_ICON[mark]) || 'mark-generic';
+4 -4
View File
@@ -1,10 +1,10 @@
/** /**
* Project feedback channel. * Project feedback channel.
* *
* There is no server and no tracker (see the About modal no telemetry of any * The app sends no telemetry of its own (see the About modal); feedback is a
* kind); feedback is a plain email the user composes and sends from their own * plain email the user composes and sends from their own client. The address is
* client. The address is a Cloudflare Email Routing alias that forwards to the * a Cloudflare Email Routing alias that forwards to the author, so it can be
* author, so it can be retired without exposing or churning a personal inbox. * retired without exposing or churning a personal inbox.
* *
* Shared by the Support modal and the About modal a contact address is worth * Shared by the Support modal and the About modal a contact address is worth
* a single source of truth so the two surfaces can't drift. * a single source of truth so the two surfaces can't drift.
+3 -2
View File
@@ -63,11 +63,12 @@ const MODAL_REGISTRY: Partial<Record<ModalName, ModalConfig>> = {
}, },
}, },
// Opened from the snippet editor with the active draft's inline data to lift out. // Opened from the snippet editor by `services/extract-action`, which seeds the
// store with the focused view's inline data (`begin`) *before* opening — so no
// `init` here, which would re-read top-level and clobber the view-scoped capture.
extract: { extract: {
name: 'extract', name: 'extract',
title: 'Extract to Dataset', title: 'Extract to Dataset',
init: () => useExtractStore.getState().init(),
getState: () => ({ name: useExtractStore.getState().name }), getState: () => ({ name: useExtractStore.getState().name }),
}, },
+113
View File
@@ -0,0 +1,113 @@
import { beforeEach, describe, expect, test } from 'vitest';
import { createDataset } from '@core/dataset';
import { useDatasetStore } from '../stores/DatasetStore';
import { dataInfoAt } from './active-dataset';
/** Seed a library dataset, profiled from inline rows. */
function seed(name: string, rows: Record<string, unknown>[]): void {
useDatasetStore
.getState()
.add(createDataset({ name, data: rows, format: 'json', source: 'inline' }));
}
/** An offset inside the first occurrence of `marker` in `text`. */
function at(text: string, marker: string): number {
return text.indexOf(marker) + 1;
}
const names = (cols: ReadonlyArray<{ name: string }>) => cols.map((c) => c.name).sort();
beforeEach(() => {
useDatasetStore.getState().reset();
});
describe('dataInfoAt — view-scoped data context', () => {
test('resolves a library binding case-insensitively', () => {
seed('Sales', [{ region: 'W', revenue: 10 }]);
const text = '{ "data": { "name": "sales" }, "encoding": { "x": { "field": "MK" } } }';
const info = dataInfoAt(text, at(text, 'MK'));
expect(info.name).toBe('Sales');
expect(names(info.columnTypes)).toEqual(['region', 'revenue']);
});
test('each layer sees its own dataset, not a sibling views', () => {
seed('Sales', [{ region: 'W', revenue: 10 }]);
seed('Regions', [{ region: 'W', population: 100 }]);
const text = JSON.stringify(
{
layer: [
{ data: { name: 'Sales' }, encoding: { x: { field: 'sales_mk' } } },
{ data: { name: 'Regions' }, encoding: { y: { field: 'regions_mk' } } },
],
},
null,
2,
);
const inLayer0 = dataInfoAt(text, at(text, 'sales_mk'));
const inLayer1 = dataInfoAt(text, at(text, 'regions_mk'));
expect(inLayer0.name).toBe('Sales');
expect(names(inLayer0.columnTypes)).toEqual(['region', 'revenue']);
expect(inLayer1.name).toBe('Regions');
expect(names(inLayer1.columnTypes)).toEqual(['population', 'region']);
});
test('a child without its own data inherits the parent binding', () => {
seed('Sales', [{ region: 'W', revenue: 10 }]);
const text = JSON.stringify(
{ data: { name: 'Sales' }, layer: [{ encoding: { x: { field: 'mk' } } }] },
null,
2,
);
const info = dataInfoAt(text, at(text, '"mk"'));
expect(info.name).toBe('Sales');
expect(names(info.columnTypes)).toEqual(['region', 'revenue']);
});
test('profiles an inline binding (ghost dataset, no name)', () => {
const text =
'{ "data": { "values": [{ "a": 1, "b": 2 }] }, "encoding": { "x": { "field": "MK" } } }';
const info = dataInfoAt(text, at(text, 'MK'));
expect(info.name).toBeNull();
expect(names(info.columnTypes)).toEqual(['a', 'b']);
});
test('profiles a self-defined top-level datasets binding', () => {
const text = JSON.stringify(
{
datasets: { local: [{ c: 1, d: 2 }] },
data: { name: 'local' },
encoding: { x: { field: 'mk' } },
},
null,
2,
);
const info = dataInfoAt(text, at(text, '"mk"'));
expect(info.name).toBeNull();
expect(names(info.columnTypes)).toEqual(['c', 'd']);
});
test('offers ancestor-derived fields, not a sibling views', () => {
seed('Sales', [{ region: 'W', revenue: 10 }]);
const text = JSON.stringify(
{
data: { name: 'Sales' },
transform: [{ calculate: 'datum.revenue * 2', as: 'shared' }],
layer: [
{ transform: [{ calculate: 'x', as: 'inner' }], encoding: { x: { field: 'mk0' } } },
{ transform: [{ calculate: 'y', as: 'sibling' }], encoding: { y: { field: 'mk1' } } },
],
},
null,
2,
);
const fields = dataInfoAt(text, at(text, 'mk0')).fields.map((f) => f.name);
expect(fields).toContain('shared'); // top-level (inherited)
expect(fields).toContain('inner'); // this view
expect(fields).not.toContain('sibling'); // the other layer
});
test('a url/generator binding yields no static columns', () => {
const url = '{ "data": { "url": "x.csv" }, "encoding": { "x": { "field": "MK" } } }';
expect(dataInfoAt(url, at(url, 'MK')).columnTypes).toEqual([]);
});
});
+205
View File
@@ -0,0 +1,205 @@
/**
* The data context of the active draft **at the cursor** (docs/architecture/08
* editor augmentation): the columns/types/stats the spec can reference where the
* cursor sits, and the fields the editor hints offer. One resolver, shared by the
* transform actions (facet/repeat field defaults) and the dataset-aware hints
* (completion/hover/inlay).
*
* View-scoped: a composed spec (layer/concat/facet/repeat) can bind a different
* dataset per view, and Vega-Lite inherits a parent view's data into its children.
* So the context is resolved at a JSON path the data binding of the nearest
* enclosing view (`dataBindingAtPath`), plus the fields derived by that view's and
* its ancestors' transforms (`derivedFieldNamesAtPath`).
*
* Source columns, in order: a named **library dataset** the binding references
* (its stored profile, matched case-insensitively like the renderer), else the
* binding's **inline rows** profiled on the fly inline `values` or a self-defined
* top-level `datasets` entry (the "ghost dataset", `core/spec-inline-data` +
* `core/profile`, nothing stored). URL and generator bindings have no static
* columns.
*
* Profiling is memoized per (draft text, enclosing view) so the inlay provider
* which queries many field lines at one draft never re-profiles the same inline
* data. App layer reads stores via `getState`, outside React.
*/
import { type ColumnStats, profileData } from '@core/profile';
import { pathAtOffset } from '@core/spec-cursor';
import { dataBindingAtPath, libraryRefName, selfDefinedNames } from '@core/spec-data';
import { derivedFieldNamesAtPath } from '@core/spec-fields';
import { rowsForDataBinding } from '@core/spec-inline-data';
import type { ParamField } from '@core/spec-params';
import type { ColumnType } from '@core/type-inference';
import { useDatasetStore } from '../stores/DatasetStore';
/** A field offered to the editor hints. */
export interface FieldHint {
name: string;
/** Source columns carry an inferred type; a spec-derived field's is unknown. */
type: ColumnType | null;
/** True when introduced by a spec transform rather than the data. */
derived: boolean;
}
/** Everything the hints need about the active draft's data at a cursor. */
export interface DataInfo {
/** The library dataset's name, or null for inline data (the ghost dataset). */
name: string | null;
columnTypes: ReadonlyArray<{ name: string; type: ColumnType }>;
columnStats: ReadonlyArray<ColumnStats>;
/** Source columns plus derived fields, de-duplicated (source wins). */
fields: ReadonlyArray<FieldHint>;
}
/** The source columns of one view's data binding (the expensive, cached part). */
interface SourceColumns {
name: string | null;
columnTypes: ReadonlyArray<{ name: string; type: ColumnType }>;
columnStats: ReadonlyArray<ColumnStats>;
}
const EMPTY_SOURCE: SourceColumns = { name: null, columnTypes: [], columnStats: [] };
const EMPTY: DataInfo = { ...EMPTY_SOURCE, fields: [] };
function safeParse(text: string): unknown {
try {
return JSON.parse(text);
} catch {
return null;
}
}
// The parsed draft, memoized by text — both the binding walk and the derived-field
// walk read it, and providers fire often at one stable draft.
let parseCache: { text: string; spec: unknown } | null = null;
function parsedSpec(text: string): unknown {
if (parseCache?.text === text) return parseCache.spec;
const spec = safeParse(text);
parseCache = { text, spec };
return spec;
}
/** The stored profile of the library dataset a binding references, or null. */
function libraryProfile(refName: string): SourceColumns | null {
const lower = refName.toLowerCase();
const ds = useDatasetStore.getState().datasets.find((d) => d.name.toLowerCase() === lower);
if (ds && ds.columnTypes.length > 0) {
return { name: ds.name, columnTypes: ds.columnTypes, columnStats: ds.columnStats };
}
return null; // unknown name, or a dataset with no profiled columns yet
}
/** Source columns for one binding: library profile, else inline rows profiled. */
function resolveSource(spec: unknown, data: unknown): SourceColumns {
const refName = libraryRefName(data, selfDefinedNames(spec));
if (refName !== null) return libraryProfile(refName) ?? EMPTY_SOURCE;
const rows = rowsForDataBinding(spec, data);
if (!rows) return EMPTY_SOURCE; // url / generator / library miss / CSV-string values
const profile = profileData(rows, 0);
return { name: null, columnTypes: profile.columnTypes, columnStats: profile.columnStats };
}
// Source columns memoized by (text, enclosing view). Keyed by the binding's anchor
// path so the inlay provider's many per-line queries within one view profile once.
// A re-import that changes a dataset's columns under unchanged text serves stale
// hints until the next keystroke — harmless, since hints are additive.
let sourceCache: { text: string; byAnchor: Map<string, SourceColumns> } | null = null;
function sourceColumns(
text: string,
spec: unknown,
anchorKey: string,
data: unknown,
): SourceColumns {
if (!sourceCache || sourceCache.text !== text) sourceCache = { text, byAnchor: new Map() };
const hit = sourceCache.byAnchor.get(anchorKey);
if (hit) return hit;
const result = resolveSource(spec, data);
sourceCache.byAnchor.set(anchorKey, result);
return result;
}
/**
* The shared resolve step: the source columns of the binding in scope at `offset`,
* with the parsed spec and cursor path so callers can layer derived fields on top
* without re-parsing or re-walking.
*/
function resolveAt(
text: string,
offset: number,
): {
source: SourceColumns;
spec: unknown;
path: ReadonlyArray<string | number>;
} {
const spec = parsedSpec(text);
const path = pathAtOffset(text, offset);
const binding = dataBindingAtPath(spec, path);
const source = binding
? sourceColumns(text, spec, JSON.stringify(binding.anchorPath), binding.data)
: EMPTY_SOURCE;
return { source, spec, path };
}
/** Source columns of the binding in scope at `offset` — no derived-field merge. */
function sourceAt(text: string, offset: number): SourceColumns {
return text.trim() === '' ? EMPTY_SOURCE : resolveAt(text, offset).source;
}
/** The data context at `offset` in the editor's draft `text`. */
export function dataInfoAt(text: string, offset: number): DataInfo {
if (text.trim() === '') return EMPTY;
const { source, spec, path } = resolveAt(text, offset);
const fields: FieldHint[] = source.columnTypes.map((c) => ({
name: c.name,
type: c.type,
derived: false,
}));
const seen = new Set(fields.map((f) => f.name));
for (const derived of derivedFieldNamesAtPath(spec, path)) {
if (!seen.has(derived)) {
fields.push({ name: derived, type: null, derived: true });
seen.add(derived);
}
}
return {
name: source.name,
columnTypes: source.columnTypes,
columnStats: source.columnStats,
fields,
};
}
/** Source columns + derived fields available at `offset` (for completion). */
export function availableFieldsAt(text: string, offset: number): ReadonlyArray<FieldHint> {
return dataInfoAt(text, offset).fields;
}
/** Source columns + inferred types in scope at `offset` (for facet/repeat defaults). */
export function boundColumnsAt(
text: string,
offset: number,
): ReadonlyArray<{ name: string; type: ColumnType }> {
return sourceAt(text, offset).columnTypes;
}
/**
* Source columns with their numeric extent in scope at `offset` (for parameter
* scaffolding a slider's bounds come from the field's `numericExtent`). Zips the
* profile's type and stat arrays by name; a non-numeric column carries a null extent.
*/
export function boundParamFieldsAt(text: string, offset: number): ReadonlyArray<ParamField> {
const source = sourceAt(text, offset);
const extentByName = new Map(source.columnStats.map((s) => [s.name, s.numericExtent]));
return source.columnTypes.map((c) => ({
name: c.name,
type: c.type,
extent: extentByName.get(c.name) ?? null,
}));
}
/** The inferred type of a source field `name` at `offset`, or null (for inlay). */
export function fieldTypeAt(text: string, offset: number, name: string): ColumnType | null {
return sourceAt(text, offset).columnTypes.find((c) => c.name === name)?.type ?? null;
}
+109 -33
View File
@@ -12,7 +12,7 @@ import type { VisualizationSpec } from 'vega-embed';
import type { Config } from 'vega-lite'; import type { Config } from 'vega-lite';
import { collectFontFamilies } from '@core/custom-theme'; import { collectFontFamilies } from '@core/custom-theme';
import { embedFontsInSvg } from '@core/chart-export'; import { embedFontsInSvg } from '@core/chart-export';
import { pickResultDataset, pickSourceDataset } from '@core/result-data'; import { inspectViewLabel, inspectableViews } from '@core/inspect-views';
import type { FontAsset } from '@core/font-asset'; import type { FontAsset } from '@core/font-asset';
/** Options for `RenderHandle.toImageURL` (spec §08 → Per-chart export). */ /** Options for `RenderHandle.toImageURL` (spec §08 → Per-chart export). */
@@ -44,15 +44,82 @@ interface ImageExportOptions {
embedFonts?: ReadonlyArray<FontAsset>; embedFonts?: ReadonlyArray<FontAsset>;
} }
/** The two ends of the chart's data pipeline, for the data inspector (spec §04). */ /** One inspectable drawn table — the two ends of its pipeline (spec §04). */
export interface InspectedData { export interface InspectableTable {
/** Parsed source rows, before the spec's transforms run — the input. */ /** Stable selection id — the resolved table's compiled name. */
id: string;
/** User-facing label (never a compiler name — `@core/inspect-views`). */
label: string;
/** Parsed source rows, before the view's transforms run — the input. */
input: ReadonlyArray<Record<string, unknown>>; input: ReadonlyArray<Record<string, unknown>>;
/** Post-transform rows the chart draws the output (equals `input` when the /** Post-transform rows the marks draw the output (equals `input` when the view
* spec has no transforms). */ * has no transforms). */
resolved: ReadonlyArray<Record<string, unknown>>; resolved: ReadonlyArray<Record<string, unknown>>;
} }
/**
* The chart's inspectable data: one table per distinct table the marks draw, in
* document order (a multi-view spec yields several). `tables` is empty when the
* chart draws nothing inspectable, distinct from a `null` handle (no chart).
*/
export interface InspectedData {
tables: InspectableTable[];
}
/**
* Debounce for the live data inspector (spec §04; multi-view scope doc M5). A
* brush drag pulses `addDataListener` continuously; coalescing to one re-read per
* ~quiet-frame keeps the table feeling live without thrashing the grid on every
* pixel of the drag.
*/
export const LIVE_INSPECT_DEBOUNCE_MS = 120;
/** The minimal Vega `View` surface the live-inspect watcher needs. */
interface DataChangeView {
addDataListener(name: string, handler: () => void): unknown;
removeDataListener(name: string, handler: () => void): unknown;
}
/**
* Attach debounced listeners to every table the inspector shows so an interactive
* selection that recomputes a drawn table (a `filter: {param}` brush) re-reads the
* inspector live see `RenderHandle.onDataChange`. Watches the union of each
* drawn table's resolved + input names (deduped); a highlight selection changes no
* data, so none fire. Returns an unsubscribe that cancels any pending re-read and
* detaches the listeners but skips detaching once the view is finalized, since
* `view.finalize()` has already dropped every listener (and the unmount cleanup
* order can run this after the destroy). `isFinalized` is a getter, not a boolean,
* so it reflects the view's state at unsubscribe time, not subscribe time.
*/
export function watchInspectableData(
view: DataChangeView,
vgSpec: unknown,
onChange: () => void,
isFinalized: () => boolean,
): () => void {
if (isFinalized()) return () => {};
const names = [...new Set(inspectableViews(vgSpec).flatMap((v) => [v.resolved, v.input]))];
if (names.length === 0) return () => {};
let timer: ReturnType<typeof setTimeout> | null = null;
const handler = (): void => {
if (timer !== null) clearTimeout(timer);
timer = setTimeout(() => {
timer = null;
onChange();
}, LIVE_INSPECT_DEBOUNCE_MS);
};
for (const name of names) view.addDataListener(name, handler);
return () => {
if (timer !== null) {
clearTimeout(timer);
timer = null;
}
if (!isFinalized()) for (const name of names) view.removeDataListener(name, handler);
};
}
export interface RenderHandle { export interface RenderHandle {
/** Finalize the underlying Vega view and clear the node. */ /** Finalize the underlying Vega view and clear the node. */
destroy(): void; destroy(): void;
@@ -78,20 +145,29 @@ export interface RenderHandle {
*/ */
resize(): void; resize(): void;
/** /**
* The chart's input and resolved (post-transform) rows for the data inspector * The chart's inspectable tables for the data inspector (spec §04). Enumerates
* (spec §04). Reads the live view's compiled dataflow once: lists its datasets, * the tables the marks draw from the compiled Vega spec (`@core/inspect-views`),
* picks the most-upstream * and for each reads its input + resolved rows from the live view. A multi-view
* source and most-downstream result (`@core/result-data`), and returns both * spec yields several tables; a unit spec yields one. This is the one place
* tables' rows. This is the one place besides export that reaches into the view, * besides export that reaches into the view, so the embedding boundary holds
* so the embedding boundary holds (arch 05 §1§2) callers get rows, never the * (arch 05 §1§2) callers get rows, never the `view`.
* `view`.
* *
* Returns `null` when there is nothing to inspect (the view was finalized, or * Returns `null` when the view was finalized (no chart). The result's `tables`
* the spec produced no inspectable table). Either side can be `[]` when its * is empty when a chart draws nothing inspectable, and any table's `input`/
* table is empty a real signal (e.g. a filter removed every row on the * `resolved` can be `[]` (e.g. a filter removed every row) kept distinct from
* resolved side), kept distinct from "no chart" so the inspector can say which. * "no chart" so the inspector can say which.
*/ */
inspectData(): InspectedData | null; inspectData(): InspectedData | null;
/**
* Subscribe to live changes of the inspected tables, for the data inspector's
* live mode (spec §04; multi-view scope doc M5). An interactive selection that
* *filters* a downstream view recomputes that view's compiled table in place
* no re-embed so a static inspector would show stale rows until the next full
* render; this fires (debounced) so the caller can re-read via `inspectData()`.
* A highlight selection (a `condition` encoding) changes no data, so it never
* fires. Returns an unsubscribe; a no-op when the view is already finalized.
*/
onDataChange(listener: () => void): () => void;
} }
export interface RenderOptions { export interface RenderOptions {
@@ -297,22 +373,22 @@ export async function renderSpec(
}, },
inspectData() { inspectData() {
if (finalized) return null; if (finalized) return null;
// Enumerate the dataflow's datasets once, then pick the input + result // The tables the marks draw + their input lineage come from the compiled Vega
// tables. getState with a truthy `data` filter is Vega's documented way to // spec (a byproduct of the embed, not recompiled); the rows come from
// list datasets (vega/editor's Data Viewer does the same) — we read only the // view.data(name), which hands back the live array (no copy).
// keys. Rows come from view.data(name), which hands back the live array (no copy). const views = inspectableViews(result.vgSpec);
const state = result.view.getState({ const rows = (name: string): ReadonlyArray<Record<string, unknown>> =>
data: () => true, (result.view.data(name) ?? []) as Record<string, unknown>[];
signals: () => false, const tables = views.map((v, i) => ({
recurse: true, id: v.resolved,
}) as { data?: Record<string, unknown> }; label: inspectViewLabel(v.input, i),
const names = Object.keys(state.data ?? {}); input: rows(v.input),
const sourceName = pickSourceDataset(names); resolved: rows(v.resolved),
const resultName = pickResultDataset(names); }));
if (sourceName === null && resultName === null) return null; return { tables };
const rows = (name: string | null): ReadonlyArray<Record<string, unknown>> => },
name === null ? [] : ((result.view.data(name) ?? []) as Record<string, unknown>[]); onDataChange(listener) {
return { input: rows(sourceName), resolved: rows(resultName) }; return watchInspectableData(result.view, result.vgSpec, listener, () => finalized);
}, },
}; };
} }
@@ -0,0 +1,118 @@
/**
* Live-inspection watcher (`watchInspectableData`) the wiring behind
* `RenderHandle.onDataChange` (spec §04; multi-view scope doc M5). Verified against
* a fake Vega view + fake timers; the real selectionfilterdata recompute is an
* integration behavior exercised manually (renderSpec is vega-embed-bound).
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { LIVE_INSPECT_DEBOUNCE_MS, watchInspectableData } from './chart-renderer';
/** A compiled-Vega-shape spec: one drawn table data_0, sourced from source_0. */
const vgSpec = {
data: [{ name: 'source_0' }, { name: 'data_0', source: 'source_0' }],
marks: [{ type: 'symbol', from: { data: 'data_0' } }],
};
function fakeView() {
const listeners = new Map<string, Set<() => void>>();
return {
added: [] as string[],
removed: [] as string[],
addDataListener(name: string, handler: () => void) {
const set = listeners.get(name) ?? new Set<() => void>();
set.add(handler);
listeners.set(name, set);
this.added.push(name);
},
removeDataListener(name: string, handler: () => void) {
listeners.get(name)?.delete(handler);
this.removed.push(name);
},
fire(name: string) {
for (const handler of listeners.get(name) ?? []) handler();
},
};
}
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
describe('watchInspectableData', () => {
it('watches both the resolved and input table of each drawn view', () => {
const view = fakeView();
watchInspectableData(
view,
vgSpec,
() => {},
() => false,
);
expect(new Set(view.added)).toEqual(new Set(['data_0', 'source_0']));
});
it('debounces a burst of changes into a single re-read', () => {
const view = fakeView();
const onChange = vi.fn();
watchInspectableData(view, vgSpec, onChange, () => false);
view.fire('data_0');
view.fire('data_0');
view.fire('data_0'); // a brush drag pulsing
expect(onChange).not.toHaveBeenCalled(); // still within the debounce window
vi.advanceTimersByTime(LIVE_INSPECT_DEBOUNCE_MS);
expect(onChange).toHaveBeenCalledTimes(1);
});
it('unsubscribe detaches listeners and cancels a pending re-read', () => {
const view = fakeView();
const onChange = vi.fn();
const stop = watchInspectableData(view, vgSpec, onChange, () => false);
view.fire('data_0');
stop();
vi.advanceTimersByTime(LIVE_INSPECT_DEBOUNCE_MS * 2);
expect(onChange).not.toHaveBeenCalled(); // pending re-read cancelled
expect(new Set(view.removed)).toEqual(new Set(['data_0', 'source_0']));
});
it('is a no-op when the view is already finalized at subscribe', () => {
const view = fakeView();
const stop = watchInspectableData(
view,
vgSpec,
() => {},
() => true,
);
expect(view.added).toEqual([]);
stop(); // safe
});
it('after finalize, unsubscribe cancels the timer but does not touch the dead view', () => {
const view = fakeView();
const onChange = vi.fn();
let finalized = false;
const stop = watchInspectableData(view, vgSpec, onChange, () => finalized);
view.fire('data_0');
finalized = true; // view.finalize() ran (dropping its own listeners) before cleanup
stop();
vi.advanceTimersByTime(LIVE_INSPECT_DEBOUNCE_MS * 2);
expect(onChange).not.toHaveBeenCalled();
expect(view.removed).toEqual([]); // didn't call removeDataListener on a dead view
});
it('does nothing for a spec that draws no inspectable table', () => {
const view = fakeView();
const stop = watchInspectableData(
view,
{ data: [], marks: [] },
() => {},
() => false,
);
expect(view.added).toEqual([]);
stop();
});
});
+102
View File
@@ -0,0 +1,102 @@
/**
* The cursor-aware CodeLens skeleton shared by the editor's structural surfaces
* (docs/architecture/08 editor augmentation). A "cursor lens" follows the view the
* cursor sits in recomputing only when the *enclosing* thing changes, not on every
* keystroke and is installed **per editor** because its lens commands need the
* editor handle to apply the edit. The composition CodeLens (`spec-transform-actions`),
* the data-transform scaffold (`spec-transform-scaffold`), and the parameter scaffold
* (`spec-param-scaffold`) are all this one shape; only three things vary between them:
* - `resolve(text, offset)` the site the cursor is in (or null when in none);
* - `keyOf(site)` a string that changes exactly when the lenses should refresh
* (so typing *within* the resolved site doesn't churn them);
* - `lensesOf(site, model, lens)` the lenses for that site, built with the shared
* `lens` factory and closing over the caller's editor commands.
*
* The draft gate (scaffolding only edits the active draft; the published view is a
* read-only reference) and the position guard live here too, since every consumer
* shares them. App-layer glue imports `monaco` and reads `useSnippetStore`.
*/
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 = (
line: number,
title: string,
id: string | null,
args: unknown[],
) => monaco.languages.CodeLens;
/** What varies between one cursor-lens consumer and another. */
export interface CursorLensSpec<Site> {
/** The site the cursor is in, or null when it is in none. */
resolve(text: string, offset: number): Site | null;
/** A key that changes exactly when the lenses should refresh. */
keyOf(site: Site): string;
/** The lenses for `site`, built with `lens` (which closes over the caller's commands). */
lensesOf(
site: Site,
model: monaco.editor.ITextModel,
lens: LensFactory,
): monaco.languages.CodeLens[];
}
const NO_LENSES: monaco.languages.CodeLensList = { lenses: [], dispose() {} };
/** True when the active draft is being edited (the only place scaffolding acts). */
function onDraft(): boolean {
return useSnippetStore.getState().editorView === 'draft';
}
/**
* Install a cursor-aware CodeLens provider on `editor`, disposed with it. The provider
* refreshes when `keyOf(resolve(...))` changes under the cursor; between refreshes
* Monaco reuses the last lenses. Returns a disposable that tears down the cursor
* subscription, the refresh emitter, and the provider registration together.
*/
export function installCursorLens<Site>(
editor: monaco.editor.IStandaloneCodeEditor,
spec: CursorLensSpec<Site>,
): monaco.IDisposable {
const lens: LensFactory = (line, title, id, args) => ({
range: new monaco.Range(line, 1, line, 1),
command: { id: id ?? '', title, arguments: args },
});
/** Resolve the site under the cursor, gated to the draft. */
const siteUnderCursor = (model: monaco.editor.ITextModel | null): Site | null => {
const pos = editor.getPosition();
if (!model || !pos || !onDraft()) return null;
return spec.resolve(model.getValue(), model.getOffsetAt(pos));
};
const onDidChange = new monaco.Emitter<monaco.languages.CodeLensProvider>();
const provider: monaco.languages.CodeLensProvider = {
onDidChange: onDidChange.event,
provideCodeLenses(model) {
const site = siteUnderCursor(model);
if (!site) return NO_LENSES;
return { lenses: spec.lensesOf(site, model, lens), dispose() {} };
},
};
const registration = monaco.languages.registerCodeLensProvider('json', provider);
let lastKey = '';
const cursorSub = editor.onDidChangeCursorPosition(() => {
const site = siteUnderCursor(editor.getModel());
const key = site ? spec.keyOf(site) : '';
if (key !== lastKey) {
lastKey = key;
onDidChange.fire(provider);
}
});
return {
dispose() {
cursorSub.dispose();
onDidChange.dispose();
registration.dispose();
},
};
}
+92
View File
@@ -0,0 +1,92 @@
/**
* Monaco snippet glue shared by the scaffold services (`spec-transform-scaffold`,
* `spec-param-scaffold`; docs/architecture/08 editor augmentation): inserting a
* `${n:default}` template as a live, Tab-through session, and the completion-side
* plumbing the slot a scaffold suggestion replaces and the suggestion item itself.
* The pure text math (offsets, comma affixing) is `core/spec-snippet`; this file is
* only what needs Monaco types or the editor handle.
*/
import * as monaco from 'monaco-editor/esm/vs/editor/edcore.main';
import { arrayAffixes } from '@core/spec-snippet';
/** Monaco's snippet contribution — the public entry to insert a `${n:…}` template with tab stops. */
interface SnippetInserter extends monaco.editor.IEditorContribution {
insert(template: string, opts?: { adjustWhitespace?: boolean }): void;
}
/**
* Insert `template` at `offset` through Monaco's snippet engine, so its
* `${n:default}` tab stops become a live, Tab-through session. Places the cursor first
* (the controller inserts at the selection). `adjustWhitespace: false` keeps the engine
* from re-basing our explicit indentation to the insertion line's it otherwise leaves
* a multi-line block's closing bracket under-indented. No-op if the contribution is
* absent (it ships in `edcore.main`, so this is just defensive).
*/
export function insertSnippetAt(
editor: monaco.editor.IStandaloneCodeEditor,
offset: number,
template: string,
): void {
const model = editor.getModel();
if (!model) return;
editor.setPosition(model.getPositionAt(offset));
editor.focus();
editor.getContribution<SnippetInserter>('snippetController2')?.insert(template, {
adjustWhitespace: false,
});
}
/** The array-element slot a scaffold completion fills. */
export interface ScaffoldSlot {
/** The range the suggestion replaces (the bare partial the user typed). */
range: monaco.Range;
/** Comma affixes that keep the surrounding array valid around the inserted entry. */
lead: string;
trail: string;
}
/**
* The completion slot at `position` for an array-element scaffold: the bare alphabetic
* partial before the cursor, parsed from the line never `getWordUntilPosition`, whose
* JSON wordPattern reaches across punctuation (docs/architecture/08 completion
* ranges) plus the comma affixes for the element the suggestion becomes.
*/
export function scaffoldSlotAt(
model: monaco.editor.ITextModel,
position: monaco.Position,
text: string,
offset: number,
): ScaffoldSlot {
const before = model.getLineContent(position.lineNumber).slice(0, position.column - 1);
const partial = /[A-Za-z]*$/.exec(before)?.[0] ?? '';
const range = new monaco.Range(
position.lineNumber,
position.column - partial.length,
position.lineNumber,
position.column,
);
const { lead, trail } = arrayAffixes(text, offset - partial.length, offset);
return { range, lead, trail };
}
/**
* A catalog entry as a snippet completion in `slot` comma-affixed to keep the array
* valid, and sorted by catalog index above the schema's generic items.
*/
export function scaffoldSuggestion(
slot: ScaffoldSlot,
index: number,
item: { label: string; detail: string; body: string; documentation?: monaco.IMarkdownString },
): monaco.languages.CompletionItem {
return {
label: item.label,
kind: monaco.languages.CompletionItemKind.Snippet,
detail: item.detail,
documentation: item.documentation,
insertText: `${slot.lead}${item.body}${slot.trail}`,
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
range: slot.range,
sortText: `0${String(index).padStart(2, '0')}`,
};
}
+82
View File
@@ -0,0 +1,82 @@
/**
* Extract-to-Dataset as an editor action (spec §03F; docs/architecture/08
* editor augmentation) the data-facing counterpart to the wrap/config actions.
*
* Resolves the embedded data of the **view the cursor sits in** (the nearest
* enclosing `data` binding, honoring Vega-Lite's parentchild inheritance), seeds
* the Extract modal with it, and opens the modal. The cursor picks which view to
* extract; a single-view spec resolves to the root binding from any cursor, so the
* common case needs no thought. Two binding shapes are liftable a view's inline
* `data.values`, and a `{ name }` reference to the spec's own top-level `datasets`
* (the data lives in the `datasets` map; we pre-fill the modal with its name).
*
* The toolbar offers the action whenever *some* view has extractable data
* (`hasExtractableData`); when the focused view itself has none it references a
* library dataset, a url, a generator there is nothing to lift here, so guide the
* user to a view that does rather than silently extract the wrong one.
*/
import type * as monaco from 'monaco-editor/esm/vs/editor/edcore.main';
import { pathAtOffset } from '@core/spec-cursor';
import { dataBindingAtPath } from '@core/spec-data';
import { inlineValuesOf, selfDefinedPayloadOf } from '@core/spec-inline-data';
import { openModal } from '../modals/ModalCoordinator';
import { type ExtractTarget, useExtractStore } from '../stores/ExtractStore';
import { notify } from '../stores/NotificationStore';
// TODO(ux-second-pass): toolbar-only — no F1-palette accelerator like the sibling
// wrap/config actions, since this opens a modal rather than an in-place edit.
/** Open Extract-to-Dataset scoped to the view at the cursor. */
export function runExtract(editor: monaco.editor.IStandaloneCodeEditor): void {
const model = editor.getModel();
if (!model) return;
const text = model.getValue();
let spec: unknown;
try {
spec = JSON.parse(text);
} catch {
notify({
kind: 'info',
title: 'Cant extract yet',
message: 'Fix the JSON so the spec parses, then extract.',
});
return;
}
const position = editor.getPosition();
const offset = position ? model.getOffsetAt(position) : 0;
const binding = dataBindingAtPath(spec, pathAtOffset(text, offset));
// Inline `values` → rewrite this view's data block; a self-defined `datasets`
// reference → lift the named entry and pre-fill the modal with that name.
const inline = binding && inlineValuesOf(binding.data);
const selfDefined = binding && !inline && selfDefinedPayloadOf(spec, binding.data);
let seed: {
source: ReturnType<typeof inlineValuesOf>;
target: ExtractTarget;
name?: string;
} | null = null;
if (binding && inline) {
seed = { source: inline, target: { kind: 'inline', anchorPath: binding.anchorPath } };
} else if (binding && selfDefined) {
const datasetName = (binding.data as { name: string }).name;
seed = {
source: selfDefined,
target: { kind: 'self-defined', datasetName },
name: datasetName,
};
}
if (!seed || !seed.source) {
notify({
kind: 'info',
title: 'No data to extract here',
message: 'Place the cursor in a view with inline or embedded data to extract it.',
});
return;
}
useExtractStore.getState().begin({ source: seed.source, target: seed.target, name: seed.name });
openModal('extract');
}
+11 -6
View File
@@ -40,11 +40,16 @@ import { useCustomThemeStore } from '../stores/CustomThemeStore';
import { notify } from '../stores/NotificationStore'; import { notify } from '../stores/NotificationStore';
import { selectActiveSnippet, useSnippetStore } from '../stores/SnippetStore'; import { selectActiveSnippet, useSnippetStore } from '../stores/SnippetStore';
/** Parse the model's JSON, or toast (and return null) when it isn't a JSON object. */ /**
function parseSpecObject(model: monaco.editor.ITextModel): Record<string, unknown> | null { * Parse spec JSON, or toast (and return null) when it isn't a JSON object. Takes
* the text (not the model) so it serves both whole-document and selection-scoped
* callers the config actions pass `model.getValue()`, the transform actions a
* selection.
*/
export function parseSpecObject(text: string): Record<string, unknown> | null {
let parsed: unknown; let parsed: unknown;
try { try {
parsed = JSON.parse(model.getValue()); parsed = JSON.parse(text);
} catch { } catch {
notify({ notify({
kind: 'error', kind: 'error',
@@ -57,7 +62,7 @@ function parseSpecObject(model: monaco.editor.ITextModel): Record<string, unknow
notify({ notify({
kind: 'error', kind: 'error',
title: 'Spec is not a JSON object', title: 'Spec is not a JSON object',
message: 'Config actions need a top-level { … } Vega-Lite spec.', message: 'This action needs a top-level { … } Vega-Lite spec.',
}); });
return null; return null;
} }
@@ -85,7 +90,7 @@ function replaceDocument(
export function runMergeChartTheme(editor: monaco.editor.IStandaloneCodeEditor): void { export function runMergeChartTheme(editor: monaco.editor.IStandaloneCodeEditor): void {
const model = editor.getModel(); const model = editor.getModel();
if (!model) return; if (!model) return;
const spec = parseSpecObject(model); const spec = parseSpecObject(model.getValue());
if (!spec) return; if (!spec) return;
const { chartTheme, uiTheme } = useAppStore.getState(); const { chartTheme, uiTheme } = useAppStore.getState();
@@ -122,7 +127,7 @@ function extractableConfig(editor: monaco.editor.IStandaloneCodeEditor): {
} | null { } | null {
const model = editor.getModel(); const model = editor.getModel();
if (!model) return null; if (!model) return null;
const spec = parseSpecObject(model); const spec = parseSpecObject(model.getValue());
if (!spec) return null; if (!spec) return null;
const { spec: rest, config } = extractConfigFromSpec(spec); const { spec: rest, config } = extractConfigFromSpec(spec);
+162
View File
@@ -0,0 +1,162 @@
/**
* Dataset-aware editor hints (docs/architecture/08 editor augmentation) the
* three things the Vega-Lite JSON schema *can't* know, because they depend on the
* user's data and this spec:
*
* - **Completion** in a `field` / `groupby` value, the bound dataset's real
* column names (plus the spec's derived fields). The schema only knows `field`
* takes a string; it can't list your columns. Enum values (`type`, `mark`, )
* are left to the schema we add only what it lacks, no second source.
* - **Hover** a column's inferred type + cardinality/range (from the stored
* profile). Monaco merges this with the schema's own hovers. (Expression-string
* hovers and completion live in services/spec-expression-hints.)
* - **Inlay hints** a faint `·<data-type>` beside each `field` (the column's
* raw type: number/string/date/boolean), annotation without touching the text.
* Deliberately the *data* type, not the encoding `type` they share the line,
* so a `: quantitative` here would read as annotating the adjacent `"type"`,
* which the hint never describes.
*
* All three read the active draft's bound dataset (services/active-dataset) and
* the cursor's JSON context (core/spec-cursor) at provide-time via `getState()`
* outside React. They register **once, globally for JSON** (like the schema and
* formatter), not per editor. Suggestion-only: over- or under-listing is
* harmless, which is why there is deliberately no "unknown field" diagnostic
* (that would false-positive on every data-dependent derived column).
*/
import * as monaco from 'monaco-editor/esm/vs/editor/edcore.main';
import { valueKeyAtOffset } from '@core/spec-cursor';
import { useSnippetStore } from '../stores/SnippetStore';
import {
availableFieldsAt,
dataInfoAt,
fieldTypeAt,
type DataInfo,
type FieldHint,
} from './active-dataset';
/** Property values that reference a data field (where column names belong). */
const FIELD_KEYS = new Set(['field', 'groupby']);
/** Markdown hover for a field hint: type + stats for source, a note for derived. */
function fieldHoverContents(hint: FieldHint, info: DataInfo): { value: string }[] {
if (hint.derived || hint.type === null) {
return [{ value: `**${hint.name}** · _derived by a transform_` }];
}
// The column's raw data type (number/string/date/boolean) — same vocabulary as
// the inlay hint, not the Vega-Lite encoding `type` the user declares.
const lines = [`**${hint.name}** · \`${hint.type}\``];
const stat = info.columnStats.find((s) => s.name === hint.name);
if (stat) {
if (stat.numericExtent)
lines.push(`Range ${stat.numericExtent.min} ${stat.numericExtent.max}`);
lines.push(`${stat.distinct}${stat.distinctCapped ? '+' : ''} distinct`);
}
lines.push(info.name ? `_from dataset “${info.name}”_` : '_from the specs inline data_');
return lines.map((value) => ({ value }));
}
let registered = false;
/** Register the dataset-aware completion / hover / inlay providers once. */
export function configureSpecDatasetHints(): void {
if (registered) return;
registered = true;
monaco.languages.registerCompletionItemProvider('json', {
triggerCharacters: ['"'],
provideCompletionItems(model, position) {
// Field suggestions only edit the draft; nothing to offer on the published view.
if (useSnippetStore.getState().editorView !== 'draft') return { suggestions: [] };
const text = model.getValue();
const offset = model.getOffsetAt(position);
const key = valueKeyAtOffset(text, offset);
if (key === null || !FIELD_KEYS.has(key)) return { suggestions: [] };
const fields = availableFieldsAt(text, offset);
if (fields.length === 0) return { suggestions: [] };
const word = model.getWordUntilPosition(position);
const range = new monaco.Range(
position.lineNumber,
word.startColumn,
position.lineNumber,
word.endColumn,
);
return {
suggestions: fields.map((f) => ({
label: f.name,
kind: f.derived
? monaco.languages.CompletionItemKind.Variable
: monaco.languages.CompletionItemKind.Field,
detail: f.derived || f.type === null ? 'derived field' : f.type,
insertText: f.name,
range,
})),
};
},
});
// All three providers annotate the draft only: they derive their field set from
// the draft buffer (dataInfoAt), so gating to the draft view keeps the hints
// consistent with the text they are computed from. The published view is a
// read-only reference, where field hints are marginal.
monaco.languages.registerHoverProvider('json', {
provideHover(model, position) {
if (useSnippetStore.getState().editorView !== 'draft') return null;
const text = model.getValue();
const offset = model.getOffsetAt(position);
// Over a field name: its type + stats, resolved at this view's data binding.
// (Expression-string hovers live in services/spec-expression-hints.)
const word = model.getWordAtPosition(position);
if (word) {
const info = dataInfoAt(text, offset);
const hint = info.fields.find((f) => f.name === word.word);
if (hint) {
return {
range: new monaco.Range(
position.lineNumber,
word.startColumn,
position.lineNumber,
word.endColumn,
),
contents: fieldHoverContents(hint, info),
};
}
}
return null;
},
});
monaco.languages.registerInlayHintsProvider('json', {
provideInlayHints(model, range) {
if (useSnippetStore.getState().editorView !== 'draft') return { hints: [], dispose() {} };
const text = model.getValue();
const hints: monaco.languages.InlayHint[] = [];
for (let line = range.startLineNumber; line <= range.endLineNumber; line++) {
// First `field` per line; the compact format keeps each encoding channel
// (and its lone field) on its own line, so one match per line suffices.
const match = /"field"\s*:\s*"([^"]+)"/.exec(model.getLineContent(line));
if (!match) continue;
// Resolve the type at this field's own data binding — views in a
// composition can bind different datasets. The offset points inside the
// field's value so the path resolves to its enclosing view.
const valueCol = match.index + match[0].length - match[1].length;
const offset = model.getOffsetAt({ lineNumber: line, column: valueCol });
const type = fieldTypeAt(text, offset, match[1]);
if (!type) continue; // unknown or derived (no type to annotate)
hints.push({
position: { lineNumber: line, column: match.index + match[0].length + 1 },
// The column's raw data type (number/string/date/boolean), not the
// Vega-Lite encoding `type`: a `: quantitative` here would read as an
// annotation of the adjacent `"type"`, which the hint never describes.
// The leading `·` marks it as a data fact about the field, not JSON syntax.
label: `·${type}`,
kind: monaco.languages.InlayHintKind.Type,
paddingLeft: true,
});
}
return { hints, dispose() {} };
},
});
}
+287
View File
@@ -0,0 +1,287 @@
/**
* Expression-aware editor intelligence (docs/architecture/08 editor
* augmentation) the help the Vega-Lite JSON schema *structurally can't* give,
* because the expression language lives inside opaque JSON strings (`calculate`,
* `filter`, `expr`, `test`):
*
* - **Completion** after `datum.` / `datum['…'`, the bound view's real column
* names; otherwise the expression language's functions (`if`, `datetime`, )
* and constants (`PI`, `E`), names derived from the parser itself
* (core/vega-expr-catalog).
* - **Signature help** parameter hints for the curated common functions, with
* the active argument tracked as you type past each comma.
* - **Hover** over an expression string, a live validity check
* (core/expr-validate, the same parser the chart uses).
* - **Markers** every expression string is parsed; a malformed one squiggles
* in place. This is the app's only editor-marker source besides the JSON
* worker, so it owns a distinct marker namespace (`vega-expr`).
*
* The three language providers register **once, globally for JSON** (like the
* schema, formatter, and dataset hints); the marker pass is **per editor**
* (it writes to a specific model and is torn down with it). All read the draft
* buffer and are gated to the draft view the published view is a read-only
* reference, where expression authoring help is marginal.
*/
import * as monaco from 'monaco-editor/esm/vs/editor/edcore.main';
import { activeCall, validateExpression } from '@core/expr-validate';
import { valueKeyAtOffset, stringValueAtOffset } from '@core/spec-cursor';
import { EXPRESSION_KEYS, expressionStringsIn } from '@core/spec-expressions';
import {
EXPR_CONSTANT_NAMES,
EXPR_FUNCTION_NAMES,
EXPR_SIGNATURES,
signatureLabel,
} from '@core/vega-expr-catalog';
import { availableFieldsAt, type FieldHint } from './active-dataset';
import { useSnippetStore } from '../stores/SnippetStore';
/** A name that can follow `datum.`; others (with spaces, etc.) need bracket access. */
const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
/** Cursor sitting in `datum.<partial>` — completing a field by dot access. */
const DATUM_DOT = /datum\.([A-Za-z0-9_$]*)$/;
/** Cursor sitting in `datum['<partial>` — completing a field by bracket access. */
const DATUM_BRACKET = /datum\[\s*['"]([^'"]*)$/;
/** Debounce for the marker recompute — responsive without thrashing on every key. */
const MARKER_DEBOUNCE_MS = 300;
/** Marker namespace, kept distinct from the JSON worker's own markers. */
const MARKER_OWNER = 'vega-expr';
/**
* The expression text from the start of the cursor's expression string up to the
* cursor, or null when the cursor is not inside one (or not on the draft view).
* Drives both completion ("what am I typing?") and signature help ("which call am
* I in?"). The containing string is located via the core enumerator so the prefix
* excludes the JSON `"key": "` framing.
*/
function expressionPrefixAt(
model: monaco.editor.ITextModel,
position: monaco.Position,
): string | null {
if (useSnippetStore.getState().editorView !== 'draft') return null;
const text = model.getValue();
const offset = model.getOffsetAt(position);
const key = valueKeyAtOffset(text, offset);
if (key === null || !EXPRESSION_KEYS.has(key)) return null;
for (const span of expressionStringsIn(text)) {
if (offset >= span.offset && offset <= span.offset + span.length) {
return text.slice(span.offset, offset);
}
}
return null;
}
/** A completion item for a data field (a real column or a transform-derived one). */
function fieldItem(field: FieldHint, range: monaco.IRange): monaco.languages.CompletionItem {
return {
label: field.name,
kind:
field.derived || field.type === null
? monaco.languages.CompletionItemKind.Variable
: monaco.languages.CompletionItemKind.Field,
detail: field.derived || field.type === null ? 'derived field' : field.type,
insertText: field.name,
range,
};
}
/** A completion item for an expression function — curated signature as the detail. */
function functionItem(name: string, range: monaco.IRange): monaco.languages.CompletionItem {
const sig = EXPR_SIGNATURES[name];
const noArgs = sig !== undefined && sig.params.length === 0;
return {
label: name,
kind: monaco.languages.CompletionItemKind.Function,
detail: sig ? signatureLabel(sig) : undefined,
documentation: sig ? { value: sig.doc } : undefined,
// Land the cursor inside the parens (snippet `$1`), unless the function takes
// no arguments — then close the call outright.
insertText: noArgs ? `${name}()` : `${name}($1)`,
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
range,
};
}
/** A completion item for an expression constant (`PI`, `E`, …). */
function constantItem(name: string, range: monaco.IRange): monaco.languages.CompletionItem {
return {
label: name,
kind: monaco.languages.CompletionItemKind.Constant,
insertText: name,
range,
};
}
let registered = false;
/** Register the expression completion / signature-help / hover providers once. */
export function configureSpecExpressionHints(): void {
if (registered) return;
registered = true;
monaco.languages.registerCompletionItemProvider('json', {
// `.` opens field completion after `datum`; the quote characters open it inside
// `datum['…']`; quick-suggest (strings:true) covers the function-name case.
triggerCharacters: ['.', '"', "'"],
provideCompletionItems(model, position) {
const prefix = expressionPrefixAt(model, position);
if (prefix === null) return { suggestions: [] };
// Build the replace range from the partial WE parse out of the prefix, never
// from Monaco's JSON word: that language's wordPattern treats `.` and `(` as
// word characters, so getWordUntilPosition after `datum.` (or `fn(`) returns
// the whole `datum.`/`fn(` token — which would both mis-target the edit and
// filter every suggestion out (none start with `datum.`).
const replaceRange = (partialLength: number): monaco.Range =>
new monaco.Range(
position.lineNumber,
position.column - partialLength,
position.lineNumber,
position.column,
);
const dot = DATUM_DOT.exec(prefix);
const bracket = dot ? null : DATUM_BRACKET.exec(prefix);
if (dot || bracket) {
const fields = availableFieldsAt(model.getValue(), model.getOffsetAt(position));
if (fields.length === 0) return { suggestions: [] };
// The partial typed after `datum.` / `datum['` — replace only that, leaving
// the `datum` token before it intact.
const partial = dot ? dot[1] : bracket![1];
const range = replaceRange(partial.length);
// Only identifier-safe names are usable after a dot; brackets take any name.
const candidates = dot ? fields.filter((f) => IDENTIFIER.test(f.name)) : fields;
return { suggestions: candidates.map((f) => fieldItem(f, range)) };
}
// Function / constant context: the partial is the trailing identifier (Monaco's
// JSON word would reach back across a preceding `(` and break filtering).
const ident = /[A-Za-z_$][A-Za-z0-9_$]*$/.exec(prefix);
const range = replaceRange(ident ? ident[0].length : 0);
const suggestions: monaco.languages.CompletionItem[] = [
...EXPR_FUNCTION_NAMES.map((name) => functionItem(name, range)),
...EXPR_CONSTANT_NAMES.map((name) => constantItem(name, range)),
{
label: 'datum',
kind: monaco.languages.CompletionItemKind.Keyword,
detail: 'the current data record',
insertText: 'datum',
range,
},
];
return { suggestions };
},
});
monaco.languages.registerSignatureHelpProvider('json', {
signatureHelpTriggerCharacters: ['(', ','],
signatureHelpRetriggerCharacters: [','],
provideSignatureHelp(model, position) {
const prefix = expressionPrefixAt(model, position);
if (prefix === null) return null;
const call = activeCall(prefix);
if (!call) return null;
const sig = EXPR_SIGNATURES[call.name];
if (!sig || sig.params.length === 0) return null;
const info: monaco.languages.SignatureInformation = {
label: signatureLabel(sig),
documentation: { value: sig.doc },
parameters: sig.params.map((p, i) => ({
// The label must be a substring of the signature label so Monaco can
// highlight the active parameter — `...rest` for the variadic tail.
label: sig.variadic && i === sig.params.length - 1 ? `...${p}` : p,
})),
};
// A variadic tail keeps highlighting its last parameter past the final comma.
const activeParameter = sig.variadic
? Math.min(call.activeParam, sig.params.length - 1)
: call.activeParam;
return { value: { signatures: [info], activeSignature: 0, activeParameter }, dispose() {} };
},
});
monaco.languages.registerHoverProvider('json', {
provideHover(model, position) {
if (useSnippetStore.getState().editorView !== 'draft') return null;
const text = model.getValue();
const offset = model.getOffsetAt(position);
const key = valueKeyAtOffset(text, offset);
if (key === null || !EXPRESSION_KEYS.has(key)) return null;
const expr = stringValueAtOffset(text, offset);
if (expr === null) return null;
const result = validateExpression(expr);
return {
contents: [
{
value: result.valid
? '✓ Valid Vega expression'
: `${result.error ?? 'Invalid expression'}`,
},
],
};
},
});
}
/**
* Validate every expression string in this editor's model and squiggle the invalid
* ones (per editor it writes markers to one model). Recomputes debounced on edit,
* and on a draftpublished toggle so the draft-only gate is honored even when the
* two buffers are identical. Disposed with the editor; clears its markers on the
* way out.
*/
export function installExpressionMarkers(
editor: monaco.editor.IStandaloneCodeEditor,
): monaco.IDisposable {
let timer: ReturnType<typeof setTimeout> | null = null;
const recompute = (): void => {
const model = editor.getModel();
if (!model) return;
// The published view is a read-only reference — no authoring markers there.
if (useSnippetStore.getState().editorView !== 'draft') {
monaco.editor.setModelMarkers(model, MARKER_OWNER, []);
return;
}
const text = model.getValue();
const markers: monaco.editor.IMarkerData[] = [];
for (const span of expressionStringsIn(text)) {
if (span.length === 0) continue; // an empty expression isn't an error
const result = validateExpression(span.value);
if (result.valid) continue;
const start = model.getPositionAt(span.offset);
const end = model.getPositionAt(span.offset + span.length);
markers.push({
severity: monaco.MarkerSeverity.Error,
message: result.error ?? 'Invalid Vega expression.',
startLineNumber: start.lineNumber,
startColumn: start.column,
endLineNumber: end.lineNumber,
endColumn: end.column,
});
}
monaco.editor.setModelMarkers(model, MARKER_OWNER, markers);
};
const schedule = (): void => {
if (timer) clearTimeout(timer);
timer = setTimeout(recompute, MARKER_DEBOUNCE_MS);
};
const contentSub = editor.onDidChangeModelContent(schedule);
const viewSub = useSnippetStore.subscribe((s, prev) => {
if (s.editorView !== prev.editorView) recompute();
});
recompute(); // initial pass
return {
dispose() {
if (timer) clearTimeout(timer);
contentSub.dispose();
viewSub();
const model = editor.getModel();
if (model) monaco.editor.setModelMarkers(model, MARKER_OWNER, []);
},
};
}
+169
View File
@@ -0,0 +1,169 @@
/**
* Parameter scaffolding for the editor (docs/architecture/08 editor augmentation)
* the "quick parameter" affordance, sibling of `spec-transform-scaffold`. It offers
* Vega-Lite parameters as ready-to-fill snippets: **variable** widgets (slider,
* dropdown, radio, checkbox bound via `bind`) and **selection** parameters (point,
* interval via `select`), each seeded from the data in scope (a slider's bounds from
* the numeric field's extent, a point selection's field from a categorical column).
* The facts the schema can't supply *where each family legally attaches* and *what a
* seeded skeleton looks like* are pure core (`core/spec-params`); this is the Monaco
* glue.
*
* **Two surfaces, mirroring `spec-transform-scaffold`:**
* - the **CodeLens** (`installSpecParamScaffoldCodeLens`, per editor) is the
* discoverable home. Because the two families attach to different homes variable
* parameters are document-global (only legal in the root `params[]`), selections
* belong to the unit whose marks they read the *kind* is chosen at the lens, and
* the lens is cursor-scoped by family: at the top level (or a single-view spec) the
* variable widgets show; inside a unit the selections show; a single-view spec, where
* the root *is* the unit, shows both on the one line. Clicking creates `params: []`
* with the entry, or appends to an existing array.
* - the **completion** (`configureSpecParamScaffold`, global-once) is the
* type-to-filter accelerator, offering both families in the root array and
* selections only in a nested unit's.
*
* Both are gated to the active draft. This is the home the schema leaves bare: the
* schema completes the `params` key and its enum values, but never a seeded slider with
* your column's real range or a `select` wired to the view.
*/
import * as monaco from 'monaco-editor/esm/vs/editor/edcore.main';
import { appendEntryEdit, createArrayPropertyEdit } from '@core/spec-snippet';
import {
PARAMS,
type ParamFamily,
type ParamHost,
type ParamSite,
paramPlacementAt,
paramSiteAt,
} from '@core/spec-params';
import { boundParamFieldsAt } from './active-dataset';
import { installCursorLens } from './editor-cursor-lens';
import { insertSnippetAt, scaffoldSlotAt, scaffoldSuggestion } from './editor-snippet';
import { useSnippetStore } from '../stores/SnippetStore';
/** The kinds surfaced as CodeLens buttons — the common few per family; the completion has the rest. */
const COMMON_VARIABLE_IDS = ['slider', 'dropdown'] as const;
const COMMON_SELECTION_IDS = ['point', 'interval'] as const;
let registered = false;
/** Register the `params[]` scaffold completion provider once. */
export function configureSpecParamScaffold(): void {
if (registered) return;
registered = true;
monaco.languages.registerCompletionItemProvider('json', {
provideCompletionItems(model, position) {
// Scaffolding only edits the draft; the published view is read-only.
if (useSnippetStore.getState().editorView !== 'draft') return { suggestions: [] };
const text = model.getValue();
const offset = model.getOffsetAt(position);
const placement = paramPlacementAt(text, offset);
if (placement.kind !== 'slot') return { suggestions: [] };
const slot = scaffoldSlotAt(model, position, text, offset);
const fields = boundParamFieldsAt(text, offset);
// The root array takes both families; a nested unit's takes selections only.
const kinds = placement.atRoot ? PARAMS : PARAMS.filter((p) => p.family === 'selection');
const suggestions = kinds.map((p, i) =>
scaffoldSuggestion(slot, i, { label: p.label, detail: p.detail, body: p.param(fields) }),
);
return { suggestions };
},
});
}
/**
* Add a parameter of `id`/`family` to its home the root (variable) or the unit
* (selection) at `anchorOffset`. Re-resolves the site from live text (the lens args may
* be a version stale), then appends to an existing `params[]` or creates one with this
* entry as its first element. Both go through Monaco's snippet engine so the entry's tab
* stops are Tab-through.
*/
function runAddParam(
editor: monaco.editor.IStandaloneCodeEditor,
id: string,
family: ParamFamily,
anchorOffset: number,
): void {
const model = editor.getModel();
if (!model) return;
const text = model.getValue();
const site = paramSiteAt(text, anchorOffset + 1);
const host = family === 'variable' ? site?.root : site?.unit;
const kind = PARAMS.find((p) => p.id === id);
if (!host || !kind) return;
const entry = kind.param(boundParamFieldsAt(text, anchorOffset + 1));
// Append to the existing `params[]`, or create one with this entry as its first
// element — placement, indentation, and commas are the core edits' tested math.
const edit = host.array
? appendEntryEdit(text, host.array, entry)
: createArrayPropertyEdit(
text,
host.view.offset,
model.getOptions().tabSize || 2,
'params',
entry,
);
insertSnippetAt(editor, edit.offset, edit.snippet);
}
/**
* Install the cursor-aware parameter-scaffold CodeLens (per editor its command needs
* this editor's handle to apply the edit). Variable widgets show at the root unless the
* cursor is inside a nested unit; selections show on the unit the cursor is in. Returns
* a disposable; dispose on unmount.
*/
export function installSpecParamScaffoldCodeLens(
editor: monaco.editor.IStandaloneCodeEditor,
): monaco.IDisposable {
const addParamCmd = editor.addCommand(
0,
(_a, id: string, family: ParamFamily, anchorOffset: number) =>
runAddParam(editor, id, family, anchorOffset),
);
return installCursorLens<ParamSite>(editor, {
resolve: paramSiteAt,
// Refresh when either home's presence or param count changes, not on every keystroke.
keyOf: (site) =>
JSON.stringify([
site.root.view.offset,
site.root.array?.count ?? -1,
site.unit?.view.offset ?? -1,
site.unit?.array?.count ?? -1,
]),
lensesOf: (site, model, lens) => {
// Anchor on the params array's line when it exists, else the object's opening line.
const lineFor = (host: ParamHost) =>
model.getPositionAt(host.array ? host.array.offset : host.view.offset).lineNumber;
const lenses: monaco.languages.CodeLens[] = [];
// Variable widgets are root-only; hide them when the cursor is in a nested unit,
// where only selections are legal.
const inNestedUnit = !!site.unit && site.unit.view.offset !== site.root.view.offset;
if (!inNestedUnit) {
const line = lineFor(site.root);
for (const id of COMMON_VARIABLE_IDS) {
lenses.push(
lens(line, `$(add) ${id}`, addParamCmd, [id, 'variable', site.root.view.offset]),
);
}
}
if (site.unit) {
const line = lineFor(site.unit);
for (const id of COMMON_SELECTION_IDS) {
lenses.push(
lens(line, `$(add) ${id}`, addParamCmd, [id, 'selection', site.unit.view.offset]),
);
}
}
return lenses;
},
});
}
+663
View File
@@ -0,0 +1,663 @@
/**
* Structural spec transforms as editor actions (docs/architecture/08 editor
* augmentation) the refactor counterpart to spec-config-actions. Wrap the
* focused view in a composition (layer / hconcat / vconcat / facet / repeat) or
* collapse a single-child composition back to a unit, over the portable core
* transforms (core/spec-transforms).
*
* **Scope** is the current selection when there is one (the user said exactly
* what to target); otherwise the view the cursor sits in an element of a
* layer/concat or a facet/repeat child, resolved by core/spec-cursor falling
* back to the whole document for a flat unit spec with no inner view.
*
* **Surfacing** mirrors spec-config-actions' three-tier model:
* - the editor toolbar's **Compose menu** (SpecEditor) is the discoverable
* home, calling `runWrap` / `runUnwrap`;
* - the **lightbulb** (`configureSpecTransformCodeActions`, registered once,
* global per-language) offers the same transforms contextually at the cursor;
* - the **F1 palette** (`installSpecTransformActions`, per editor) is the
* keyboard accelerator. These are *not* added to the right-click menu the
* lightbulb already covers the in-place case, and config-actions hold the
* three context-menu slots; nine items there would be a thicket.
*
* Edits go through `executeEdits` (toolbar/palette) or a `WorkspaceEdit` (the
* lightbulb, which has no editor handle) both build the replacement text via
* the one `buildNext` + `formatScoped` path, so there is a single transform
* path, only the application differs. Z restores the previous text; invalid
* JSON no-ops with a toast; `!editorReadonly` hides the actions on the published
* view, and the lightbulb is gated to the active draft.
*/
import * as monaco from 'monaco-editor/esm/vs/editor/edcore.main';
import { defaultFieldType } from '@core/chart-builder';
import { formatJson } from '@core/json-format';
import { isJsonObject, type JsonObject } from '@core/spec-config';
import { findViewRange } from '@core/spec-cursor';
import {
type CompositionTarget,
compositionTargetAt,
elementOffset,
insertView,
moveView,
moveViewTo,
type SpecPath,
} from '@core/spec-insert';
import { simplifyStructure, wrapContainer, wrapViews, type DropAxis } from '@core/spec-restructure';
import {
unwrapSingleton,
wrapInConcat,
wrapInFacet,
wrapInLayer,
wrapInRepeat,
} from '@core/spec-transforms';
import { notify } from '../stores/NotificationStore';
import { useSnippetStore } from '../stores/SnippetStore';
import { boundColumnsAt } from './active-dataset';
import { installCursorLens } from './editor-cursor-lens';
import { parseSpecObject } from './spec-config-actions';
/** The composition operators the wrap actions offer. */
type WrapKind = 'layer' | 'hconcat' | 'vconcat' | 'facet' | 'repeat';
/** The slice of the document a transform reads and rewrites. */
interface Scope {
range: monaco.Range;
text: string;
/** Whole-document offset of the focused view, for resolving its data binding. */
offset: number;
/** Column the slice starts at (0-based), so re-indented output stays aligned. */
baseCol: number;
}
const isEmptyRange = (r: monaco.IRange): boolean =>
r.startLineNumber === r.endLineNumber && r.startColumn === r.endColumn;
/** The whole document, as a scope. */
function wholeDocument(model: monaco.editor.ITextModel): Scope {
const range = model.getFullModelRange();
return { range, text: model.getValue(), offset: 0, baseCol: 0 };
}
/**
* The slice a transform acts on: an explicit selection if present; else the view
* the cursor sits in (core/spec-cursor); else the whole document. `offset` is the
* cursor/selection-start position in the whole document, used to resolve the
* focused view's data binding (facet/repeat field defaults).
*/
function resolveScope(model: monaco.editor.ITextModel, range: monaco.IRange | null): Scope {
if (!range) return wholeDocument(model);
const offset = model.getOffsetAt({
lineNumber: range.startLineNumber,
column: range.startColumn,
});
if (!isEmptyRange(range)) {
const r = monaco.Range.lift(range);
return { range: r, text: model.getValueInRange(r), offset, baseCol: r.startColumn - 1 };
}
const node = findViewRange(model.getValue(), offset);
if (!node) return wholeDocument(model);
const start = model.getPositionAt(node.offset);
const end = model.getPositionAt(node.offset + node.length);
const r = new monaco.Range(start.lineNumber, start.column, end.lineNumber, end.column);
return { range: r, text: model.getValueInRange(r), offset, baseCol: start.column - 1 };
}
/** Indent every line after the first by `baseCol`, so a scoped edit stays aligned. */
function reindent(text: string, baseCol: number): string {
if (baseCol <= 0) return text;
const pad = ' '.repeat(baseCol);
return text
.split('\n')
.map((line, i) => (i === 0 ? line : pad + line))
.join('\n');
}
/** Serialize the replacement in the app's compact JSON style, aligned to the scope. */
function formatScoped(model: monaco.editor.ITextModel, scope: Scope, next: JsonObject): string {
const raw = JSON.stringify(next);
const formatted = formatJson(raw, { indent: model.getOptions().tabSize }) ?? raw;
return reindent(formatted, scope.baseCol);
}
/** A categorical column to facet by (first nominal/ordinal), or a placeholder. */
function defaultFacet(text: string, offset: number): { field: string; type: string } {
const cols = boundColumnsAt(text, offset);
const categorical = cols.find((c) => {
const t = defaultFieldType(c.type);
return t === 'nominal' || t === 'ordinal';
});
const col = categorical ?? cols[0];
return col
? { field: col.name, type: defaultFieldType(col.type) }
: { field: 'field', type: 'nominal' };
}
/** Quantitative columns to repeat over + the channel to rewire, with fallbacks. */
function defaultRepeat(
spec: JsonObject,
text: string,
offset: number,
): { fields: string[]; channel: string | null } {
const cols = boundColumnsAt(text, offset);
const numeric = cols
.filter((c) => defaultFieldType(c.type) === 'quantitative')
.map((c) => c.name);
const fields = numeric.length > 0 ? numeric.slice(0, 3) : cols.slice(0, 2).map((c) => c.name);
const encoding = isJsonObject(spec.encoding) ? spec.encoding : null;
const channel = encoding ? ('y' in encoding ? 'y' : (Object.keys(encoding)[0] ?? null)) : null;
return { fields: fields.length > 0 ? fields : ['field1', 'field2'], channel };
}
/**
* Apply a wrap of the given kind to the parsed (scoped) spec. `text`/`offset` are
* the whole document and the focused view's position, used to resolve that view's
* data binding for the facet/repeat field defaults.
*/
function buildNext(spec: JsonObject, kind: WrapKind, text: string, offset: number): JsonObject {
switch (kind) {
case 'layer':
return wrapInLayer(spec);
case 'hconcat':
return wrapInConcat(spec, 'h');
case 'vconcat':
return wrapInConcat(spec, 'v');
case 'facet': {
const { field, type } = defaultFacet(text, offset);
return wrapInFacet(spec, field, type);
}
case 'repeat': {
const { fields, channel } = defaultRepeat(spec, text, offset);
return wrapInRepeat(spec, fields, channel);
}
}
}
const WRAP_NOUN: Record<WrapKind, string> = {
layer: 'a layer',
hconcat: 'a horizontal concat',
vconcat: 'a vertical concat',
facet: 'a facet',
repeat: 'a repeat',
};
/**
* Replace the scope as one undoable edit (the toolbar / palette path). `focus`
* returns focus to the editor afterwards true for editor-originated actions,
* false when the wireframe drove the edit and must keep its own focus (APG: a
* keyboard reorder stays on the moved item for consecutive moves).
*/
function writeBack(
editor: monaco.editor.IStandaloneCodeEditor,
range: monaco.Range,
text: string,
focus = true,
): void {
editor.pushUndoStop();
editor.executeEdits('spec-transform', [{ range, text }]);
editor.pushUndoStop();
if (focus) editor.focus();
}
/**
* The model, focused scope, and the spec parsed off it the shared prologue of
* the scoped actions, or null (after toasting on invalid JSON) when there is
* nothing to act on.
*/
function resolveTarget(
editor: monaco.editor.IStandaloneCodeEditor,
): { model: monaco.editor.ITextModel; scope: Scope; spec: JsonObject } | null {
const model = editor.getModel();
if (!model) return null;
const scope = resolveScope(model, editor.getSelection());
const spec = parseSpecObject(scope.text);
return spec ? { model, scope, spec } : null;
}
/** Wrap the focused view (selection, else whole document) in a composition. */
export function runWrap(editor: monaco.editor.IStandaloneCodeEditor, kind: WrapKind): void {
const target = resolveTarget(editor);
if (!target) return;
const { model, scope, spec } = target;
const next = buildNext(spec, kind, model.getValue(), scope.offset);
writeBack(editor, scope.range, formatScoped(model, scope, next));
notify({
kind: 'success',
title: 'View wrapped',
message: `Wrapped in ${WRAP_NOUN[kind]}. Undo with ⌘/Ctrl+Z.`,
});
}
/** Collapse a single-child layer/concat in the focused scope back to a unit. */
export function runUnwrap(editor: monaco.editor.IStandaloneCodeEditor): void {
const target = resolveTarget(editor);
if (!target) return;
const { model, scope, spec } = target;
const next = unwrapSingleton(spec);
if (!next) {
notify({
kind: 'info',
title: 'Nothing to simplify',
message: 'Select a layer or concat with a single child to collapse it.',
});
return;
}
writeBack(editor, scope.range, formatScoped(model, scope, next));
notify({
kind: 'success',
title: 'Composition simplified',
message: 'Collapsed the single-child composition. Undo with ⌘/Ctrl+Z.',
});
}
/**
* Apply a structural array edit (insert / move) as one undoable whole-document
* rewrite, then put the cursor on the affected view so the lenses re-anchor to it
* and a repeated click keeps acting on the same view. `followIndex` is the view's
* index in `arrayPath` *after* the edit. Reformatted in the app's compact style,
* which is idempotent on an already-formatted draft.
*
* `successTitle` of null suppresses the success toast (the wireframe gives its own
* feedback visible move + live-region announcement so a per-move toast would
* double up); `focusEditor` of false keeps focus off the editor for the same path.
*/
function applyArrayEdit(
editor: monaco.editor.IStandaloneCodeEditor,
build: (spec: JsonObject) => JsonObject | null,
arrayPath: SpecPath,
followIndex: number,
successTitle: string | null,
focusEditor = true,
): void {
const model = editor.getModel();
if (!model) return;
const spec = parseSpecObject(model.getValue());
if (!spec) return;
const next = build(spec);
if (!next) {
notify({
kind: 'info',
title: 'Could not change the composition',
message: 'The composition changed — try the affordance again.',
});
return;
}
const formatted = formatScoped(model, wholeDocument(model), next);
writeBack(editor, model.getFullModelRange(), formatted, focusEditor);
const offset = elementOffset(formatted, arrayPath, followIndex);
if (offset !== null) {
const pos = model.getPositionAt(offset);
editor.setPosition(pos);
editor.revealPositionInCenterIfOutsideViewport(pos);
}
if (successTitle)
notify({ kind: 'success', title: successTitle, message: 'Undo with ⌘/Ctrl+Z.' });
}
/** Insert an empty view at `index` of the composition at `arrayPath`. */
function runAddView(
editor: monaco.editor.IStandaloneCodeEditor,
arrayPath: SpecPath,
index: number,
): void {
applyArrayEdit(editor, (s) => insertView(s, arrayPath, index), arrayPath, index, 'View added');
}
/** Swap the view at `index` with its sibling `delta` steps away (±1). */
function runMoveView(
editor: monaco.editor.IStandaloneCodeEditor,
arrayPath: SpecPath,
index: number,
delta: number,
): void {
applyArrayEdit(
editor,
(s) => moveView(s, arrayPath, index, delta),
arrayPath,
index + delta,
delta < 0 ? 'View moved up' : 'View moved down',
);
}
/**
* Reorder the view at `from` to `to` within the composition at `arrayPath` the
* composition wireframe's drag/keyboard reorder. No success toast and no editor
* focus-steal: the wireframe owns the feedback and keeps focus on the moved box.
*/
export function runMoveViewTo(
editor: monaco.editor.IStandaloneCodeEditor,
arrayPath: SpecPath,
from: number,
to: number,
): void {
applyArrayEdit(editor, (s) => moveViewTo(s, arrayPath, from, to), arrayPath, to, null, false);
}
/**
* Apply a whole-spec structural edit: parse the draft, `build` the next spec, and
* write it back as one undoable edit or surface the "could not restructure" info
* toast when `build` returns null (a degenerate or stale drop). The whole-spec sibling
* of `applyArrayEdit`, for the wireframe's drag/simplify family no cursor-follow and
* no success toast, since the wireframe gives its own feedback. `notifyOnNull` of false
* stays silent: a no-op (e.g. Simplify with nothing redundant) is not a failure.
*/
function applyWholeSpecEdit(
editor: monaco.editor.IStandaloneCodeEditor,
build: (spec: JsonObject) => JsonObject | null,
notifyOnNull = true,
): void {
const model = editor.getModel();
if (!model) return;
const spec = parseSpecObject(model.getValue());
if (!spec) return;
const next = build(spec);
if (!next) {
if (notifyOnNull)
notify({
kind: 'info',
title: 'Could not restructure',
message: 'That drop isnt possible here — the composition may have changed.',
});
return;
}
writeBack(
editor,
model.getFullModelRange(),
formatScoped(model, wholeDocument(model), next),
false,
);
}
/**
* Restructure the spec by pairing the dragged `sourcePath` view beside the drop
* `targetPath` view in a new concat of `axis` the wireframe's cross-container drag
* (wrap, move-in, collapse, all in core/spec-restructure).
*/
export function runWrapViews(
editor: monaco.editor.IStandaloneCodeEditor,
targetPath: SpecPath,
sourcePath: SpecPath,
axis: DropAxis,
side: 'before' | 'after',
): void {
applyWholeSpecEdit(editor, (s) => wrapViews(s, targetPath, sourcePath, axis, side));
}
/**
* Pull the dragged `sourcePath` view out into a new full-span row/column around the
* whole `containerPath` container the wireframe's frame-margin drag (core
* `wrapContainer`, the complement to the edge-drop `wrapViews`).
*/
export function runWrapContainer(
editor: monaco.editor.IStandaloneCodeEditor,
containerPath: SpecPath,
sourcePath: SpecPath,
axis: DropAxis,
side: 'before' | 'after',
): void {
applyWholeSpecEdit(editor, (s) => wrapContainer(s, containerPath, sourcePath, axis, side));
}
/**
* Collapse every redundant single-child composition in the spec (core
* `simplifyStructure`) the wireframe's Simplify action, offered when it detects a
* `{hconcat: [oneView]}`-style wrapper. A no-op (silent) when nothing is redundant.
*/
export function runSimplifyStructure(editor: monaco.editor.IStandaloneCodeEditor): void {
applyWholeSpecEdit(editor, simplifyStructure, false);
}
/** Insert a view above/below the one the cursor is in (the keyboard path). */
function runInsertRelative(
editor: monaco.editor.IStandaloneCodeEditor,
where: 'above' | 'below',
): void {
const model = editor.getModel();
const pos = editor.getPosition();
if (!model || !pos) return;
const target = compositionTargetAt(model.getValue(), model.getOffsetAt(pos));
if (!target) {
notify({
kind: 'info',
title: 'No composition here',
message: 'Place the cursor in a view inside a layer or concat.',
});
return;
}
if (!target.element) {
runAddView(editor, target.arrayPath, 0); // empty composition → its first view
return;
}
runAddView(
editor,
target.arrayPath,
where === 'above' ? target.element.index : target.element.index + 1,
);
}
/** Move the view the cursor is in up/down among its siblings (the keyboard path). */
function runMoveRelative(editor: monaco.editor.IStandaloneCodeEditor, delta: number): void {
const model = editor.getModel();
const pos = editor.getPosition();
if (!model || !pos) return;
const target = compositionTargetAt(model.getValue(), model.getOffsetAt(pos));
if (!target?.element) {
notify({
kind: 'info',
title: 'No view to move',
message: 'Place the cursor in a view inside a layer or concat.',
});
return;
}
const to = target.element.index + delta;
if (to < 0 || to >= target.count) {
notify({
kind: 'info',
title: delta < 0 ? 'Already first' : 'Already last',
message: 'This view is at the edge of its composition.',
});
return;
}
runMoveView(editor, target.arrayPath, target.element.index, delta);
}
/**
* Install the cursor-aware composition CodeLens (per editor the lens commands
* need this editor's handle to apply the edit). Over the view the cursor sits in
* it offers ` Add view above/below` at the view's edges and `↑/↓ Move` to
* reorder it among its siblings; an empty composition gets a single ` Add view`.
* Returns a disposable; dispose on unmount.
*/
export function installSpecTransformCodeLens(
editor: monaco.editor.IStandaloneCodeEditor,
): monaco.IDisposable {
const addCmd = editor.addCommand(0, (_a, path: SpecPath, index: number) =>
runAddView(editor, path, index),
);
const moveCmd = editor.addCommand(0, (_a, path: SpecPath, index: number, delta: number) =>
runMoveView(editor, path, index, delta),
);
return installCursorLens<CompositionTarget>(editor, {
resolve: compositionTargetAt,
// Refresh when the enclosing view or the sibling count changes, not on every keystroke.
keyOf: (target) =>
JSON.stringify([target.arrayPath, target.element?.index ?? -1, target.count]),
lensesOf: (target, model, lens) => {
if (!target.element) {
// Off a view, only an empty composition gets an affordance — the array's
// own line stays clean otherwise.
if (target.count !== 0) return [];
const line = model.getPositionAt(target.arrayOffset).lineNumber;
return [lens(line, '$(add) Add view', addCmd, [target.arrayPath, 0])];
}
const { index, offset, length } = target.element;
// "above" actions sit on the view's first line, "below" on the line after
// its last — so up-actions group at the top, down-actions at the bottom.
const topLine = model.getPositionAt(offset).lineNumber;
const bottomLine = Math.min(
model.getLineCount(),
model.getPositionAt(offset + length).lineNumber + 1,
);
const lenses: monaco.languages.CodeLens[] = [
lens(topLine, '$(add) Add view above', addCmd, [target.arrayPath, index]),
];
if (index > 0)
lenses.push(lens(topLine, '$(arrow-up) Move up', moveCmd, [target.arrayPath, index, -1]));
lenses.push(lens(bottomLine, '$(add) Add view below', addCmd, [target.arrayPath, index + 1]));
if (index < target.count - 1)
lenses.push(
lens(bottomLine, '$(arrow-down) Move down', moveCmd, [target.arrayPath, index, 1]),
);
return lenses;
},
});
}
/**
* Register the F1-palette actions on the editor (the keyboard accelerator).
* Returns a disposable; dispose on editor unmount, like the other per-editor
* installs. Deliberately no `contextMenuGroupId` see the module header.
*/
export function installSpecTransformActions(
editor: monaco.editor.IStandaloneCodeEditor,
): monaco.IDisposable {
const actions = [
editor.addAction({
id: 'astrolabe.wrap-layer',
label: 'Wrap View in a Layer',
precondition: '!editorReadonly',
run: () => runWrap(editor, 'layer'),
}),
editor.addAction({
id: 'astrolabe.wrap-hconcat',
label: 'Wrap View in Horizontal Concat',
precondition: '!editorReadonly',
run: () => runWrap(editor, 'hconcat'),
}),
editor.addAction({
id: 'astrolabe.wrap-vconcat',
label: 'Wrap View in Vertical Concat',
precondition: '!editorReadonly',
run: () => runWrap(editor, 'vconcat'),
}),
editor.addAction({
id: 'astrolabe.wrap-facet',
label: 'Wrap View in a Facet',
precondition: '!editorReadonly',
run: () => runWrap(editor, 'facet'),
}),
editor.addAction({
id: 'astrolabe.wrap-repeat',
label: 'Wrap View in a Repeat',
precondition: '!editorReadonly',
run: () => runWrap(editor, 'repeat'),
}),
editor.addAction({
id: 'astrolabe.unwrap',
label: 'Simplify Single-Child Composition',
precondition: '!editorReadonly',
run: () => runUnwrap(editor),
}),
editor.addAction({
id: 'astrolabe.insert-view-above',
label: 'Insert View Above',
precondition: '!editorReadonly',
run: () => runInsertRelative(editor, 'above'),
}),
editor.addAction({
id: 'astrolabe.insert-view-below',
label: 'Insert View Below',
precondition: '!editorReadonly',
run: () => runInsertRelative(editor, 'below'),
}),
editor.addAction({
id: 'astrolabe.move-view-up',
label: 'Move View Up',
precondition: '!editorReadonly',
run: () => runMoveRelative(editor, -1),
}),
editor.addAction({
id: 'astrolabe.move-view-down',
label: 'Move View Down',
precondition: '!editorReadonly',
run: () => runMoveRelative(editor, 1),
}),
];
return {
dispose() {
for (const action of actions) action.dispose();
},
};
}
/**
* The lightbulb has no editor handle, so it returns a `WorkspaceEdit` instead of
* calling `executeEdits`; both paths build the replacement through `buildNext` +
* `formatScoped`.
*/
function editAction(
model: monaco.editor.ITextModel,
scope: Scope,
title: string,
next: JsonObject,
): monaco.languages.CodeAction {
return {
title,
kind: 'refactor.rewrite',
edit: {
edits: [
{
resource: model.uri,
versionId: model.getVersionId(),
textEdit: { range: scope.range, text: formatScoped(model, scope, next) },
},
],
},
};
}
let codeActionsRegistered = false;
/**
* Register the wrap/simplify refactors as code actions (the lightbulb) once,
* globally for JSON like the schema and formatter, not per editor. Idempotent.
*/
export function configureSpecTransformCodeActions(): void {
if (codeActionsRegistered) return;
codeActionsRegistered = true;
monaco.languages.registerCodeActionProvider('json', {
provideCodeActions(model, range) {
const empty = { actions: [], dispose() {} };
// Global provider, no editor handle: gate on the store the way the run*
// path is gated by `!editorReadonly` — only on the active snippet's draft.
const snippet = useSnippetStore.getState();
if (snippet.activeSnippetId === null || snippet.editorView !== 'draft') return empty;
const scope = resolveScope(model, range);
let spec: unknown;
try {
spec = JSON.parse(scope.text);
} catch {
return empty;
}
if (!isJsonObject(spec)) return empty;
const viewSpec = spec;
const fullText = model.getValue();
const build = (kind: WrapKind) => buildNext(viewSpec, kind, fullText, scope.offset);
const actions: monaco.languages.CodeAction[] = [
editAction(model, scope, 'Wrap view in a layer', build('layer')),
editAction(model, scope, 'Wrap view in horizontal concat', build('hconcat')),
editAction(model, scope, 'Wrap view in vertical concat', build('vconcat')),
editAction(model, scope, 'Wrap view in a facet', build('facet')),
editAction(model, scope, 'Wrap view in a repeat', build('repeat')),
];
const collapsed = unwrapSingleton(spec);
if (collapsed)
actions.push(editAction(model, scope, 'Simplify single-child composition', collapsed));
return { actions, dispose() {} };
},
});
}
+155
View File
@@ -0,0 +1,155 @@
/**
* Data-transform scaffolding for the editor (docs/architecture/08 editor
* augmentation) the "quick transform" affordance. It offers the popular Vega-Lite
* transforms (filter, calculate, aggregate, bin, timeUnit, ) as ready-to-fill
* snippets, each seeded with a type-appropriate column from the data in scope. The
* facts the schema can't supply — *where a view's pipeline is* and *what a field-typed
* skeleton looks like* are pure core (`core/spec-data-transforms`); this is the thin
* Monaco glue.
*
* **Two surfaces, mirroring the composition transforms (`spec-transform-actions`):**
* - the **CodeLens** (`installSpecTransformScaffoldCodeLens`, per editor its
* commands need the editor handle) is the *discoverable* home: an ` Add
* transform` lens on a view with no pipeline, and per-step ` filter`/`
* aggregate`/… lenses on the array once it exists. Clicking splices the step in
* via Monaco's snippet engine, so the field-typed placeholders are Tab-through.
* - the **completion** (`configureSpecTransformScaffold`, global-once) is the
* type-to-filter *accelerator* on the same catalog, for when the cursor is
* already in a `transform[]` slot.
*
* Both are gated to the active draft the published view is a read-only reference.
* The scaffold is deliberately the one home the schema leaves bare: the schema already
* completes the inline channel transforms (`bin`/`timeUnit` on an encoding field) and
* the `transform` key itself, so this adds no second source there.
*/
import * as monaco from 'monaco-editor/esm/vs/editor/edcore.main';
import {
DATA_TRANSFORMS,
transformPlacementAt,
transformSiteAt,
type TransformSite,
} from '@core/spec-data-transforms';
import { appendEntryEdit, createArrayPropertyEdit } from '@core/spec-snippet';
import { boundColumnsAt } from './active-dataset';
import { installCursorLens } from './editor-cursor-lens';
import { insertSnippetAt, scaffoldSlotAt, scaffoldSuggestion } from './editor-snippet';
import { useSnippetStore } from '../stores/SnippetStore';
/** The transforms surfaced as CodeLens buttons — the common few; the completion has the rest. */
const COMMON_STEP_IDS = ['filter', 'aggregate', 'calculate', 'bin', 'timeUnit'] as const;
let registered = false;
/** Register the `transform[]` step-scaffold completion provider once. */
export function configureSpecTransformScaffold(): void {
if (registered) return;
registered = true;
monaco.languages.registerCompletionItemProvider('json', {
provideCompletionItems(model, position) {
// Scaffolding only edits the draft; the published view is read-only.
if (useSnippetStore.getState().editorView !== 'draft') return { suggestions: [] };
const text = model.getValue();
const offset = model.getOffsetAt(position);
const placement = transformPlacementAt(text, offset);
if (placement.kind !== 'step') return { suggestions: [] };
const slot = scaffoldSlotAt(model, position, text, offset);
const fields = boundColumnsAt(text, offset);
const shared = placement.scope === 'shared';
const suggestions = DATA_TRANSFORMS.map((t, i) =>
scaffoldSuggestion(slot, i, {
label: t.label,
detail: t.detail,
body: t.step(fields),
// A shared step (on a composition parent) transforms the data feeding every
// child view, not just one — surface that so the placement isn't a surprise.
documentation: shared
? {
value:
'_Applies to all views below — this `transform` sits on a composition parent._',
}
: undefined,
}),
);
return { suggestions };
},
});
}
/**
* Splice a field-typed step into the `transform` array at `arrayOffset`, via Monaco's
* snippet engine so the placeholders are Tab-through. Re-resolves the array from the
* live text (the lens args may be a version stale) and skips silently if it's gone.
*/
function runAddStep(
editor: monaco.editor.IStandaloneCodeEditor,
id: string,
arrayOffset: number,
): void {
const model = editor.getModel();
if (!model) return;
const text = model.getValue();
const site = transformSiteAt(text, arrayOffset + 1);
const transform = DATA_TRANSFORMS.find((t) => t.id === id);
if (!site?.array || !transform) return;
const fields = boundColumnsAt(text, site.array.offset);
const edit = appendEntryEdit(text, site.array, transform.step(fields));
insertSnippetAt(editor, edit.offset, edit.snippet);
}
/**
* Insert an empty `transform: []` as the view's first property and drop the cursor
* between its brackets (`$0`), so the per-step lenses appear next and the first step
* fills the array in place. Placement, indentation, and the comma are
* `createArrayPropertyEdit`'s tested math.
*/
function runAddTransform(editor: monaco.editor.IStandaloneCodeEditor, viewOffset: number): void {
const model = editor.getModel();
if (!model) return;
const text = model.getValue();
const site = transformSiteAt(text, viewOffset + 1);
if (!site || site.array) return; // gone, or already has a pipeline
const tab = model.getOptions().tabSize || 2;
const edit = createArrayPropertyEdit(text, site.view.offset, tab, 'transform', '$0');
insertSnippetAt(editor, edit.offset, edit.snippet);
}
/**
* Install the cursor-aware transform-scaffold CodeLens (per editor its commands
* need this editor's handle to apply the edit, exactly like the composition CodeLens
* in `spec-transform-actions`). Over the view the cursor sits in it shows ` Add
* transform` when there is no pipeline, or ` filter`/` aggregate`/… on the array
* when there is. Returns a disposable; dispose on unmount.
*/
export function installSpecTransformScaffoldCodeLens(
editor: monaco.editor.IStandaloneCodeEditor,
): monaco.IDisposable {
const addTransformCmd = editor.addCommand(0, (_a, viewOffset: number) =>
runAddTransform(editor, viewOffset),
);
const addStepCmd = editor.addCommand(0, (_a, id: string, arrayOffset: number) =>
runAddStep(editor, id, arrayOffset),
);
return installCursorLens<TransformSite>(editor, {
resolve: transformSiteAt,
// Refresh when the enclosing view or its step count changes, not on every keystroke.
keyOf: (site) => JSON.stringify([site.view.offset, site.array?.count ?? -1]),
lensesOf: (site, model, lens) => {
if (!site.array) {
const line = model.getPositionAt(site.view.offset).lineNumber;
return [lens(line, '$(add) Add transform', addTransformCmd, [site.view.offset])];
}
const line = model.getPositionAt(site.array.offset).lineNumber;
return COMMON_STEP_IDS.map((id) =>
lens(line, `$(add) ${id}`, addStepCmd, [id, site.array!.offset]),
);
},
});
}
+16
View File
@@ -42,3 +42,19 @@ describe('data inspector height (divider math)', () => {
expect(inspectorHeightValue(96, 150)).toBeNull(); expect(inspectorHeightValue(96, 150)).toBeNull();
}); });
}); });
describe('requestRevealView (wireframe → editor)', () => {
beforeEach(() => useAppStore.setState({ revealTarget: null }));
test('carries the requested range', () => {
store().requestRevealView(40, 12);
expect(store().revealTarget).toMatchObject({ offset: 40, length: 12 });
});
test('bumps the nonce on a repeat request for the same range, so it re-fires', () => {
store().requestRevealView(40, 12);
const first = store().revealTarget!.nonce;
store().requestRevealView(40, 12);
expect(store().revealTarget!.nonce).toBe(first + 1);
});
});
+96
View File
@@ -1,5 +1,7 @@
import { create } from 'zustand'; import { create } from 'zustand';
import type { FitMode } from '@core/rendering'; import type { FitMode } from '@core/rendering';
import type { SpecPath } from '@core/spec-insert';
import type { DropAxis } from '@core/spec-restructure';
import type { UiTheme } from '@core/theme'; import type { UiTheme } from '@core/theme';
import type { ChartThemeSelection } from '@core/vega-themes'; import type { ChartThemeSelection } from '@core/vega-themes';
import type { ModalName } from '../modals/types'; import type { ModalName } from '../modals/types';
@@ -65,6 +67,40 @@ export interface AppState {
dataInspectorHeight: number; dataInspectorHeight: number;
/** The currently open modal, or null. */ /** The currently open modal, or null. */
activeModal: ModalName | null; activeModal: ModalName | null;
/**
* A request from the composition wireframe to select and reveal a view's source
* range in the editor (wireframe editor; arch 08 editor augmentation). The
* nonce makes a repeat request for the same range re-fire. Null until the first.
*/
revealTarget: { offset: number; length: number; nonce: number } | null;
/**
* A request from the composition wireframe to restructure reorder a view
* within its array (`move`), pair the dragged view beside a drop target in a new
* concat (`wrap`, the cross-container drag), or stack it against a whole container
* pulled out into a new full-span row/column (`wrap-container`, the frame-margin
* drag). Applied by the editor (which owns the undoable edit) as one Z step; the
* nonce makes a repeat re-fire. Null until the first.
*/
composeRequest:
| { kind: 'move'; arrayPath: SpecPath; from: number; to: number; nonce: number }
| {
kind: 'wrap';
targetPath: SpecPath;
sourcePath: SpecPath;
axis: DropAxis;
side: 'before' | 'after';
nonce: number;
}
| {
kind: 'wrap-container';
containerPath: SpecPath;
sourcePath: SpecPath;
axis: DropAxis;
side: 'before' | 'after';
nonce: number;
}
| { kind: 'simplify'; nonce: number }
| null;
setTheme: (theme: UiTheme) => void; setTheme: (theme: UiTheme) => void;
/** Flip between light and dark — the header ThemeToggle's action. */ /** Flip between light and dark — the header ThemeToggle's action. */
@@ -84,6 +120,26 @@ export interface AppState {
* which calls this; arrives with the modal system in M3. * which calls this; arrives with the modal system in M3.
*/ */
setActiveModal: (modal: ModalName | null) => void; setActiveModal: (modal: ModalName | null) => void;
/** Ask the editor to select + reveal a view's source range (composition wireframe). */
requestRevealView: (offset: number, length: number) => void;
/** Ask the editor to reorder a view within its composition array (wireframe drag/keyboard). */
requestComposeMove: (arrayPath: SpecPath, from: number, to: number) => void;
/** Ask the editor to pair a dragged view beside a drop target in a new concat (wireframe drag). */
requestComposeWrap: (
targetPath: SpecPath,
sourcePath: SpecPath,
axis: DropAxis,
side: 'before' | 'after',
) => void;
/** Ask the editor to pull a dragged view out into a new full-span row/column around a container (wireframe margin drag). */
requestComposeWrapContainer: (
containerPath: SpecPath,
sourcePath: SpecPath,
axis: DropAxis,
side: 'before' | 'after',
) => void;
/** Ask the editor to collapse redundant single-child compositions (wireframe Simplify). */
requestComposeSimplify: () => void;
} }
export const useAppStore = create<AppState>((set) => ({ export const useAppStore = create<AppState>((set) => ({
@@ -93,6 +149,8 @@ export const useAppStore = create<AppState>((set) => ({
dataInspectorOpen: false, dataInspectorOpen: false,
dataInspectorHeight: DATA_INSPECTOR_DEFAULT_HEIGHT, dataInspectorHeight: DATA_INSPECTOR_DEFAULT_HEIGHT,
activeModal: null, activeModal: null,
revealTarget: null,
composeRequest: null,
setTheme: (uiTheme) => set({ uiTheme }), setTheme: (uiTheme) => set({ uiTheme }),
toggleTheme: () => set((s) => ({ uiTheme: s.uiTheme === 'dark' ? 'light' : 'dark' })), toggleTheme: () => set((s) => ({ uiTheme: s.uiTheme === 'dark' ? 'light' : 'dark' })),
@@ -101,4 +159,42 @@ export const useAppStore = create<AppState>((set) => ({
setDataInspectorOpen: (dataInspectorOpen) => set({ dataInspectorOpen }), setDataInspectorOpen: (dataInspectorOpen) => set({ dataInspectorOpen }),
setDataInspectorHeight: (dataInspectorHeight) => set({ dataInspectorHeight }), setDataInspectorHeight: (dataInspectorHeight) => set({ dataInspectorHeight }),
setActiveModal: (activeModal) => set({ activeModal }), setActiveModal: (activeModal) => set({ activeModal }),
requestRevealView: (offset, length) =>
set((s) => ({ revealTarget: { offset, length, nonce: (s.revealTarget?.nonce ?? 0) + 1 } })),
requestComposeMove: (arrayPath, from, to) =>
set((s) => ({
composeRequest: {
kind: 'move',
arrayPath,
from,
to,
nonce: (s.composeRequest?.nonce ?? 0) + 1,
},
})),
requestComposeWrap: (targetPath, sourcePath, axis, side) =>
set((s) => ({
composeRequest: {
kind: 'wrap',
targetPath,
sourcePath,
axis,
side,
nonce: (s.composeRequest?.nonce ?? 0) + 1,
},
})),
requestComposeWrapContainer: (containerPath, sourcePath, axis, side) =>
set((s) => ({
composeRequest: {
kind: 'wrap-container',
containerPath,
sourcePath,
axis,
side,
nonce: (s.composeRequest?.nonce ?? 0) + 1,
},
})),
requestComposeSimplify: () =>
set((s) => ({
composeRequest: { kind: 'simplify', nonce: (s.composeRequest?.nonce ?? 0) + 1 },
})),
})); }));
+199
View File
@@ -0,0 +1,199 @@
import { beforeEach, describe, expect, test } from 'vitest';
import { createSnippet } from '@core/snippet';
import { useDatasetStore } from './DatasetStore';
import { hasExtractableData, useExtractStore } from './ExtractStore';
import { useSnippetStore } from './SnippetStore';
const extract = () => useExtractStore.getState();
const snippets = () => useSnippetStore.getState();
const datasets = () => useDatasetStore.getState();
/** Load a single active snippet whose live draft buffer is `draft`. */
function activeDraft(draft: string): void {
const s = createSnippet({ id: 's', spec: draft, now: new Date('2026-01-01T00:00:00Z') });
useSnippetStore.getState().hydrate([s], 's');
useSnippetStore.getState().updateDraft(draft);
}
beforeEach(() => {
useExtractStore.getState().reset();
useSnippetStore.getState().reset();
useDatasetStore.getState().reset();
});
describe('hasExtractableData', () => {
test('true for inline data, false for a library reference or bad JSON', () => {
expect(hasExtractableData('{"data":{"values":[{"a":1}]},"mark":"bar"}')).toBe(true);
expect(hasExtractableData('{"data":{"name":"sales"}}')).toBe(false);
expect(hasExtractableData('not json')).toBe(false);
});
test('true when inline data lives only in a nested view', () => {
expect(
hasExtractableData('{"layer":[{"data":{"name":"sales"}},{"data":{"values":[{"a":1}]}}]}'),
).toBe(true);
});
test('true for a reference to a self-defined datasets entry', () => {
expect(hasExtractableData('{"datasets":{"sales":[{"a":1}]},"data":{"name":"sales"}}')).toBe(
true,
);
});
});
describe('confirm — inline rewrite', () => {
test('extracts the root binding and rewrites it to a by-name reference', () => {
activeDraft('{"data":{"values":[{"a":1},{"a":2}]},"mark":"bar"}');
extract().begin({
source: { values: [{ a: 1 }, { a: 2 }], format: 'json' },
target: { kind: 'inline', anchorPath: [] },
});
extract().setName('Sales');
expect(extract().confirm(new Date('2026-02-01T00:00:00Z'))).toBe(true);
const ds = datasets().datasets.find((d) => d.name === 'Sales');
expect(ds?.data).toEqual([{ a: 1 }, { a: 2 }]);
const draft = JSON.parse(snippets().draftText) as { data: unknown; mark: unknown };
expect(draft.data).toEqual({ name: 'Sales' });
expect(draft.mark).toBe('bar');
});
test('rewrites only the focused view in a composition, leaving siblings intact', () => {
activeDraft(
JSON.stringify({
layer: [{ data: { name: 'sales' }, mark: 'line' }, { data: { values: [{ a: 1 }] } }],
}),
);
extract().begin({
source: { values: [{ a: 1 }], format: 'json' },
target: { kind: 'inline', anchorPath: ['layer', 1] },
});
extract().setName('Overlay');
expect(extract().confirm()).toBe(true);
const draft = JSON.parse(snippets().draftText) as { layer: Array<{ data: unknown }> };
expect(draft.layer[0].data).toEqual({ name: 'sales' }); // sibling untouched
expect(draft.layer[1].data).toEqual({ name: 'Overlay' }); // focused view rewritten
});
test("rewrites a lookup transform's inline from.data (the cursor sits inside it)", () => {
activeDraft(
JSON.stringify({
data: { name: 'Library' },
transform: [
{ lookup: 'k', from: { data: { values: [{ k: 1, v: 9 }] }, key: 'k', fields: ['v'] } },
],
mark: 'bar',
}),
);
extract().begin({
source: { values: [{ k: 1, v: 9 }], format: 'json' },
target: { kind: 'inline', anchorPath: ['transform', 0, 'from'] },
});
extract().setName('Lookup');
expect(extract().confirm()).toBe(true);
const draft = JSON.parse(snippets().draftText) as {
transform: Array<{ from: { data: unknown } }>;
};
expect(draft.transform[0].from.data).toEqual({ name: 'Lookup' });
});
test('preserves a CSV string payload as a raw-text dataset', () => {
activeDraft('{"data":{"values":"a,b\\n1,2","format":{"type":"csv"}},"mark":"bar"}');
extract().begin({
source: { values: 'a,b\n1,2', format: 'csv' },
target: { kind: 'inline', anchorPath: [] },
});
extract().setName('Raw');
expect(extract().confirm()).toBe(true);
const ds = datasets().datasets.find((d) => d.name === 'Raw');
expect(ds?.format).toBe('csv');
expect(ds?.data).toBe('a,b\n1,2');
});
});
describe('confirm — self-defined datasets rewrite', () => {
test('keeping the name drops the datasets entry; the reference resolves to the library', () => {
activeDraft('{"datasets":{"sales":[{"a":1},{"a":2}]},"data":{"name":"sales"},"mark":"bar"}');
extract().begin({
source: { values: [{ a: 1 }, { a: 2 }], format: 'json' },
target: { kind: 'self-defined', datasetName: 'sales' },
name: 'sales',
});
expect(extract().confirm()).toBe(true);
const ds = datasets().datasets.find((d) => d.name === 'sales');
expect(ds?.data).toEqual([{ a: 1 }, { a: 2 }]);
const draft = JSON.parse(snippets().draftText) as { datasets?: unknown; data: unknown };
expect(draft.datasets).toBeUndefined(); // map emptied → removed
expect(draft.data).toEqual({ name: 'sales' }); // reference unchanged, now a library ref
});
test('renaming rewrites every reference and keeps other datasets entries', () => {
activeDraft(
JSON.stringify({
datasets: { sales: [{ a: 1 }], other: [{ b: 2 }] },
layer: [
{ data: { name: 'sales' } },
{ data: { name: 'sales' } },
{ data: { name: 'other' } },
],
}),
);
extract().begin({
source: { values: [{ a: 1 }], format: 'json' },
target: { kind: 'self-defined', datasetName: 'sales' },
name: 'Sales 2024',
});
expect(extract().confirm()).toBe(true);
const draft = JSON.parse(snippets().draftText) as {
datasets: Record<string, unknown>;
layer: Array<{ data: unknown }>;
};
expect(draft.layer[0].data).toEqual({ name: 'Sales 2024' }); // both sales refs rewritten
expect(draft.layer[1].data).toEqual({ name: 'Sales 2024' });
expect(draft.layer[2].data).toEqual({ name: 'other' }); // untouched
expect(draft.datasets).toEqual({ other: [{ b: 2 }] }); // sales entry dropped, other kept
});
});
describe('confirm — guards', () => {
test('rejects a duplicate name and creates nothing', () => {
activeDraft('{"data":{"values":[{"a":1}]}}');
extract().begin({
source: { values: [{ a: 1 }], format: 'json' },
target: { kind: 'inline', anchorPath: [] },
});
extract().setName('Sales');
extract().confirm();
const countAfterFirst = datasets().datasets.length;
activeDraft('{"data":{"values":[{"b":2}]}}');
extract().begin({
source: { values: [{ b: 2 }], format: 'json' },
target: { kind: 'inline', anchorPath: [] },
});
extract().setName('Sales');
expect(extract().confirm()).toBe(false);
expect(extract().error).toMatch(/already exists/);
expect(datasets().datasets.length).toBe(countAfterFirst);
});
test('refuses when the anchor path no longer resolves, creating nothing', () => {
activeDraft('{"data":{"values":[{"a":1}]}}'); // no layer array
extract().begin({
source: { values: [{ a: 1 }], format: 'json' },
target: { kind: 'inline', anchorPath: ['layer', 3] },
});
extract().setName('Ghost');
expect(extract().confirm()).toBe(false);
expect(extract().error).toMatch(/Could not locate/);
expect(datasets().datasets.length).toBe(0);
});
});
+87 -67
View File
@@ -1,92 +1,86 @@
/** /**
* Extract-inline-data Dataset state (spec §03F). * Extract-embedded-data Dataset state (spec §03F).
* *
* Backs the Extract modal: the reverse of a named reference. It captures the * Backs the Extract modal: the reverse of a named reference. It captures the
* active snippet draft's inline data, takes a dataset name, and on confirm saves * embedded data of the **view the cursor sits in**, takes a dataset name, and on
* the data as a new dataset and rewrites the draft so the inline data is replaced * confirm saves the data as a new library dataset and rewrites the spec so the
* by a by-name reference (`{ "data": { "name": … } }`). * embedded data is replaced by a by-name reference.
* *
* Scope (M3): the **top-level** `data` block of the draft spec the common case * Two shapes of embedded data, captured as a `target` (spec §03F; multi-view scope
* for a single-view chart. Inline data nested inside layers/concats is left for a * doc M3):
* later pass; `hasInlineData` reflects exactly what `confirm` can lift, so the * - **inline** a view's `data.values`. Confirm rewrites that view's `data`
* editor only offers the action when this store can act on it. * block at its anchor path to `{ name }`.
* - **self-defined** a `{ name: X }` reference to the spec's own top-level
* `datasets.X`. Confirm removes the `datasets` entry (and the map when it
* empties); the reference resolves to the new library dataset, renamed when the
* name changes (`core/spec-refs` `promoteSelfDefinedDataset`).
*
* The store is the editor-free side of the flow: it never reads the cursor or the
* Monaco model `services/extract-action` resolves the focused binding and seeds
* it via `begin`, so this stays a plain, testable store.
*/ */
import { create } from 'zustand'; import { create } from 'zustand';
import type { DataFormat } from '@core/format-detection';
import { createDataset } from '@core/dataset'; import { createDataset } from '@core/dataset';
import { formatSpec } from '@core/json-format';
import { isNameTaken } from '@core/naming'; import { isNameTaken } from '@core/naming';
import { setDataBindingAtPath } from '@core/spec-data';
import { type InlinePayload, specHasExtractableData } from '@core/spec-inline-data';
import { promoteSelfDefinedDataset } from '@core/spec-refs';
import { useDatasetStore } from './DatasetStore'; import { useDatasetStore } from './DatasetStore';
import { notify } from './NotificationStore'; import { notify } from './NotificationStore';
import { useSnippetStore } from './SnippetStore'; import { useSnippetStore } from './SnippetStore';
/** The inline `data` block of a parsed spec, if it carries `values`. */ /** The path of the view whose `data` block Extract rewrites (`[]` = root). */
interface InlineData { type AnchorPath = ReadonlyArray<string | number>;
values: unknown;
format: DataFormat;
}
/** /** What confirm rewrites — an inline `data` block, or a self-defined `datasets` entry. */
* Read the top-level inline data from a draft spec's text, or null when there is export type ExtractTarget =
* none (no snippet, unparseable, or no `data.values`). The format comes from an | { kind: 'inline'; anchorPath: AnchorPath }
* explicit `data.format.type` when present (raw CSV/TSV strings), else JSON. | { kind: 'self-defined'; datasetName: string };
*/
function readInlineData(draftText: string): InlineData | null { /** True when the active snippet's draft has data Extract can lift, in any view. */
let parsed: unknown; export function hasExtractableData(draftText: string): boolean {
try { try {
parsed = JSON.parse(draftText); return specHasExtractableData(JSON.parse(draftText));
} catch { } catch {
return null; return false;
} }
if (!parsed || typeof parsed !== 'object') return null;
const data = (parsed as Record<string, unknown>).data;
if (!data || typeof data !== 'object') return null;
const values = (data as Record<string, unknown>).values;
if (values === undefined) return null;
const declared = (data as Record<string, unknown>).format;
const type =
declared && typeof declared === 'object'
? (declared as Record<string, unknown>).type
: undefined;
const format: DataFormat =
type === 'csv' || type === 'tsv' || type === 'topojson' ? type : 'json';
return { values, format };
}
/** True when the active snippet's draft has top-level inline data to extract. */
export function hasInlineData(draftText: string): boolean {
return readInlineData(draftText) !== null;
} }
export interface ExtractState { export interface ExtractState {
/** Proposed dataset name (required, unique). */ /** Proposed dataset name (required, unique). */
name: string; name: string;
/** The inline data captured at open, for the read-only preview. */ /** The focused view's embedded data captured at open, for the read-only preview. */
source: InlineData | null; source: InlinePayload | null;
/** Where `confirm` writes the by-name reference. */
target: ExtractTarget | null;
/** Inline validation message, or null. */ /** Inline validation message, or null. */
error: string | null; error: string | null;
/** Capture the active snippet's inline data and reset the form. */ /** Seed the form with the focused view's captured data (extract-action). */
init: () => void; begin: (captured: { source: InlinePayload; target: ExtractTarget; name?: string }) => void;
setName: (name: string) => void; setName: (name: string) => void;
/** /**
* Validate, create the dataset, and rewrite the active draft to reference it by * Validate, create the dataset, and rewrite the spec to reference it by name.
* name. Returns whether it committed; on failure `error` is set. `now` * Returns whether it committed; on failure `error` is set. `now` injectable for
* injectable for tests. * tests.
*/ */
confirm: (now?: Date) => boolean; confirm: (now?: Date) => boolean;
reset: () => void; reset: () => void;
} }
const INITIAL = { name: '', source: null as InlineData | null, error: null as string | null }; const INITIAL = {
name: '',
source: null as InlinePayload | null,
target: null as ExtractTarget | null,
error: null as string | null,
};
export const useExtractStore = create<ExtractState>((set, get) => ({ export const useExtractStore = create<ExtractState>((set, get) => ({
...INITIAL, ...INITIAL,
init: () => { begin: ({ source, target, name = '' }) => set({ name, source, target, error: null }),
const draft = useSnippetStore.getState().draftText;
set({ name: '', source: readInlineData(draft), error: null });
},
setName: (name) => set({ name, error: null }), setName: (name) => set({ name, error: null }),
@@ -100,13 +94,42 @@ export const useExtractStore = create<ExtractState>((set, get) => ({
set({ error: `A dataset named "${name}" already exists. Choose a different name.` }); set({ error: `A dataset named "${name}" already exists. Choose a different name.` });
return false; return false;
} }
const source = get().source; const { source, target } = get();
if (!source) { if (!source || !target) {
set({ error: 'No inline data to extract.' }); set({ error: 'No data to extract.' });
return false; return false;
} }
// JSON/TopoJSON store the parsed value; CSV/TSV keep raw text — but inline // Resolve the rewrite before any side effect: parse the live draft and apply the
// target's rewrite to a fresh copy. Both can fail (the draft is no longer valid
// JSON, or its shape changed under the modal); bail with a message and create
// nothing rather than leave a half-done extraction.
const draftText = useSnippetStore.getState().draftText;
let spec: unknown;
try {
spec = JSON.parse(draftText);
} catch {
set({ error: 'The spec is no longer valid JSON. Close and reopen Extract.' });
return false;
}
let rewritten: unknown;
if (target.kind === 'inline') {
if (!setDataBindingAtPath(spec, target.anchorPath, { name })) {
set({ error: 'Could not locate the data to replace. Close and reopen Extract.' });
return false;
}
rewritten = spec;
} else {
const next = promoteSelfDefinedDataset(spec, target.datasetName, name);
if (!next) {
set({ error: 'Could not locate the data to replace. Close and reopen Extract.' });
return false;
}
rewritten = next;
}
// JSON/TopoJSON store the parsed value; CSV/TSV keep raw text — but the captured
// `values` is already the right runtime shape for each, so store it directly. // `values` is already the right runtime shape for each, so store it directly.
const dataset = createDataset({ const dataset = createDataset({
name, name,
@@ -117,20 +140,17 @@ export const useExtractStore = create<ExtractState>((set, get) => ({
}); });
useDatasetStore.getState().add(dataset); useDatasetStore.getState().add(dataset);
// Rewrite the top-level data block to a by-name reference, preserving the rest // Re-serialize in the app's house style (json-format), preserving the spec and
// of the spec and its pretty-printed text shape. // only swapping the captured data for the reference.
const draftText = useSnippetStore.getState().draftText; useSnippetStore.getState().replaceActiveDraft(formatSpec(rewritten), now);
const spec = JSON.parse(draftText) as Record<string, unknown>;
spec.data = { name };
useSnippetStore.getState().replaceActiveDraft(JSON.stringify(spec, null, 2), now);
// Success confirmation (spec §03F). Title states the action; the message // Success confirmation (spec §03F). Title states the action; the message adds
// adds the consequence — the spec was rewritten to reference the new dataset // the consequence — the spec was rewritten to reference the new dataset by name
// by name (council toast-copy rule, docs/architecture/10 → Toast copy). // (council toast-copy rule, docs/architecture/10 → Toast copy).
notify({ notify({
kind: 'success', kind: 'success',
title: 'Dataset created', title: 'Dataset created',
message: `The spec now references "${name}" instead of its inline data.`, message: `The spec now references "${name}" instead of its embedded data.`,
}); });
set(INITIAL); set(INITIAL);
return true; return true;
-54
View File
@@ -1,54 +0,0 @@
import { afterEach, describe, expect, it } from 'vitest';
import { usePreviewStore } from './PreviewStore';
const store = () => usePreviewStore.getState();
afterEach(() => {
// Reset to a known clean state between tests so store leaks don't affect order.
store().setError(null);
store().setBusy(false);
});
describe('PreviewStore — error slice', () => {
it('starts with null error', () => {
expect(store().error).toBeNull();
});
it('setError stores the provided message', () => {
store().setError('Rendering error: something went wrong.');
expect(store().error).toBe('Rendering error: something went wrong.');
});
it('setError(null) clears the message', () => {
store().setError('an error');
store().setError(null);
expect(store().error).toBeNull();
});
});
describe('PreviewStore — busy slice', () => {
it('starts with busy=false', () => {
expect(store().busy).toBe(false);
});
it('setBusy(true) sets busy to true', () => {
store().setBusy(true);
expect(store().busy).toBe(true);
});
it('setBusy(false) clears busy', () => {
store().setBusy(true);
store().setBusy(false);
expect(store().busy).toBe(false);
});
it('busy and error are independent — setting one does not affect the other', () => {
store().setBusy(true);
store().setError('some error');
expect(store().busy).toBe(true);
expect(store().error).toBe('some error');
store().setBusy(false);
expect(store().error).toBe('some error'); // error unchanged by clearing busy
});
});
-43
View File
@@ -1,43 +0,0 @@
/**
* Preview render status the bridge between the Live Preview (which owns
* rendering) and the two panes that surface its outcome.
*
* Both the editor and the preview must show the same render problem: spec §03E
* puts a readable error in the **editor** pane, and spec §04 puts one in the
* **preview** pane, for the very same failure (invalid JSON, or valid JSON that
* fails to render as Vega-Lite including an unresolved dataset reference). The
* Live Preview is the single producer; it writes the current error here and both
* panes subscribe. `null` means the current spec rendered cleanly (or is blank).
*
* `busy` tracks whether a render is in flight long enough to warrant a visible
* indicator (arch §10.2: >~1s owes a non-blocking busy overlay). LivePreview arms
* a 1 s timer when a render starts and sets `busy = true` only if the render has
* not settled by then; it clears `busy` on settle or error regardless.
*
* Kept as its own tiny store rather than folded into the SnippetStore: this is
* transient render state, not durable domain data, and it must not be persisted.
*/
import { create } from 'zustand';
export interface PreviewState {
/** The current render error message, or null when the spec renders cleanly. */
error: string | null;
/** Set (or clear) the current render error. */
setError: (error: string | null) => void;
/**
* True while a render has been in flight for longer than the ~1s NN/g threshold
* (arch §10.2). The LivePreview overlay reads this to show a non-blocking busy
* indication; aria-busy on the preview region mirrors it.
*/
busy: boolean;
/** Set or clear the busy flag. */
setBusy: (busy: boolean) => void;
}
export const usePreviewStore = create<PreviewState>((set) => ({
error: null,
setError: (error) => set({ error }),
busy: false,
setBusy: (busy) => set({ busy }),
}));
+1 -1
View File
@@ -114,7 +114,7 @@ export interface SnippetState {
/** /**
* Replace the active snippet's draft spec with new text and reload the editor * Replace the active snippet's draft spec with new text and reload the editor
* on the draft view (bumps `bufferEpoch`). Used by programmatic rewrites such * on the draft view (bumps `bufferEpoch`). Used by programmatic rewrites such
* as Extract-to-Dataset (spec §03F), which substitutes inline data for a * as Extract-to-Dataset (spec §03F), which substitutes embedded data for a
* by-name reference. No-op when no snippet is active. `now` injectable. * by-name reference. No-op when no snippet is active. `now` injectable.
*/ */
replaceActiveDraft: (text: string, now?: Date) => void; replaceActiveDraft: (text: string, now?: Date) => void;
+33 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { validateExpression, referencedFields } from './expr-validate'; import { validateExpression, referencedFields, activeCall } from './expr-validate';
describe('validateExpression', () => { describe('validateExpression', () => {
it('accepts a well-formed Vega expression', () => { it('accepts a well-formed Vega expression', () => {
@@ -37,3 +37,35 @@ describe('referencedFields', () => {
expect(referencedFields('datum.price *')).toEqual([]); expect(referencedFields('datum.price *')).toEqual([]);
}); });
}); });
describe('activeCall', () => {
it('returns null when the cursor is not inside any call', () => {
expect(activeCall('datum.price + ')).toBeNull();
expect(activeCall('')).toBeNull();
});
it('names the enclosing call and reports the first argument', () => {
expect(activeCall('clamp(')).toEqual({ name: 'clamp', activeParam: 0 });
expect(activeCall('if(datum.x > 0')).toEqual({ name: 'if', activeParam: 0 });
});
it('counts commas to find the active argument', () => {
expect(activeCall('clamp(datum.x, 0, ')).toEqual({ name: 'clamp', activeParam: 2 });
});
it('reports the innermost call when calls are nested', () => {
expect(activeCall('if(datum.x > 0, min(1, ')).toEqual({ name: 'min', activeParam: 1 });
});
it('keeps a nested array argument on its outer call argument', () => {
expect(activeCall('clamp(datum.x, [1, 2')).toEqual({ name: 'clamp', activeParam: 1 });
});
it('does not count commas inside string literals', () => {
expect(activeCall("if(test(regexp('a,b'), datum.s), ")).toEqual({ name: 'if', activeParam: 1 });
});
it('treats a bare grouping paren as not a call', () => {
expect(activeCall('(datum.x + 1) * ')).toBeNull();
});
});
+57
View File
@@ -85,6 +85,63 @@ export function referencedFields(expr: string): string[] {
return fields; return fields;
} }
/** The function call a cursor sits inside, and which argument it is on. */
export interface ActiveCall {
/** The called function's name (the identifier before the open paren). */
name: string;
/** Zero-based index of the argument the cursor is in (commas seen so far). */
activeParam: number;
}
/**
* Given the expression text from its start up to the cursor, the innermost
* function call the cursor sits inside its name and the argument index or
* `null` when the cursor is not within a call. Used to drive editor signature
* help. A forward scan keeps a stack of bracket frames (parens and square
* brackets), skips string literals, and counts commas per frame; the nearest
* unclosed frame whose open paren follows an identifier is the active call, and
* that frame's comma count is the active argument.
*/
export function activeCall(prefix: string): ActiveCall | null {
interface Frame {
name: string | null;
commas: number;
}
const stack: Frame[] = [];
for (let i = 0; i < prefix.length; i++) {
const c = prefix[i];
if (c === '\\') {
i++; // an escape consumes the next character
continue;
}
if (c === '"' || c === "'") {
// Skip a string literal so its parens/commas don't disturb the scan.
const quote = c;
i++;
while (i < prefix.length && prefix[i] !== quote) {
if (prefix[i] === '\\') i++;
i++;
}
continue;
}
if (c === '(') {
const name = /([A-Za-z_$][A-Za-z0-9_$]*)\s*$/.exec(prefix.slice(0, i));
stack.push({ name: name ? name[1] : null, commas: 0 });
} else if (c === '[') {
stack.push({ name: null, commas: 0 });
} else if (c === ')' || c === ']') {
stack.pop();
} else if (c === ',' && stack.length > 0) {
stack[stack.length - 1].commas++;
}
}
for (let i = stack.length - 1; i >= 0; i--) {
const frame = stack[i];
if (frame.name !== null) return { name: frame.name, activeParam: frame.commas };
}
return null;
}
/** /**
* The static field name of a `datum.<name>` / `datum['name']` member access, or * The static field name of a `datum.<name>` / `datum['name']` member access, or
* `null` when the node isn't such an access (a different object, computed-dynamic * `null` when the node isn't such an access (a different object, computed-dynamic
+173
View File
@@ -0,0 +1,173 @@
import { describe, expect, test } from 'vitest';
import { inspectViewLabel, inspectableViews } from './inspect-views';
// Fixtures mirror the shapes Vega-Lite 6 actually compiles to (verified by
// compiling each composition and dumping `vgSpec.data` + `vgSpec.marks`).
describe('inspectableViews', () => {
test('a single unit: one drawn table, resolved + input ends of its pipeline', () => {
const vg = {
data: [
{ name: 'source_0', values: [] },
{ name: 'data_0', source: 'source_0' },
],
marks: [{ type: 'rect', name: 'marks', from: { data: 'data_0' } }],
};
expect(inspectableViews(vg)).toEqual([{ resolved: 'data_0', input: 'source_0' }]);
});
test('vconcat sharing one source: two tables, same input, different resolved', () => {
const vg = {
data: [
{ name: 'source_0', values: [] },
{ name: 'data_1', source: 'source_0' },
{ name: 'data_2', source: 'source_0' },
],
marks: [
{
type: 'group',
name: 'concat_0_group',
marks: [{ type: 'rect', name: 'concat_0_marks', from: { data: 'data_1' } }],
},
{
type: 'group',
name: 'concat_1_group',
marks: [{ type: 'rect', name: 'concat_1_marks', from: { data: 'data_2' } }],
},
],
};
expect(inspectableViews(vg)).toEqual([
{ resolved: 'data_1', input: 'source_0' },
{ resolved: 'data_2', input: 'source_0' },
]);
});
test('layers binding different data: each table traces to its own named source', () => {
const vg = {
data: [
{ name: 'a', values: [] },
{ name: 'b', values: [] },
{ name: 'data_0', source: 'a' },
{ name: 'data_1', source: 'b' },
],
marks: [
{ type: 'line', name: 'layer_0_marks', from: { data: 'data_0' } },
{ type: 'symbol', name: 'layer_1_marks', from: { data: 'data_1' } },
],
};
expect(inspectableViews(vg)).toEqual([
{ resolved: 'data_0', input: 'a' },
{ resolved: 'data_1', input: 'b' },
]);
});
test('repeat: one drawn table per repeated child', () => {
const vg = {
data: [
{ name: 'source_0', values: [] },
{ name: 'data_1', source: 'source_0' },
{ name: 'data_2', source: 'source_0' },
],
marks: [
{
type: 'group',
name: 'child__a_group',
marks: [{ type: 'rect', name: 'child__a_marks', from: { data: 'data_1' } }],
},
{
type: 'group',
name: 'child__b_group',
marks: [{ type: 'rect', name: 'child__b_marks', from: { data: 'data_2' } }],
},
],
};
expect(inspectableViews(vg).map((v) => v.resolved)).toEqual(['data_1', 'data_2']);
});
test('facet: the cell data via from.facet.data; layout-helper tables excluded', () => {
const vg = {
data: [
{ name: 'source_0', values: [] },
{ name: 'data_0', source: 'source_0' },
{ name: 'facet_domain', source: 'data_0' },
{ name: 'facet_domain_row' },
{ name: 'facet_domain_column' },
],
marks: [
{ type: 'group', name: 'facet-title' },
{ type: 'group', name: 'row_header', from: { data: 'facet_domain_row' } },
{ type: 'group', name: 'column_footer', from: { data: 'facet_domain_column' } },
{
type: 'group',
name: 'cell',
from: { facet: { data: 'data_0' } },
marks: [{ type: 'rect', name: 'child_marks', from: { data: 'facet' } }],
},
],
};
// Only the faceted cell data is inspectable; facet_domain* are layout, and the
// child's `from: { data: 'facet' }` names no real table.
expect(inspectableViews(vg)).toEqual([{ resolved: 'data_0', input: 'source_0' }]);
});
test('input equals resolved when the drawn table is itself the source (no transforms)', () => {
const vg = {
data: [{ name: 'source_0', values: [] }],
marks: [{ type: 'rect', name: 'marks', from: { data: 'source_0' } }],
};
expect(inspectableViews(vg)).toEqual([{ resolved: 'source_0', input: 'source_0' }]);
});
test('a line+point unit shows two tables (Vega-Lite desugars point into a layer)', () => {
// Documented consequence of enumerating drawn tables: point overlay → two
// near-identical tables (data_1 derives from data_0), both tracing to source_0.
const vg = {
data: [
{ name: 'source_0', values: [] },
{ name: 'data_0', source: 'source_0' },
{ name: 'data_1', source: 'data_0' },
],
marks: [
{ type: 'line', name: 'layer_0_marks', from: { data: 'data_0' } },
{ type: 'symbol', name: 'layer_1_marks', from: { data: 'data_1' } },
],
};
expect(inspectableViews(vg)).toEqual([
{ resolved: 'data_0', input: 'source_0' },
{ resolved: 'data_1', input: 'source_0' },
]);
});
test('deduplicates a table drawn by more than one mark', () => {
const vg = {
data: [
{ name: 'source_0', values: [] },
{ name: 'data_0', source: 'source_0' },
],
marks: [
{ type: 'rect', name: 'm1', from: { data: 'data_0' } },
{ type: 'text', name: 'm2', from: { data: 'data_0' } },
],
};
expect(inspectableViews(vg)).toEqual([{ resolved: 'data_0', input: 'source_0' }]);
});
test('returns [] for malformed or data-less specs', () => {
expect(inspectableViews(null)).toEqual([]);
expect(inspectableViews({})).toEqual([]);
expect(inspectableViews({ data: [], marks: [] })).toEqual([]);
expect(inspectableViews({ marks: [{ type: 'rect', from: { data: 'ghost' } }] })).toEqual([]);
});
});
describe('inspectViewLabel', () => {
test('compiler-generated names become an ordinal "View N"', () => {
expect(inspectViewLabel('source_0', 0)).toBe('View 1');
expect(inspectViewLabel('data_2', 1)).toBe('View 2');
});
test('a user-authored dataset name is shown verbatim', () => {
expect(inspectViewLabel('sales', 0)).toBe('sales');
expect(inspectViewLabel('data_foo', 2)).toBe('data_foo'); // no trailing digits → not compiler
});
});
+126
View File
@@ -0,0 +1,126 @@
/**
* Inspectable drawn tables of a compiled chart (the data inspector spec §04;
* docs/architecture/05 "the data inspector rides the boundary").
*
* Portable core: pure analysis of a **compiled Vega spec** (the byproduct
* `vega-embed` already produces to render the chart nothing is compiled here).
* A multi-view Vega-Lite spec (layer/concat/facet/repeat) compiles to several
* data tables, and the inspector lets the user pick which one to look at "what
* data am I actually visualizing?" to debug in-spec transforms.
*
* What counts as inspectable is the set of tables the **marks actually draw**, read
* from the compiled marks tree's `from.data` (and a facet cell's `from.facet.data`).
* That is the honest answer to the question and it sidesteps a mapping that cannot
* be made reliable: Vega-Lite *desugars* some marks into layers (e.g. a `line` with
* `point: true` becomes two marks over two tables), so a compiled table cannot be
* traced back to a single authored view. We therefore enumerate by drawn table, not
* by authored view one consequence being that a point-overlay shows as two nearly
* identical tables (the line's and the point's), which is literally what is drawn.
*
* For each drawn table we report two ends, mirroring the inspector's Input |
* Resolved toggle: `resolved` is the table the marks draw (after the view's
* transforms), and `input` is its most-upstream source (before them), found by
* following each table's `source` link the documented, stable Vega dataflow
* format.
*/
/** A drawn table the user can inspect, with the two ends of its pipeline. */
export interface InspectableView {
/** The compiled dataset the marks draw — the post-transform "Resolved" rows. */
resolved: string;
/** The most-upstream source of `resolved` — the "Input" rows before transforms. */
input: string;
}
interface VgData {
name?: unknown;
source?: unknown;
}
interface VgMark {
from?: { data?: unknown; facet?: { data?: unknown } };
marks?: unknown;
}
function isObject(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
/** Vega's compiler-generated table names (`source_0`, `data_2`) — jargon, never shown. */
function isCompilerName(name: string): boolean {
return /^(source|data)_\d+$/.test(name);
}
/**
* A user-facing label for an inspectable view: its input source's name when that
* is user-authored (a named dataset like `sales`), else an ordinal `View N`
* (1-based, by document order). Compiler-generated names never surface they are
* jargon to the user (council: NN/g #2 "speak the users' language";
* docs/architecture/10). The richer recognition cue columns + row count is the
* UI's `detail` line, built from the rows.
*/
export function inspectViewLabel(input: string, index: number): string {
return isCompilerName(input) ? `View ${index + 1}` : input;
}
/** Facet layout helper tables (domains/headers), never a table the user draws into. */
function isLayoutHelper(name: string): boolean {
return name.startsWith('facet_domain');
}
/** The table a mark draws, from `from.data` or a facet cell's `from.facet.data`. */
function drawnTable(mark: VgMark): string | null {
const from = mark.from;
if (!isObject(from)) return null;
if (typeof from.data === 'string') return from.data;
if (isObject(from.facet) && typeof from.facet.data === 'string') return from.facet.data;
return null;
}
/**
* The inspectable drawn tables of a compiled Vega spec, in document order, one per
* distinct table the marks render. Returns `[]` for a spec with no drawable data
* (or a malformed input).
*/
export function inspectableViews(vgSpec: unknown): InspectableView[] {
if (!isObject(vgSpec)) return [];
const dataList: VgData[] = Array.isArray(vgSpec.data) ? (vgSpec.data as VgData[]) : [];
const sourceOf = new Map<string, string | undefined>();
for (const d of dataList) {
if (isObject(d) && typeof d.name === 'string') {
sourceOf.set(d.name, typeof d.source === 'string' ? d.source : undefined);
}
}
// The most-upstream ancestor of `name` via the `source` chain (cycle-guarded).
const rootSourceOf = (name: string): string => {
const seen = new Set<string>();
let current = name;
while (sourceOf.has(current) && !seen.has(current)) {
const next = sourceOf.get(current);
if (next === undefined) break;
seen.add(current);
current = next;
}
return current;
};
const views: InspectableView[] = [];
const seen = new Set<string>();
const visit = (node: unknown): void => {
if (Array.isArray(node)) {
for (const item of node) visit(item);
return;
}
if (!isObject(node)) return;
const mark = node as VgMark;
const table = drawnTable(mark);
if (table !== null && sourceOf.has(table) && !isLayoutHelper(table) && !seen.has(table)) {
seen.add(table);
views.push({ resolved: table, input: rootSourceOf(table) });
}
if (Array.isArray(mark.marks)) visit(mark.marks);
};
visit(vgSpec.marks);
return views;
}
+79 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { parseLesson, type LessonBlock } from './lesson-parse'; import { injectDatasets, parseLesson, type LessonBlock } from './lesson-parse';
const LESSON = `--- const LESSON = `---
slug: binning slug: binning
@@ -105,6 +105,12 @@ describe('parseLesson — block model', () => {
expect(() => parseLesson(`---\nslug: x\ntagline: y\n---\nhi`)).toThrow(/missing "title"/); expect(() => parseLesson(`---\nslug: x\ntagline: y\n---\nhi`)).toThrow(/missing "title"/);
}); });
it('throws on a slug outside [a-z0-9-] (router/shell contract)', () => {
expect(() => parseLesson(`---\nslug: Bad_Slug\ntitle: t\ntagline: g\n---\nhi`)).toThrow(
/slug must be lowercase/,
);
});
it('throws with stage context on invalid JSON', () => { it('throws with stage context on invalid JSON', () => {
const bad = `---\nslug: x\ntitle: t\ntagline: g\n---\n:::progression\n## a\n\`\`\`vega-lite\n{ bad }\n\`\`\`\n:::`; const bad = `---\nslug: x\ntitle: t\ntagline: g\n---\n:::progression\n## a\n\`\`\`vega-lite\n{ bad }\n\`\`\`\n:::`;
expect(() => parseLesson(bad)).toThrow(/stage "a": invalid JSON/); expect(() => parseLesson(bad)).toThrow(/stage "a": invalid JSON/);
@@ -124,4 +130,76 @@ describe('parseLesson — block model', () => {
'callout', 'callout',
]); ]);
}); });
it('defaults to no datasets when there is no :::data block', () => {
expect(parseLesson(LESSON).datasets).toEqual({});
});
});
describe('parseLesson — :::data blocks', () => {
const withData = `---\nslug: x\ntitle: t\ntagline: g\n---
:::data
{ "ev": [{ "arm": "A", "n": 1 }, { "arm": "B", "n": 2 }] }
:::
A chart that references it by name:
\`\`\`vega-lite
{ "data": { "name": "ev" }, "mark": "bar" }
\`\`\``;
it('collects named rows into lesson.datasets, not as a block', () => {
const lesson = parseLesson(withData);
expect(lesson.datasets).toEqual({
ev: [
{ arm: 'A', n: 1 },
{ arm: 'B', n: 2 },
],
});
// The data directive is metadata — only the prose + chart are blocks.
expect(types(lesson.blocks)).toEqual(['prose', 'chart']);
});
it('merges multiple :::data blocks', () => {
const two = `---\nslug: x\ntitle: t\ntagline: g\n---
:::data
{ "a": [{ "v": 1 }] }
:::
:::data
{ "b": [{ "v": 2 }] }
:::
Words.`;
expect(parseLesson(two).datasets).toEqual({ a: [{ v: 1 }], b: [{ v: 2 }] });
});
it('throws on a non-object :::data body', () => {
const bad = `---\nslug: x\ntitle: t\ntagline: g\n---\n:::data\n[1, 2]\n:::`;
expect(() => parseLesson(bad)).toThrow(/:::data: body must be a JSON object/);
});
it('throws when a dataset value is not an array', () => {
const bad = `---\nslug: x\ntitle: t\ntagline: g\n---\n:::data\n{ "ev": 5 }\n:::\nhi`;
expect(() => parseLesson(bad)).toThrow(/:::data "ev": value must be an array/);
});
});
describe('injectDatasets', () => {
const datasets = { ev: [{ n: 1 }] };
it('merges lesson datasets into a clone, leaving the source spec untouched', () => {
const spec = { data: { name: 'ev' }, mark: 'bar' };
const out = injectDatasets(spec, datasets);
expect(out).toEqual({ data: { name: 'ev' }, mark: 'bar', datasets: { ev: [{ n: 1 }] } });
expect(spec).not.toHaveProperty('datasets');
});
it("lets a spec's own datasets win on a name clash", () => {
const spec = { datasets: { ev: [{ n: 99 }] }, mark: 'bar' };
expect(injectDatasets(spec, datasets).datasets).toEqual({ ev: [{ n: 99 }] });
});
it('returns the spec unchanged when there are no datasets', () => {
const spec = { mark: 'bar' };
expect(injectDatasets(spec, {})).toBe(spec);
});
}); });
+81 -15
View File
@@ -22,11 +22,15 @@
* *
* Free intro prose (markdown) * Free intro prose (markdown)
* *
* :::data
* { "ev": [ {row}, {row} ] }
* :::
*
* :::progression * :::progression
* ## raw counts * ## raw counts
* Note prose for this stage (markdown) * Note prose for this stage (markdown)
* ```vega-lite * ```vega-lite
* { spec } * { "data": { "name": "ev" }, spec }
* ``` * ```
* ## + bin * ## + bin
* *
@@ -42,10 +46,13 @@
* A callout (markdown) * A callout (markdown)
* ::: * :::
* *
* A `:::progression` block holds `##`-delimited stages (heading tab label, prose * A `:::data` block names datasets once for the whole lesson (body: a JSON object
* note, fenced `vega-lite` spec). A bare fenced `vega-lite` block is a * of `name → rows`); specs reference them by `{ "name": … }` and the rows are
* standalone chart. Any other `:::name` block is a callout. Everything else is * injected at render time, so they aren't repeated per stage. A `:::progression`
* prose, emitted in the order it appears. * block holds `##`-delimited stages (heading tab label, prose note, fenced
* `vega-lite` spec). A bare fenced `vega-lite` block is a standalone chart. Any
* other `:::name` block is a callout. Everything else is prose, emitted in the
* order it appears.
*/ */
export interface ProgressionStage { export interface ProgressionStage {
@@ -81,6 +88,12 @@ export interface ParsedLesson {
slug: string; slug: string;
title: string; title: string;
tagline: string; tagline: string;
/**
* Lesson-level named datasets, collected from `:::data` blocks. Specs reference
* a dataset by `{ "data": { "name": … } }`; {@link injectDatasets} merges these
* in at render time so the rows aren't repeated in every fenced spec.
*/
datasets: Record<string, unknown[]>;
/** The lesson body, in document order. */ /** The lesson body, in document order. */
blocks: LessonBlock[]; blocks: LessonBlock[];
} }
@@ -100,14 +113,25 @@ export function parseLesson(source: string): ParsedLesson {
if (!fm) throw new Error('lesson: missing `---` frontmatter block'); if (!fm) throw new Error('lesson: missing `---` frontmatter block');
const meta = parseFrontmatter(fm[1]); const meta = parseFrontmatter(fm[1]);
const slug = required(meta, 'slug'); const slug = required(meta, 'slug');
// The slug is the lesson's URL segment (`/learn/<slug>/`) and the route key; keep it
// to the charset the router and the page-shell generator both assume, so a typo fails
// loudly here rather than silently serving the wrong page.
if (!/^[a-z0-9-]+$/.test(slug)) {
throw new Error(`lesson "${slug}": slug must be lowercase letters, digits, and hyphens`);
}
const title = required(meta, 'title'); const title = required(meta, 'title');
const tagline = required(meta, 'tagline'); const tagline = required(meta, 'tagline');
return { slug, title, tagline, blocks: parseBlocks(text.slice(fm[0].length), slug) }; const { blocks, datasets } = parseBlocks(text.slice(fm[0].length), slug);
return { slug, title, tagline, datasets, blocks };
} }
function parseBlocks(body: string, slug: string): LessonBlock[] { function parseBlocks(
body: string,
slug: string,
): { blocks: LessonBlock[]; datasets: Record<string, unknown[]> } {
const blocks: LessonBlock[] = []; const blocks: LessonBlock[] = [];
const datasets: Record<string, unknown[]> = {};
const pushProse = (text: string): void => { const pushProse = (text: string): void => {
const trimmed = text.trim(); const trimmed = text.trim();
if (trimmed) blocks.push({ type: 'prose', markdown: trimmed }); if (trimmed) blocks.push({ type: 'prose', markdown: trimmed });
@@ -121,13 +145,15 @@ function parseBlocks(body: string, slug: string): LessonBlock[] {
if (m[1] !== undefined) { if (m[1] !== undefined) {
const variant = m[1]; const variant = m[1];
const inner = m[2]; const inner = m[2];
// TODO: if lessons multiply, add a `:::data` directive whose values stages if (variant === 'progression') {
// inherit — today a lesson's inline data is repeated in every fenced spec. blocks.push({ type: 'progression', stages: parseStages(inner, slug) });
blocks.push( } else if (variant === 'data') {
variant === 'progression' // Lesson metadata, not a rendered block: fold the named rows into the
? { type: 'progression', stages: parseStages(inner, slug) } // lesson's dataset map for render-time injection.
: { type: 'callout', variant, markdown: inner.trim() }, Object.assign(datasets, parseDatasets(inner, slug));
); } else {
blocks.push({ type: 'callout', variant, markdown: inner.trim() });
}
} else { } else {
blocks.push({ type: 'chart', spec: parseSpec(m[3], slug, 'chart') }); blocks.push({ type: 'chart', spec: parseSpec(m[3], slug, 'chart') });
} }
@@ -136,7 +162,47 @@ function parseBlocks(body: string, slug: string): LessonBlock[] {
pushProse(body.slice(cursor)); pushProse(body.slice(cursor));
if (blocks.length === 0) throw new Error(`lesson "${slug}": no content`); if (blocks.length === 0) throw new Error(`lesson "${slug}": no content`);
return blocks; return { blocks, datasets };
}
/** Parse a `:::data` body — a JSON object of `name → rows[]` — into named datasets. */
function parseDatasets(inner: string, slug: string): Record<string, unknown[]> {
let parsed: unknown;
try {
parsed = JSON.parse(inner.trim());
} catch (e) {
throw new Error(`lesson "${slug}", :::data: invalid JSON — ${(e as Error).message}`, {
cause: e,
});
}
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
throw new Error(`lesson "${slug}", :::data: body must be a JSON object of { name: rows }`);
}
const out: Record<string, unknown[]> = {};
for (const [name, rows] of Object.entries(parsed)) {
if (!Array.isArray(rows)) {
throw new Error(`lesson "${slug}", :::data "${name}": value must be an array of rows`);
}
out[name] = rows;
}
return out;
}
/**
* Merge lesson-level named datasets into a spec for *rendering* the authored
* spec (shown in the source pane and diffed) keeps its `{ "data": { "name": … } }`
* reference and never carries the rows. Returns a clone with the spec's own
* `datasets` winning on a name clash; mirrors the clone-before-embed rule the app's
* other render-time spec tweaks follow (never mutate the authored spec). A no-op
* (same reference) when there are no datasets to inject.
*/
export function injectDatasets(
spec: Record<string, unknown>,
datasets: Record<string, unknown[]>,
): Record<string, unknown> {
if (Object.keys(datasets).length === 0) return spec;
const own = (spec.datasets as Record<string, unknown> | undefined) ?? {};
return { ...spec, datasets: { ...datasets, ...own } };
} }
function parseStages(inner: string, slug: string): ProgressionStage[] { function parseStages(inner: string, slug: string): ProgressionStage[] {
+20
View File
@@ -203,6 +203,26 @@ describe('prepareSpecForRender — dataset resolution (spec §04 Rendering Contr
expect(out.data).toEqual({ name: 'local' }); expect(out.data).toEqual({ name: 'local' });
}); });
test('native Vega-Lite data with a name label is left untouched (not resolved, never throws)', () => {
// A name on inline/url data, or a generator, is native Vega-Lite — even when
// the name is absent from the library. Resolution must not touch or reject it.
const inline = { data: { name: 'pts', values: [{ a: 1 }] }, mark: 'point' };
expect(prepareSpecForRender(inline, { datasets }).data).toEqual({
name: 'pts',
values: [{ a: 1 }],
});
const url = { data: { name: 'remote', url: 'https://x/y.csv' } };
expect(prepareSpecForRender(url, { datasets }).data).toEqual({
name: 'remote',
url: 'https://x/y.csv',
});
const gen = { data: { name: 'seq', sequence: { start: 0, stop: 5 } } };
expect(prepareSpecForRender(gen, { datasets }).data).toEqual({
name: 'seq',
sequence: { start: 0, stop: 5 },
});
});
test('resolution runs before fit-mode: a ref + fit mode produce both transforms', () => { test('resolution runs before fit-mode: a ref + fit mode produce both transforms', () => {
const spec = { data: { name: 'JsonDs' }, mark: 'bar' }; const spec = { data: { name: 'JsonDs' }, mark: 'bar' };
const out = prepareSpecForRender(spec, { datasets, fitMode: 'full' }) as unknown as { const out = prepareSpecForRender(spec, { datasets, fitMode: 'full' }) as unknown as {
+25 -32
View File
@@ -10,13 +10,15 @@
* 1. Dataset reference resolution implemented in M3 (see below). * 1. Dataset reference resolution implemented in M3 (see below).
* 2. Fit-mode sizing implemented in M2. * 2. Fit-mode sizing implemented in M2.
* *
* Step 1 (spec §04 Rendering Contract): every named-data reference * Step 1 (spec §04 Rendering Contract): every library reference
* (`{ data: { name } }`) is replaced in-place with the referenced library * (`{ data: { name } }`) is replaced in-place with the referenced library
* dataset's actual contents, shaped by source and format. A name the spec defines * dataset's actual contents, shaped by source and format. What counts as a library
* for itself via a top-level `datasets` object is left untouched (Vega-Lite * reference is decided by `core/spec-data` (named data, not self-defined), so a
* resolves it natively); an unknown library name throws `DatasetNotFoundError`. * `name` riding on inline `values`/`url`/a generator is native Vega-Lite data and
* Resolution recurses into the same nested sub-specs as fit-mode, runs before * is left untouched; a name the spec defines for itself via a top-level `datasets`
* sizing, and operates only on the copy. * object is left untouched too (Vega-Lite resolves it natively); an unknown library
* name throws `DatasetNotFoundError`. Resolution recurses into the same nested
* sub-specs as fit-mode, runs before sizing, and operates only on the copy.
* *
* The copy-not-mutate invariant and the call site the renderer depends on are * The copy-not-mutate invariant and the call site the renderer depends on are
* fixed. * fixed.
@@ -24,6 +26,7 @@
import type { DataFormat } from './format-detection'; import type { DataFormat } from './format-detection';
import type { DataSource } from './dataset'; import type { DataSource } from './dataset';
import { libraryRefName, selfDefinedNames } from './spec-data';
/** Preview sizing modes (spec §04 → Fit / Sizing Modes). `default` = Original. */ /** Preview sizing modes (spec §04 → Fit / Sizing Modes). `default` = Original. */
export type FitMode = 'default' | 'width' | 'height' | 'full'; export type FitMode = 'default' | 'width' | 'height' | 'full';
@@ -113,16 +116,6 @@ function applyFitMode(node: unknown, mode: FitMode): void {
if (isSpecNode(node.spec)) applyFitMode(node.spec, mode); if (isSpecNode(node.spec)) applyFitMode(node.spec, mode);
} }
/** The set of dataset names a spec defines for itself via top-level `datasets`. */
function selfDefinedDatasetNames(spec: unknown): Set<string> {
const names = new Set<string>();
if (isSpecNode(spec)) {
const datasets = spec.datasets;
if (isSpecNode(datasets)) for (const key of Object.keys(datasets)) names.add(key);
}
return names;
}
/** /**
* Build the replacement `data` object for one resolved reference (spec §04 * Build the replacement `data` object for one resolved reference (spec §04
* Rendering Contract, step 1). `rest` is the reference's other keys (e.g. a * Rendering Contract, step 1). `rest` is the reference's other keys (e.g. a
@@ -157,13 +150,16 @@ function resolvedData(dataset: ResolvableDataset, rest: SpecNode): SpecNode {
} }
/** /**
* Replace every named-data reference in `node` with its library dataset's * Replace every library reference in `node` with its dataset's contents, recursing
* contents, recursing through the spec (arrays and objects) so refs anywhere are * through the spec (arrays and objects) so refs anywhere are resolved matching
* resolved matching `extractDatasetRefs`, including pruning the `data` and * `extractDatasetRefs`, including pruning the `data` and top-level `datasets`
* top-level `datasets` payload keys so resolution never descends into user data * payload keys so resolution never descends into user data rows. What counts as a
* rows. A self-defined name is left untouched; an unknown library name throws * library reference is decided by the shared `libraryRefName` (core/spec-data):
* `DatasetNotFoundError`. Matching is case-insensitive, mirroring naming.ts. * named data the spec does not define for itself. A `name` riding on inline
* Mutates in place; the caller already works on a copy. * `values`, a `url`, or a generator is native Vega-Lite data and is left untouched;
* an unknown library name throws `DatasetNotFoundError`. Matching is
* case-insensitive, mirroring naming.ts. Mutates in place; the caller already works
* on a copy.
*/ */
function resolveDatasetRefs( function resolveDatasetRefs(
node: unknown, node: unknown,
@@ -176,16 +172,13 @@ function resolveDatasetRefs(
} }
if (!isSpecNode(node)) return; if (!isSpecNode(node)) return;
const data = node.data; const refName = libraryRefName(node.data, selfDefined);
if (isSpecNode(data) && typeof data.name === 'string') { if (refName !== null) {
const name = data.name; const dataset = byName.get(refName.toLowerCase());
if (!selfDefined.has(name)) { if (!dataset) throw new DatasetNotFoundError(refName);
const dataset = byName.get(name.toLowerCase()); const { name: _drop, ...rest } = node.data as SpecNode;
if (!dataset) throw new DatasetNotFoundError(name);
const { name: _drop, ...rest } = data;
node.data = resolvedData(dataset, rest); node.data = resolvedData(dataset, rest);
} }
}
// Recurse into every key except the two that hold data payloads (`data` — // Recurse into every key except the two that hold data payloads (`data` —
// resolved/captured above; `datasets` — the spec's own inline data). Pruning // resolved/captured above; `datasets` — the spec's own inline data). Pruning
@@ -229,7 +222,7 @@ export function prepareSpecForRender<T>(spec: T, options: PrepareOptions = {}):
const datasets = options.datasets ?? []; const datasets = options.datasets ?? [];
const byName = new Map<string, ResolvableDataset>(); const byName = new Map<string, ResolvableDataset>();
for (const d of datasets) byName.set(d.name.toLowerCase(), d); for (const d of datasets) byName.set(d.name.toLowerCase(), d);
resolveDatasetRefs(copy, byName, selfDefinedDatasetNames(copy)); resolveDatasetRefs(copy, byName, selfDefinedNames(copy));
// 2. Fit-mode sizing. // 2. Fit-mode sizing.
applyFitMode(copy, options.fitMode ?? 'default'); applyFitMode(copy, options.fitMode ?? 'default');
-56
View File
@@ -1,56 +0,0 @@
import { describe, expect, it } from 'vitest';
import { pickResultDataset, pickSourceDataset } from './result-data';
describe('pickResultDataset', () => {
it('prefers the most-downstream transform output over the source', () => {
expect(pickResultDataset(['source_0', 'data_0', 'marks', 'root'])).toBe('data_0');
});
it('picks the highest data_<n> numerically, not lexically', () => {
expect(pickResultDataset(['source_0', 'data_0', 'data_1', 'data_2'])).toBe('data_2');
// 10 must beat 2 — guards against string ordering ('data_10' < 'data_2').
expect(pickResultDataset(['data_2', 'data_10', 'data_9'])).toBe('data_10');
});
it('falls back to the most-downstream source when there is no data_<n>', () => {
expect(pickResultDataset(['source_0', 'source_1', 'marks'])).toBe('source_1');
});
it('ignores dataflow internals when choosing', () => {
expect(pickResultDataset(['root', 'marks', 'layout', 'cell', 'source_0'])).toBe('source_0');
expect(pickResultDataset(['brush_store', '_facet', 'a:b', 'data_0'])).toBe('data_0');
});
it('returns a remaining non-internal table when no source/data convention matches', () => {
expect(pickResultDataset(['root', 'marks', 'my_named_source'])).toBe('my_named_source');
});
it('returns null when nothing is inspectable', () => {
expect(pickResultDataset([])).toBeNull();
expect(pickResultDataset(['root', 'marks', 'layout', 'brush_store'])).toBeNull();
});
});
describe('pickSourceDataset', () => {
it('prefers the most-upstream source — the input before transforms', () => {
expect(pickSourceDataset(['source_0', 'source_1', 'data_0', 'data_1'])).toBe('source_0');
});
it('picks the lowest source_<n> numerically', () => {
expect(pickSourceDataset(['source_2', 'source_10', 'source_1'])).toBe('source_1');
});
it('falls back to the earliest transform stage when there is no source_<n>', () => {
expect(pickSourceDataset(['data_0', 'data_1', 'marks'])).toBe('data_0');
});
it('equals the result when a spec has no transforms (only a source)', () => {
const names = ['source_0', 'marks', 'root'];
expect(pickSourceDataset(names)).toBe('source_0');
expect(pickResultDataset(names)).toBe('source_0');
});
it('returns null when nothing is inspectable', () => {
expect(pickSourceDataset(['root', 'marks', 'brush_store'])).toBeNull();
});
});
-95
View File
@@ -1,95 +0,0 @@
/**
* Picking the input and resolved datasets from a rendered Vega view (the data
* inspector spec §04; arch 05 "the data inspector rides the boundary").
*
* A compiled Vega dataflow holds many named datasets. Vega-Lite names the ones a
* spec produces by convention: `source_<n>` for each parsed data source, and
* `data_<n>` for each transform/aggregation stage. So a single view carries both
* ends of the pipeline:
*
* - the **input** is the most-upstream source (`source_0`) the parsed rows
* before the spec's transforms;
* - the **resolved** output is the most-downstream `data_<n>` the rows the marks
* of a single-view chart actually draw.
*
* Everything else in the dataflow (`root`, `marks`, layout/scale tables, selection
* `*_store`s, `_`-prefixed internals, faceted `a:b` names) is plumbing the user
* never authored. A spec with no transforms has only a source, so input and
* resolved resolve to the same table correct: no transforms means output = input.
*
* This is the pure half of the inspector given the view's dataset names, choose
* one per direction. The view access itself (listing names, reading rows) lives
* behind the renderer's `RenderHandle` boundary (services/chart-renderer; arch 05).
*
* Limitation: a single name can't represent a multi-view spec (layer/concat/facet
* produce several `source_<n>` / `data_<n>`, one per view). We return the
* most-upstream source and most-downstream output both real, drawn tables and
* leave a full dataset selector as a future option.
*/
/** Datasets that are dataflow plumbing, never a table the user would inspect. */
function isInternalDataset(name: string): boolean {
return (
name === 'root' ||
name === 'marks' ||
name === 'layout' ||
name === 'cell' ||
name.startsWith('_') ||
name.endsWith('_store') || // selection tuple stores
name.includes(':') // faceted / cross-context child sources
);
}
/** The trailing index of a `prefix_<n>` name (e.g. `data_12` → 12), or null. */
function suffixIndex(name: string, prefix: string): number | null {
if (!name.startsWith(`${prefix}_`)) return null;
const n = Number(name.slice(prefix.length + 1));
return Number.isInteger(n) ? n : null;
}
/** The `prefix_<n>` name with the highest (`'max'`) or lowest (`'min'`) suffix. */
function bySuffix(names: readonly string[], prefix: string, end: 'min' | 'max'): string | null {
let best: string | null = null;
let bestN = end === 'max' ? -Infinity : Infinity;
for (const name of names) {
const n = suffixIndex(name, prefix);
if (n === null) continue;
if (end === 'max' ? n > bestN : n < bestN) {
bestN = n;
best = name;
}
}
return best;
}
/**
* Choose the compiled dataset representing the rows the chart draws, given all of
* the view's dataset names. Prefers the most-downstream transform output
* (`data_<max n>`), then the most-downstream source (`source_<max n>`), then any
* remaining non-internal table. Returns null when nothing is inspectable.
*/
export function pickResultDataset(names: readonly string[]): string | null {
const candidates = names.filter((n) => !isInternalDataset(n));
return (
bySuffix(candidates, 'data', 'max') ??
bySuffix(candidates, 'source', 'max') ??
candidates[0] ??
null
);
}
/**
* Choose the compiled dataset representing the chart's input the parsed rows
* before the spec's transforms. Prefers the most-upstream source (`source_<min n>`),
* then the earliest transform stage (`data_<min n>`) for the rare source-less spec,
* then any remaining non-internal table. Returns null when nothing is inspectable.
*/
export function pickSourceDataset(names: readonly string[]): string | null {
const candidates = names.filter((n) => !isInternalDataset(n));
return (
bySuffix(candidates, 'source', 'min') ??
bySuffix(candidates, 'data', 'min') ??
candidates[0] ??
null
);
}
+24
View File
@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest';
import { schemeColors } from './scheme-colors';
describe('schemeColors', () => {
it('returns the full fixed palette for a categorical scheme (count ignored)', () => {
const colors = schemeColors('tableau10', 3);
expect(colors).toHaveLength(10);
expect(colors.every((c) => /^#[0-9a-f]{6}$/i.test(c))).toBe(true);
});
it('samples a continuous scheme at count stops, normalized to hex', () => {
const colors = schemeColors('viridis', 5);
expect(colors).toHaveLength(5);
expect(colors.every((c) => /^#[0-9a-f]{6}$/.test(c))).toBe(true);
});
it('samples the midpoint for a single continuous stop', () => {
expect(schemeColors('viridis', 1)).toHaveLength(1);
});
it('returns [] for an unknown scheme', () => {
expect(schemeColors('not-a-scheme')).toEqual([]);
});
});
+40
View File
@@ -0,0 +1,40 @@
/**
* Named-scheme resolution to hex swatches (docs/exploration/chart-theming-scope.md §5).
*
* Split from theme-controls.ts because of the `vega-scale` import below:
* theme-controls is reachable from the landing/learn entry graphs (via
* `vega-themes`), and a static vega-scale edge there drags the vega vendor
* chunk into the marketing pages' eager JS (`scripts/check-light-entries.mjs`
* guards this after every build). Only the app's color controls resolve
* schemes to swatches, so the edge lives here, app-side of the split.
*/
import { scheme } from 'vega-scale';
/**
* Resolve a named Vega scheme to hex swatches. A categorical scheme returns its
* fixed palette in full; a continuous scheme (sequential/diverging) is sampled
* at `count` evenly-spaced stops (`count` applies to continuous schemes only).
* Continuous interpolators yield `rgb(...)`, normalized to hex here. An unknown
* name returns `[]` the picker shows that scheme without a preview rather than
* throwing. Used for the swatch/gradient preview and "materialize to swatches".
*/
export function schemeColors(name: string, count = 9): string[] {
const resolved: unknown = scheme(name);
if (Array.isArray(resolved)) return resolved.map((c) => toHex(String(c)));
if (typeof resolved === 'function' && count > 0) {
const interp = resolved as (t: number) => string;
if (count === 1) return [toHex(interp(0.5))];
return Array.from({ length: count }, (_, i) => toHex(interp(i / (count - 1))));
}
return [];
}
/** Normalize a CSS color to `#rrggbb`; passes through existing hex and unknowns. */
function toHex(color: string): string {
if (color.startsWith('#')) return color;
const m = /rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)/i.exec(color);
if (!m) return color;
const h = (n: string) => Math.round(Number(n)).toString(16).padStart(2, '0');
return `#${h(m[1])}${h(m[2])}${h(m[3])}`;
}
+107
View File
@@ -0,0 +1,107 @@
import { describe, expect, it } from 'vitest';
import { findViewRange, valueKeyAtOffset } from './spec-cursor';
/** Parse the slice a range points at, for asserting which view was targeted. */
function sliceObject(text: string, range: { offset: number; length: number }) {
return JSON.parse(text.slice(range.offset, range.offset + range.length)) as Record<
string,
unknown
>;
}
const layered = JSON.stringify(
{
$schema: 'https://vega.github.io/schema/vega-lite/v6.json',
description: 'A simple bar chart.',
data: { values: [{ category: 'A', value: 28 }] },
layer: [
{
name: 'outer1',
mark: 'bar',
encoding: {
x: { field: 'category', type: 'nominal' },
y: { field: 'value', type: 'quantitative' },
},
},
{ mark: 'point', encoding: {}, name: 'outer2' },
],
},
null,
2,
);
describe('findViewRange', () => {
it('targets the layer element the cursor sits in, not the whole doc', () => {
const at = layered.indexOf('"outer2"'); // cursor inside the second layer element
const range = findViewRange(layered, at)!;
expect(range).not.toBeNull();
expect(sliceObject(layered, range).name).toBe('outer2');
});
it('climbs out of a deeply nested property to the enclosing view', () => {
const at = layered.indexOf('"nominal"'); // deep inside outer1's x encoding
const range = findViewRange(layered, at)!;
expect(sliceObject(layered, range).name).toBe('outer1');
});
it('returns null at a top-level key (caller wraps the whole document)', () => {
const at = layered.indexOf('"description"');
expect(findViewRange(layered, at)).toBeNull();
});
it('returns null for a flat unit spec (no inner view)', () => {
const unit = JSON.stringify(
{ mark: 'bar', encoding: { x: { field: 'a', type: 'nominal' } } },
null,
2,
);
expect(findViewRange(unit, unit.indexOf('"field"'))).toBeNull();
});
it('targets the facet/repeat child spec', () => {
const faceted = JSON.stringify(
{ facet: { field: 'g', type: 'nominal' }, spec: { mark: 'bar', encoding: {} } },
null,
2,
);
const range = findViewRange(faceted, faceted.indexOf('"bar"'))!;
expect(sliceObject(faceted, range).mark).toBe('bar');
});
it('still resolves a complete inner view while the outer doc is mid-edit', () => {
// Trailing junk makes the whole document unparseable; the inner object is intact.
const broken = layered.replace(/}\s*$/, '} ,');
const at = broken.indexOf('"outer2"');
const range = findViewRange(broken, at)!;
expect(sliceObject(broken, range).name).toBe('outer2');
});
});
describe('valueKeyAtOffset', () => {
const spec = JSON.stringify(
{ mark: 'bar', encoding: { x: { field: 'category', type: 'nominal' } }, transform: [] },
null,
2,
);
it('reports the property whose value the cursor is in', () => {
// inside the "category" value of "field": "category"
const at = spec.indexOf('category') + 2;
expect(valueKeyAtOffset(spec, at)).toBe('field');
});
it('reports the array key for an element position', () => {
const grouped = JSON.stringify(
{ transform: [{ aggregate: [], groupby: ['a', 'b'] }] },
null,
2,
);
const at = grouped.indexOf('"b"') + 1;
expect(valueKeyAtOffset(grouped, at)).toBe('groupby');
});
it('is null on a property key, not its value', () => {
const at = spec.indexOf('"field"') + 1; // on the key itself
expect(valueKeyAtOffset(spec, at)).toBe(null);
});
});
+105
View File
@@ -0,0 +1,105 @@
/**
* Cursor enclosing-view mapping (docs/architecture/08 editor augmentation).
*
* Portable core: no browser APIs, no React, no Monaco just `jsonc-parser`'s
* error-tolerant scanner (so this keeps working while the draft is briefly
* unparseable mid-edit). Given the spec text and a cursor offset, it finds the
* range of the *view* the cursor sits in, so a structural transform can act on
* "the element I'm focused on" rather than the whole document.
*
* A **view** is a spec object you can meaningfully wrap in a composition: an
* element of a `layer`/`hconcat`/`vconcat`/`concat` array, or the `spec` child of
* a facet/repeat. Encoding blocks, `data`, a single `field` the other objects
* the cursor might land in are not views and are skipped; the walk climbs to
* the nearest enclosing one. When the cursor is in a top-level unit spec (no
* nesting), there is no inner view and the result is null the caller falls back
* to the whole document, which is the right target for a flat spec.
*
* Only the byte range is returned; turning it into a Monaco range is the editor
* integration's job (app/services/spec-transform-actions).
*/
import { findNodeAtOffset, getLocation, parseTree, type Node } from 'jsonc-parser';
import { ARRAY_COMPOSITIONS } from './spec-transforms';
/** A byte range into the spec text. */
interface NodeRange {
offset: number;
length: number;
}
/** The property key a node sits under, when it is a property's value. */
function propertyKey(node: Node | undefined): string | undefined {
if (node?.type === 'property') {
const key = node.children?.[0];
return typeof key?.value === 'string' ? key.value : undefined;
}
return undefined;
}
/**
* Is this object node a view an element of a composition array, or a facet /
* repeat `spec` child? (The object's parent is the array/property that frames it.)
*/
function isViewObject(node: Node): boolean {
const parent = node.parent;
if (!parent) return false; // the root object: not an *inner* view
if (parent.type === 'property') return propertyKey(parent) === 'spec';
if (parent.type === 'array') {
const key = propertyKey(parent.parent);
return key !== undefined && ARRAY_COMPOSITIONS.includes(key);
}
return false;
}
/**
* The range of the nested view enclosing `offset`, or null when the cursor is not
* inside one (a flat unit spec, or whitespace between top-level keys) the
* caller then targets the whole document.
*/
export function findViewRange(text: string, offset: number): NodeRange | null {
const tree = parseTree(text);
if (!tree) return null;
let node: Node | undefined = findNodeAtOffset(tree, offset);
while (node) {
if (node.type === 'object' && isViewObject(node)) {
return { offset: node.offset, length: node.length };
}
node = node.parent;
}
return null;
}
/**
* The JSON path (object keys and array indices, root cursor) of the node at
* `offset`. Drives the view-scoped data context: the path is walked to find the
* nearest enclosing view's data binding and ancestor transforms
* (`core/spec-data`, `core/spec-fields`). Error-tolerant, so it works mid-edit.
*/
export function pathAtOffset(text: string, offset: number): (string | number)[] {
return [...getLocation(text, offset).path];
}
/**
* The property key whose *value* the cursor sits in, or null when the cursor is
* on a key, at the top level, or otherwise not in a value. For an array element
* the key is the array's property (so a `groupby: ["a", "b|"]` element reports
* `groupby`). Drives the field-position field hints "am I completing a
* `field`?". Error-tolerant, so it works mid-edit.
*/
export function valueKeyAtOffset(text: string, offset: number): string | null {
const { path, isAtPropertyKey } = getLocation(text, offset);
if (isAtPropertyKey || path.length === 0) return null;
const last = path[path.length - 1];
// An array element reports the array's own key (e.g. groupby[i] → "groupby").
const key = typeof last === 'number' ? path[path.length - 2] : last;
return typeof key === 'string' ? key : null;
}
/** The string literal the cursor is inside, or null when it is not on a string. */
export function stringValueAtOffset(text: string, offset: number): string | null {
const tree = parseTree(text);
if (!tree) return null;
const node = findNodeAtOffset(tree, offset);
return node?.type === 'string' && typeof node.value === 'string' ? node.value : null;
}
+185
View File
@@ -0,0 +1,185 @@
import Ajv, { type ValidateFunction } from 'ajv';
import { describe, expect, it } from 'vitest';
import vegaLiteSchema from 'vega-lite/vega-lite-schema.json';
import {
DATA_TRANSFORMS,
transformPlacementAt,
transformSiteAt,
type TypedField,
} from './spec-data-transforms';
import { snippetToPlain } from './spec-snippet';
/** Compile the bundled Vega-Lite schema once so every step can be validated for real. */
const validateSpec: ValidateFunction = new Ajv({
strict: false,
validateFormats: false,
}).compile(vegaLiteSchema as object);
/** Place a `|` marker in the source, strip it, and classify at that offset. */
function placeAt(marked: string) {
const offset = marked.indexOf('|');
return transformPlacementAt(marked.replace('|', ''), offset);
}
/** Place a `|` marker in the source, strip it, and resolve the transform site. */
function siteAt(marked: string) {
const offset = marked.indexOf('|');
return transformSiteAt(marked.replace('|', ''), offset);
}
const FIELDS: TypedField[] = [
{ name: 'price', type: 'number' },
{ name: 'date', type: 'date' },
{ name: 'origin', type: 'string' },
];
describe('transformPlacementAt — step slots', () => {
it('classifies an empty transform array as a step slot', () => {
expect(placeAt('{"transform":[|],"mark":"bar"}')).toEqual({ kind: 'step', scope: 'view' });
});
it('classifies the slot after an existing step', () => {
expect(placeAt('{"transform":[{"filter":"x"},|],"mark":"bar"}')).toEqual({
kind: 'step',
scope: 'view',
});
});
it('classifies a bare partial word in the array as a step slot', () => {
// `fil` is not valid JSON, but jsonc-parser locates the slot regardless.
expect(placeAt('{"transform":[fil|],"mark":"bar"}')).toEqual({ kind: 'step', scope: 'view' });
});
it('marks a transform on a composition parent as shared (upstream of children)', () => {
const src = '{"transform":[|],"layer":[{"mark":"bar"},{"mark":"line"}]}';
expect(placeAt(src)).toEqual({ kind: 'step', scope: 'shared' });
});
it('resolves the owning view of a nested transform (layer child = local)', () => {
const src = '{"layer":[{"transform":[|],"mark":"bar"},{"mark":"line"}]}';
expect(placeAt(src)).toEqual({ kind: 'step', scope: 'view' });
});
});
describe('transformPlacementAt — not a step slot', () => {
it('stays out of a key inside an existing step object (the schema owns that)', () => {
expect(placeAt('{"transform":[{"fil|"}],"mark":"bar"}')).toEqual({ kind: 'none' });
});
it('does not fire at an encoding channel key (the schema completes those)', () => {
expect(placeAt('{"encoding":{"x":{"field":"date","ti|"}}}')).toEqual({ kind: 'none' });
});
it('does not fire at a bare view key', () => {
expect(placeAt('{"mark":"bar","tr|"}')).toEqual({ kind: 'none' });
});
it('returns none for a plain value position', () => {
expect(placeAt('{"mark":"|"}')).toEqual({ kind: 'none' });
});
});
describe('transformSiteAt', () => {
it('reports no array on a bare unit view (cursor anywhere inside it)', () => {
const site = siteAt('{"mark":"bar","encoding":{"x":{"field":"a|"}}}');
expect(site).toMatchObject({ scope: 'view', array: null });
expect(site?.view.offset).toBe(0);
});
it('reports the array and its element count when a pipeline exists', () => {
const site = siteAt('{"transform":[{"filter":"x"}],"mark":"|bar"}');
expect(site?.array).toMatchObject({ count: 1 });
});
it('reports an empty array with count 0', () => {
const site = siteAt('{"transform":[|],"mark":"bar"}');
expect(site?.array).toMatchObject({ count: 0 });
});
it('resolves the site from inside the transform array itself', () => {
const site = siteAt('{"transform":[{"filter":"x"},|],"mark":"bar"}');
expect(site?.array).toMatchObject({ count: 1 });
expect(site?.view.offset).toBe(0);
});
it('climbs to the nearest view — a layer child is local (view scope)', () => {
const site = siteAt('{"layer":[{"mark":"ba|r"},{"mark":"line"}]}');
expect(site).toMatchObject({ scope: 'view', array: null });
expect(site?.view.offset).toBeGreaterThan(0); // the child, not the root
});
it('marks a composition parents pipeline as shared', () => {
const site = siteAt('{"transform":[|],"layer":[{"mark":"bar"}]}');
expect(site).toMatchObject({ scope: 'shared' });
});
it('the view range spans the whole enclosing view object', () => {
const src = '{"mark":"bar","encodi|ng":{}}';
const site = siteAt(src);
// offset at the opening brace, length to the closing brace inclusive.
expect(site?.view.offset).toBe(0);
expect(site?.view.length).toBe(src.replace('|', '').length);
});
});
describe('catalog — every step snippet, seeded, is valid Vega-Lite', () => {
/** A unit spec carrying the stripped step, for real-schema validation. */
const withStep = (snippet: string) => ({
data: { values: [{ price: 1, origin: 'A' }] },
mark: 'point',
encoding: {},
transform: [JSON.parse(snippetToPlain(snippet)) as unknown],
});
for (const t of DATA_TRANSFORMS) {
it(`${t.id} (with fields in scope)`, () => {
const ok = validateSpec(withStep(t.step(FIELDS)));
expect(ok, JSON.stringify(validateSpec.errors?.[0])).toBe(true);
});
it(`${t.id} (no data in scope — generic fallbacks)`, () => {
const ok = validateSpec(withStep(t.step([])));
expect(ok, JSON.stringify(validateSpec.errors?.[0])).toBe(true);
});
}
});
describe('catalog — field-typed defaults', () => {
it('seeds filter with the first available column', () => {
expect(JSON.parse(snippetToPlain(byId('filter').step(FIELDS)))).toEqual({
filter: 'datum.price > 0',
});
});
it('seeds aggregate with a numeric field and a categorical groupby', () => {
const parsed = parseAggregate(byId('aggregate').step(FIELDS));
expect(parsed.aggregate[0]).toMatchObject({ field: 'price', as: 'price_mean' });
expect(parsed.groupby).toEqual(['origin']);
});
it('seeds timeUnit with a temporal field', () => {
const parsed = JSON.parse(snippetToPlain(byId('timeUnit').step(FIELDS))) as { field: string };
expect(parsed.field).toBe('date');
});
it('falls back to generic names when no column matches the need', () => {
const parsed = parseAggregate(byId('aggregate').step([]));
expect(parsed.aggregate[0].field).toBe('value');
expect(parsed.groupby).toEqual(['category']);
});
});
function byId(id: string) {
const t = DATA_TRANSFORMS.find((x) => x.id === id);
if (!t) throw new Error(`no transform ${id}`);
return t;
}
/** Parse an aggregate step snippet into its (typed) fields, for assertions. */
function parseAggregate(snippet: string): {
aggregate: Array<{ op: string; field: string; as: string }>;
groupby: string[];
} {
return JSON.parse(snippetToPlain(snippet)) as {
aggregate: Array<{ op: string; field: string; as: string }>;
groupby: string[];
};
}
+284
View File
@@ -0,0 +1,284 @@
/**
* Data-transform scaffolding (docs/architecture/08 editor augmentation) the
* pure counterpart of `spec-transforms`. Where `spec-transforms` rewrites a view's
* *structure* (wrap in a composition), this scaffolds a view's *data pipeline*: the
* Vega-Lite `transform` steps (filter, calculate, aggregate, ) the editor offers as
* ready-to-fill, field-typed placeholders. Text/Monaco handling (ranges, insertion,
* the CodeLens) is the service's job (app/services/spec-transform-scaffold); this
* owns the facts the schema can't supply — *where* a view's pipeline lives
* (`transformSiteAt`, for the lens) or a step slot sits (`transformPlacementAt`, for
* the completion), and *what* a field-typed skeleton for each transform looks like.
*
* The **discoverable surface is a CodeLens** ` Add transform` on a view with no
* pipeline, and ` filter`/` aggregate`/ on the array once it exists mirroring
* the composition CodeLens (`spec-transform-actions`). The step-slot completion is a
* type-to-filter accelerator on the same catalog, not the primary way in.
*
* **Where a data transform can legally go (grammar-confirmed against the bundled VL
* schema the `transform` array appears on 16 spec types, which reduce to one idea:
* every view node).** Three contexts exist; we scaffold only the one the schema
* leaves bare, on the same "add only what the schema lacks" rule as `spec-dataset-hints`:
*
* - **step (owned here)** an element of a view's `transform: [ … ]` array. The
* array sits on *every* view node (unit, layer, facet, concat, repeat, and the
* root) the node set `spec-cursor`/`spec-data` already call a "view" so it
* inherits top-down: a step on a composition parent is **shared** (upstream of
* all its children); a step on a unit is **local**. The schema never offers a
* ready `{ "filter": … }` with your columns, so this is where we add value.
* - **inline (deferred to the schema)** `bin`/`timeUnit`/`aggregate`/`sort` are
* also properties on an encoding channel's field def. The JSON schema already
* completes those keys *and* their enum values, so scaffolding them here would be
* a redundant second source.
* - **new pipeline (deferred composition of two that already work)** starting a
* `transform` on a bare view is the schema completing the `transform` key ( empty
* array) followed by the step completion firing inside it. No separate surface needed.
*
* `transformPlacementAt` classifies the cursor from `jsonc-parser`'s error-tolerant
* tree, so it holds mid-edit. The catalog builders take the columns in scope (resolved
* by the service via `active-dataset`, exactly as the facet/repeat wraps do) and seed
* each placeholder with a type-appropriate default.
*/
import {
findNodeAtLocation,
findNodeAtOffset,
getLocation,
parseTree,
type Node,
} from 'jsonc-parser';
import { ARRAY_COMPOSITIONS } from './spec-transforms';
import type { ColumnType } from './type-inference';
/** A column in scope at the cursor, with its inferred data type. */
export interface TypedField {
name: string;
type: ColumnType;
}
/** A `transform` step belongs to a composition parent (shared) or a unit (local). */
type TransformScope = 'shared' | 'view';
/**
* Where the cursor sits relative to a data transform:
* - `step` in a `transform: [ … ]` element slot (offer the field-typed catalog),
* tagged with whether that array is a composition parent's (shared) or a unit's;
* - `none` anywhere else (including inside an existing step object, where the
* schema owns key completion).
*/
export type TransformPlacement = { kind: 'step'; scope: TransformScope } | { kind: 'none' };
/** A byte range into the spec text. */
interface ByteRange {
offset: number;
length: number;
}
/**
* The data-transform site for the view the cursor is in what the CodeLens needs to
* anchor and act. `view` is the enclosing view object's range (where an ` Add
* transform` lens sits when there is no pipeline yet); `array` is the `transform[]`
* range and element count when one exists (where the per-step ` filter` lenses
* sit, and where a new step is spliced in). `scope` marks a composition parent's
* pipeline as shared.
*/
export interface TransformSite {
scope: TransformScope;
view: ByteRange;
array: (ByteRange & { count: number }) | null;
}
/** Composition keys whose presence on a view makes its transform *shared* upstream
* the array operators (shared with `spec-cursor`/`spec-insert`) plus the single-child
* `facet`/`repeat`, so this stays one spelling of the operator set. */
const COMPOSITION_KEYS = [...ARRAY_COMPOSITIONS, 'facet', 'repeat'];
/** The object node at a jsonc location path, or null if the path isn't an object. */
function objectNodeAt(tree: Node | undefined, path: ReadonlyArray<string | number>): Node | null {
if (!tree) return null;
const node = findNodeAtLocation(tree, path as (string | number)[]);
return node && node.type === 'object' ? node : null;
}
/** The property keys of an object node. */
function objectKeys(node: Node | null): string[] {
if (!node?.children) return [];
const keys: string[] = [];
for (const prop of node.children) {
const key = prop.type === 'property' ? prop.children?.[0] : undefined;
if (key?.type === 'string' && typeof key.value === 'string') keys.push(key.value);
}
return keys;
}
/** A view carrying a composition key feeds all its children, so its transform is shared. */
function scopeFromKeys(keys: readonly string[]): TransformScope {
return keys.some((k) => COMPOSITION_KEYS.includes(k)) ? 'shared' : 'view';
}
/**
* Classify the cursor's data-transform context. Read from `jsonc-parser`'s
* error-tolerant tree so it holds mid-edit a step slot is a `transform` array
* element position (`[ …, "transform", <index> ]`) that is *not* a property key,
* which excludes the cursor being inside a step object editing its own keys (the
* schema owns that). `scope` reads the owning view's keys, defaulting to `view` when
* the tree can't resolve them.
*/
export function transformPlacementAt(text: string, offset: number): TransformPlacement {
const { path, isAtPropertyKey } = getLocation(text, offset);
if (
isAtPropertyKey ||
path.length < 2 ||
path[path.length - 2] !== 'transform' ||
typeof path[path.length - 1] !== 'number'
) {
return { kind: 'none' };
}
const owner = objectNodeAt(parseTree(text), path.slice(0, -2));
return { kind: 'step', scope: scopeFromKeys(objectKeys(owner)) };
}
/** Is this object node a view a `transform` can attach to — the root, a composition, or a unit? */
function isViewNode(node: Node): boolean {
if (!node.parent) return true; // the root spec
const keys = objectKeys(node);
if (keys.some((k) => COMPOSITION_KEYS.includes(k))) return true;
return keys.includes('mark') || keys.includes('encoding');
}
/** The `transform` array node of a view object, or null when it has none. */
function transformArrayNode(view: Node): Node | null {
for (const prop of view.children ?? []) {
if (prop.type === 'property' && prop.children?.[0]?.value === 'transform') {
const value = prop.children[1];
return value?.type === 'array' ? value : null;
}
}
return null;
}
/**
* The transform site for the nearest view enclosing `offset` climbed from the
* error-tolerant tree (so it holds mid-edit), or null when the cursor is in no view.
* The CodeLens reads this to place its lenses and to splice a new step; the byte
* ranges are turned into Monaco positions by the service.
*/
export function transformSiteAt(text: string, offset: number): TransformSite | null {
const tree = parseTree(text);
if (!tree) return null;
let node: Node | undefined = findNodeAtOffset(tree, offset, true);
while (node) {
if (node.type === 'object' && isViewNode(node)) break;
node = node.parent;
}
if (!node) return null;
const array = transformArrayNode(node);
return {
scope: scopeFromKeys(objectKeys(node)),
view: { offset: node.offset, length: node.length },
array: array
? { offset: array.offset, length: array.length, count: array.children?.length ?? 0 }
: null,
};
}
// ─── Field-typed default picking ────────────────────────────────────────────────
/** What kind of column a placeholder wants, so its default is type-appropriate. */
type FieldNeed = 'any' | 'numeric' | 'temporal' | 'categorical';
/** The first column matching a need, or null when none is in scope. */
function pick(fields: readonly TypedField[], need: FieldNeed): string | null {
switch (need) {
case 'numeric':
return fields.find((f) => f.type === 'number')?.name ?? null;
case 'temporal':
return fields.find((f) => f.type === 'date')?.name ?? null;
case 'categorical':
return fields.find((f) => f.type === 'string' || f.type === 'boolean')?.name ?? null;
case 'any':
return fields[0]?.name ?? null;
}
}
/** A tab-stop's default text: the resolved column when one is in scope, else a name. */
function def(fields: readonly TypedField[], need: FieldNeed, fallback: string): string {
return pick(fields, need) ?? fallback;
}
// ─── The transform catalog ──────────────────────────────────────────────────────
/** One data transform the editor can scaffold as a `transform[]` step. */
export interface DataTransform {
id: string;
/** Completion label. */
label: string;
/** One-line description (completion detail). */
detail: string;
/** The step snippet: compact JSON with `${n:default}` tab stops. */
step(fields: readonly TypedField[]): string;
}
/** The scaffoldable transforms, in rough order of how often they're reached for. */
export const DATA_TRANSFORMS: readonly DataTransform[] = [
{
id: 'filter',
label: 'filter',
detail: 'Keep only rows matching a predicate',
step: (f) => `{ "filter": "datum.\${1:${def(f, 'any', 'field')}} > \${2:0}" }`,
},
{
id: 'calculate',
label: 'calculate',
detail: 'Derive a new field from an expression',
step: (f) => `{ "calculate": "datum.\${1:${def(f, 'any', 'field')}}", "as": "\${2:newField}" }`,
},
{
id: 'aggregate',
label: 'aggregate',
detail: 'Group rows and summarize (mean, sum, count…)',
step: (f) =>
`{ "aggregate": [{ "op": "\${1:mean}", "field": "\${2:${def(f, 'numeric', 'value')}}", "as": "\${3:${def(f, 'numeric', 'value')}_mean}" }], "groupby": ["\${4:${def(f, 'categorical', 'category')}}"] }`,
},
{
id: 'bin',
label: 'bin',
detail: 'Bucket a quantitative field into bins',
step: (f) =>
`{ "bin": \${1:true}, "field": "\${2:${def(f, 'numeric', 'value')}}", "as": "\${3:${def(f, 'numeric', 'value')}_binned}" }`,
},
{
id: 'timeUnit',
label: 'timeUnit',
detail: 'Derive a calendar unit (year, month…) from a date',
step: (f) =>
`{ "timeUnit": "\${1:yearmonth}", "field": "\${2:${def(f, 'temporal', 'date')}}", "as": "\${3:${def(f, 'temporal', 'date')}_unit}" }`,
},
{
id: 'window',
label: 'window',
detail: 'Running totals, ranks, row numbers over a sort',
step: (f) =>
`{ "window": [{ "op": "\${1:row_number}", "as": "\${2:rank}" }], "sort": [{ "field": "\${3:${def(f, 'any', 'field')}}", "order": "descending" }] }`,
},
{
id: 'joinaggregate',
label: 'joinaggregate',
detail: 'Group aggregate that keeps every row (share of total)',
step: (f) =>
`{ "joinaggregate": [{ "op": "\${1:sum}", "field": "\${2:${def(f, 'numeric', 'value')}}", "as": "\${3:total}" }], "groupby": ["\${4:${def(f, 'categorical', 'category')}}"] }`,
},
{
id: 'fold',
label: 'fold',
detail: 'Reshape wide columns into key/value rows',
step: (f) =>
`{ "fold": ["\${1:${f[0]?.name ?? 'field1'}}", "\${2:${f[1]?.name ?? 'field2'}}"], "as": ["\${3:key}", "\${4:value}"] }`,
},
{
id: 'lookup',
label: 'lookup',
detail: 'Join fields from another dataset by a key',
step: () =>
`{ "lookup": "\${1:key}", "from": { "data": { "name": "\${2:dataset}" }, "key": "\${3:key}", "fields": ["\${4:field}"] } }`,
},
];
+193
View File
@@ -0,0 +1,193 @@
import { describe, expect, test } from 'vitest';
import {
classifyData,
dataBindingAtPath,
forEachDataBinding,
libraryRefName,
mapDataBindings,
selfDefinedNames,
setDataBindingAtPath,
} from './spec-data';
describe('classifyData', () => {
test('classifies the four Vega-Lite data shapes', () => {
expect(classifyData({ url: 'x.csv' })).toBe('url');
expect(classifyData({ values: [{ a: 1 }] })).toBe('inline');
expect(classifyData({ sequence: { start: 0, stop: 5 } })).toBe('generator');
expect(classifyData({ sphere: true })).toBe('generator');
expect(classifyData({ graticule: true })).toBe('generator');
expect(classifyData({ name: 'Sales' })).toBe('named');
});
test('url / values / generator win over a bare name (Vega-Lite precedence)', () => {
// A name on these shapes is a runtime-rebind label, not a named reference.
expect(classifyData({ name: 'pts', values: [{ a: 1 }] })).toBe('inline');
expect(classifyData({ name: 'remote', url: 'x.csv' })).toBe('url');
expect(classifyData({ name: 'seq', sequence: { start: 0, stop: 5 } })).toBe('generator');
});
test('returns null for non-data values', () => {
expect(classifyData(null)).toBeNull();
expect(classifyData(undefined)).toBeNull();
expect(classifyData('string')).toBeNull();
expect(classifyData([])).toBeNull();
expect(classifyData({})).toBeNull();
expect(classifyData({ name: 123 })).toBeNull(); // a non-string name is not named data
});
});
describe('selfDefinedNames', () => {
test('collects top-level datasets keys', () => {
expect([...selfDefinedNames({ datasets: { a: [], b: [] } })].sort()).toEqual(['a', 'b']);
});
test('is empty when there is no datasets map', () => {
expect(selfDefinedNames({ data: { name: 'x' } }).size).toBe(0);
expect(selfDefinedNames(null).size).toBe(0);
});
});
describe('libraryRefName', () => {
const none = new Set<string>();
test('returns the name of a true named reference', () => {
expect(libraryRefName({ name: 'Sales' }, none)).toBe('Sales');
});
test('returns null for inline/url/generator data carrying a name', () => {
expect(libraryRefName({ name: 'pts', values: [{ a: 1 }] }, none)).toBeNull();
expect(libraryRefName({ name: 'remote', url: 'x.csv' }, none)).toBeNull();
expect(libraryRefName({ name: 'seq', sequence: { start: 0, stop: 5 } }, none)).toBeNull();
});
test('returns null for a self-defined name', () => {
expect(libraryRefName({ name: 'local' }, new Set(['local']))).toBeNull();
});
test('returns null for a non-data value', () => {
expect(libraryRefName(null, none)).toBeNull();
expect(libraryRefName({ values: [{ a: 1 }] }, none)).toBeNull();
});
});
describe('dataBindingAtPath', () => {
test('finds the top-level data when no view overrides it', () => {
const spec = { data: { name: 'Sales' }, layer: [{ mark: 'bar', encoding: { x: {} } }] };
// Cursor deep inside the first layer's encoding — inherits the top-level data.
const binding = dataBindingAtPath(spec, ['layer', 0, 'encoding', 'x']);
expect(binding).toEqual({ data: { name: 'Sales' }, anchorPath: [] });
});
test('the nearest enclosing view wins (Vega-Lite data inheritance)', () => {
const spec = {
data: { name: 'Top' },
layer: [{ data: { name: 'Inner' }, encoding: { y: { field: 'a' } } }],
};
const binding = dataBindingAtPath(spec, ['layer', 0, 'encoding', 'y', 'field']);
expect(binding).toEqual({ data: { name: 'Inner' }, anchorPath: ['layer', 0] });
});
test('a sibling view does not leak its data', () => {
const spec = {
layer: [
{ data: { name: 'A' }, mark: 'bar' },
{ data: { name: 'B' }, mark: 'line' },
],
};
expect(dataBindingAtPath(spec, ['layer', 1, 'mark'])).toEqual({
data: { name: 'B' },
anchorPath: ['layer', 1],
});
});
test('resolves through a facet/repeat child spec', () => {
const spec = { facet: { field: 'g' }, spec: { data: { name: 'Child' }, mark: 'point' } };
expect(dataBindingAtPath(spec, ['spec', 'mark'])).toEqual({
data: { name: 'Child' },
anchorPath: ['spec'],
});
});
test('returns null when no ancestor on the path declares data', () => {
expect(dataBindingAtPath({ mark: 'bar' }, ['mark'])).toBeNull();
});
test('descends defensively past a non-object on the path', () => {
expect(dataBindingAtPath({ mark: 'bar' }, ['mark', 'type', 'nope'])).toBeNull();
});
});
describe('setDataBindingAtPath', () => {
test('sets the data block of the view at the anchor path', () => {
const spec = { layer: [{ mark: 'line', data: { values: [{ a: 1 }] } }] };
expect(setDataBindingAtPath(spec, ['layer', 0], { name: 'X' })).toBe(true);
expect(spec.layer[0].data).toEqual({ name: 'X' });
});
test('sets the root data block for the empty path', () => {
const spec: { data?: unknown; mark: string } = { mark: 'bar' };
expect(setDataBindingAtPath(spec, [], { name: 'Root' })).toBe(true);
expect(spec.data).toEqual({ name: 'Root' });
});
test('returns false when the path does not resolve to an object', () => {
const spec = { layer: [{ mark: 'line' }] };
expect(setDataBindingAtPath(spec, ['layer', 3], { name: 'X' })).toBe(false);
expect(setDataBindingAtPath({ mark: 'bar' }, ['mark'], { name: 'X' })).toBe(false);
});
});
describe('forEachDataBinding', () => {
test('visits every object node and prunes the data/datasets payloads', () => {
// A row inside `data.values` carries a field literally named `data`; the prune
// keeps the walk from visiting it as a binding.
const spec = {
datasets: { ds: [{ z: 1 }] },
layer: [{ data: { name: 'A' } }, { data: { values: [{ data: { name: 'buried' } }] } }],
};
const seen: unknown[] = [];
forEachDataBinding(spec, (node) => {
if (node.data !== undefined) seen.push(node.data);
});
expect(seen).toEqual([{ name: 'A' }, { values: [{ data: { name: 'buried' } }] }]);
});
test('passes the spec self-defined names and stops early on a true return', () => {
const spec = {
datasets: { ds: [{ a: 1 }] },
layer: [{ data: { name: 'ds' } }, { data: { name: 'other' } }],
};
let visits = 0;
let sawSelfDefined: ReadonlySet<string> | null = null;
forEachDataBinding(spec, (node, selfDefined) => {
visits++;
sawSelfDefined = selfDefined;
return node.data !== undefined; // stop at the first node carrying a data block
});
expect(sawSelfDefined && [...sawSelfDefined]).toEqual(['ds']);
// root (no data) → layer[0] (has data, stop). layer[1] never visited.
expect(visits).toBe(2);
});
});
describe('mapDataBindings', () => {
test('maps each data block, carries datasets through, and prunes payloads', () => {
const spec = {
datasets: { ds: [{ a: 1 }] },
layer: [{ data: { name: 'A' } }, { data: { values: [{ data: 'row-field' }] } }],
};
const out = mapDataBindings(spec, (data) =>
typeof (data as { name?: unknown }).name === 'string' ? { name: 'MAPPED' } : data,
) as typeof spec;
expect(out.layer[0].data).toEqual({ name: 'MAPPED' });
expect(out.layer[1].data).toEqual({ values: [{ data: 'row-field' }] }); // payload untouched
expect(out.datasets).toEqual({ ds: [{ a: 1 }] }); // carried through
});
test('does not mutate the input', () => {
const spec = { data: { name: 'A' } };
const before = structuredClone(spec);
mapDataBindings(spec, () => ({ name: 'B' }));
expect(spec).toEqual(before);
});
});
+197
View File
@@ -0,0 +1,197 @@
/**
* The Vega-Lite data model the single place that decides what a `data` block
* *means* (docs/architecture/05 Rendering Contract; 07 Relationships). Pure
* core: no browser APIs, no React.
*
* A Vega-Lite `data` block is exactly one of four shapes (mirrored from
* vega-lite/src/data.ts `isUrlData`/`isInlineData`/`isNamedData`/`isGenerator`):
*
* - **url** has `url`
* - **inline** has `values` (and MAY also carry a `name` label)
* - **generator** has `sequence` | `sphere` | `graticule`
* - **named** has a string `name`, and NONE of the above
*
* Astrolabe binds its library datasets through *named* data: `{ data: { name } }`
* resolves to the library dataset's rows at render time (core/rendering). The
* fidelity rule, taken verbatim from Vega-Lite, is that a `name` ALONE does not
* make a reference a `name` riding on inline `values`, a `url`, or a generator
* is a label Vega-Lite uses to rebind data at runtime, and Astrolabe must leave it
* untouched. Classifying by this rule (not by "has a string name") is what keeps
* Astrolabe a strict *extension* of Vega-Lite rather than a divergence: every
* native data form keeps working, and only true named references are resolved.
*
* Reference detection (`extractDatasetRefs`/rename in core/spec-refs) and reference
* resolution (core/rendering) both run through `libraryRefName` here, so the two
* can never disagree on what counts as a library dependency.
*/
import { isJsonObject } from './spec-config';
/** The four Vega-Lite data shapes; `null` when the value is not a data block. */
export type DataKind = 'url' | 'inline' | 'generator' | 'named';
/** The keys that mark a Vega-Lite data *generator* (sequence/sphere/graticule). */
const GENERATOR_KEYS = ['sequence', 'sphere', 'graticule'] as const;
/**
* Classify a Vega-Lite `data` value, matching Vega-Lite's own precedence: `url`
* and `values` and generator keys win over a bare `name`, so a named-inline or
* named-url block is inline/url data not a named reference. Returns `null` for a
* non-object, `data: null`, or an empty/unrecognized block.
*/
export function classifyData(data: unknown): DataKind | null {
if (!isJsonObject(data)) return null;
if ('url' in data) return 'url';
if ('values' in data) return 'inline';
if (GENERATOR_KEYS.some((key) => key in data)) return 'generator';
if (typeof data.name === 'string') return 'named';
return null;
}
/**
* The set of dataset names a spec defines for itself via a top-level `datasets`
* object. These are resolved natively by Vega-Lite, so they are never library
* dependencies and are excluded from reference detection and resolution.
*/
export function selfDefinedNames(spec: unknown): Set<string> {
const names = new Set<string>();
if (isJsonObject(spec) && isJsonObject(spec.datasets)) {
for (const key of Object.keys(spec.datasets)) names.add(key);
}
return names;
}
/**
* The library dataset name a `data` block references, or `null` when it is not a
* library reference i.e. it is named data (`classifyData` `'named'`) whose
* name is not one the spec defines for itself. This is the single predicate behind
* both reference extraction and render-time resolution.
*/
export function libraryRefName(data: unknown, selfDefined: ReadonlySet<string>): string | null {
if (classifyData(data) !== 'named') return null;
const { name } = data as { name: string };
return selfDefined.has(name) ? null : name;
}
/** A `data` block in scope at a cursor, with the path prefix where it was found. */
export interface DataBinding {
/** The raw `data` value (classify it with `classifyData` / `libraryRefName`). */
data: unknown;
/** The path prefix of the view that declares this `data` — `[]` for the root. */
anchorPath: ReadonlyArray<string | number>;
}
/**
* The data binding in scope at a JSON `path` into the spec the `data` block of
* the *nearest enclosing view* (docs/architecture/08 editor augmentation). This
* implements Vega-Lite's data inheritance: a layer/concat/facet/repeat child
* inherits its parent's data unless it declares its own, so the binding is the
* `data` of the deepest object along the path that has one. Returns `null` when no
* ancestor on the path declares `data`.
*
* `path` is a jsonc-parser location path (object keys and array indices, root to
* cursor). The walk descends it defensively, stopping at any non-object/array.
*/
export function dataBindingAtPath(
spec: unknown,
path: ReadonlyArray<string | number>,
): DataBinding | null {
let node: unknown = spec;
let binding: DataBinding | null = null;
if (isJsonObject(node) && 'data' in node) binding = { data: node.data, anchorPath: [] };
for (let i = 0; i < path.length; i++) {
if (node === null || typeof node !== 'object') break;
node = (node as Record<string | number, unknown>)[path[i]];
if (isJsonObject(node) && 'data' in node) {
binding = { data: node.data, anchorPath: path.slice(0, i + 1) };
}
}
return binding;
}
/**
* Set the `data` block of the view at `anchorPath` (an anchor from
* `dataBindingAtPath`), in place the write-counterpart of the read above, used by
* Extract-to-Dataset to swap a view's inline data for a `{ name }` reference.
* Mutates `spec`, so the caller passes a copy it owns (a fresh parse). Returns false
* when the path no longer resolves to an object, so the caller can refuse rather
* than write the wrong node.
*/
export function setDataBindingAtPath(
spec: unknown,
anchorPath: ReadonlyArray<string | number>,
block: unknown,
): boolean {
let node: unknown = spec;
for (const key of anchorPath) {
if (node === null || typeof node !== 'object') return false;
node = (node as Record<string | number, unknown>)[key];
}
if (node === null || typeof node !== 'object') return false;
(node as Record<string, unknown>).data = block;
return true;
}
/**
* The one spec walk every data-binding pass shares (reference detection/resolution,
* extraction gates). Visits each object node that could carry a `data` binding,
* with the spec's self-defined `datasets` names, and **prunes two payload keys**
* a `data` object's contents and the top-level `datasets` map hold user data, not
* nested specs, so descending into them would misread a data row carrying a field
* literally named `data` as a binding. `visit` returns `true` to stop early (for a
* predicate); any other return continues. Pruning the same two keys here is what
* keeps every pass in agreement on what counts as a reference they cannot drift
* because they share this walk.
*/
export function forEachDataBinding(
spec: unknown,
visit: (node: Record<string, unknown>, selfDefined: ReadonlySet<string>) => boolean | void,
): void {
const selfDefined = selfDefinedNames(spec);
let stop = false;
const walk = (node: unknown): void => {
if (stop) return;
if (Array.isArray(node)) {
for (const item of node) walk(item);
return;
}
if (!isJsonObject(node)) return;
if (visit(node, selfDefined) === true) {
stop = true;
return;
}
for (const key of Object.keys(node)) {
if (key === 'data' || key === 'datasets') continue;
walk(node[key]);
}
};
walk(spec);
}
/**
* Return a deep copy of `spec` with each data binding's `data` value replaced by
* `mapData(data, selfDefined)` the copy-rewrite counterpart of
* {@link forEachDataBinding}, for rename/promote passes. Prunes the same two keys:
* the top-level `datasets` map is carried through untouched, and a `data` payload is
* never descended into (only its block is mapped). The input is not mutated.
*/
export function mapDataBindings(
spec: unknown,
mapData: (data: unknown, selfDefined: ReadonlySet<string>) => unknown,
): unknown {
const selfDefined = selfDefinedNames(spec);
const rewrite = (node: unknown): unknown => {
if (Array.isArray(node)) return node.map(rewrite);
if (node && typeof node === 'object') {
const out: Record<string, unknown> = {};
for (const [key, value] of Object.entries(node as Record<string, unknown>)) {
if (key === 'data') out[key] = mapData(value, selfDefined);
else if (key === 'datasets') out[key] = value;
else out[key] = rewrite(value);
}
return out;
}
return node;
};
return rewrite(spec);
}
+97
View File
@@ -0,0 +1,97 @@
import { describe, expect, it } from 'vitest';
import { EXPRESSION_KEYS, expressionStringsIn, firstExpressionError } from './spec-expressions';
/** The substring a span points at, to assert the offset/length land on the text. */
function slice(text: string, span: { offset: number; length: number }): string {
return text.slice(span.offset, span.offset + span.length);
}
describe('spec-expressions', () => {
it('declares the expression-bearing keys', () => {
expect([...EXPRESSION_KEYS].sort()).toEqual(['calculate', 'expr', 'filter', 'test']);
});
it('finds a calculate expression and points between the quotes', () => {
const text = '{ "transform": [ { "calculate": "datum.a + 1", "as": "b" } ] }';
const spans = expressionStringsIn(text);
expect(spans).toHaveLength(1);
expect(spans[0].key).toBe('calculate');
expect(spans[0].value).toBe('datum.a + 1');
expect(slice(text, spans[0])).toBe('datum.a + 1');
});
it('finds string filter and test, in source order', () => {
const text =
'{ "transform": [ { "filter": "datum.x > 0" } ], "encoding": { "color": { "condition": { "test": "datum.x > 5", "value": "red" } } } }';
const spans = expressionStringsIn(text);
expect(spans.map((s) => s.value)).toEqual(['datum.x > 0', 'datum.x > 5']);
});
it('excludes object-form filter predicates (only string values are expressions)', () => {
const text =
'{ "transform": [ { "filter": { "field": "x", "gt": 0 } }, { "filter": { "param": "brush" } } ] }';
expect(expressionStringsIn(text)).toEqual([]);
});
it('finds an expr value (param / value-ref form)', () => {
const text = '{ "params": [ { "name": "n", "expr": "width / 2" } ] }';
const spans = expressionStringsIn(text);
expect(spans.map((s) => s.value)).toEqual(['width / 2']);
});
it('finds expressions across all views of a composition', () => {
const text =
'{ "hconcat": [ { "transform": [ { "calculate": "datum.a", "as": "a2" } ] }, { "transform": [ { "calculate": "datum.b", "as": "b2" } ] } ] }';
expect(expressionStringsIn(text).map((s) => s.value)).toEqual(['datum.a', 'datum.b']);
});
it('keeps offsets aligned when the string contains escapes', () => {
const text = '{ "transform": [ { "calculate": "datum[\\"a b\\"]", "as": "c" } ] }';
const spans = expressionStringsIn(text);
expect(spans).toHaveLength(1);
// The raw inner token (escapes intact) is what the span covers.
expect(slice(text, spans[0])).toBe('datum[\\"a b\\"]');
});
it('yields a zero-length span for an empty expression', () => {
const text = '{ "transform": [ { "calculate": "", "as": "c" } ] }';
const spans = expressionStringsIn(text);
expect(spans).toHaveLength(1);
expect(spans[0].length).toBe(0);
expect(spans[0].value).toBe('');
});
it('returns nothing for unparseable text rather than throwing', () => {
expect(expressionStringsIn('{ "transform": [ { "calc')).toEqual([]);
});
});
describe('firstExpressionError', () => {
it('returns null when every expression parses', () => {
const text = '{ "transform": [ { "filter": "datum.value > 0" } ] }';
expect(firstExpressionError(text)).toBeNull();
});
it('attributes a malformed expression to its property, with the parser message', () => {
const text = '{ "transform": [ { "filter": "datum[\'value\'] ==" } ] }';
const err = firstExpressionError(text);
expect(err?.key).toBe('filter');
expect(err?.expr).toBe("datum['value'] ==");
expect(err?.message).toBe('Unexpected end of input');
});
it('reports the 1-based line of the offending expression', () => {
const text = ['{', ' "transform": [', ' { "calculate": "clamp(" }', ' ]', '}'].join('\n');
expect(firstExpressionError(text)?.line).toBe(3);
});
it('ignores empty expressions (incomplete, not malformed)', () => {
expect(firstExpressionError('{ "transform": [ { "calculate": "", "as": "x" } ] }')).toBeNull();
});
it('returns the first malformed expression in document order', () => {
const text =
'{ "transform": [ { "calculate": "datum.a +", "as": "x" }, { "filter": "datum.b ==" } ] }';
expect(firstExpressionError(text)?.key).toBe('calculate');
});
});
+123
View File
@@ -0,0 +1,123 @@
/**
* Expression strings within a spec document (docs/architecture/08 editor
* augmentation). Portable core: `jsonc-parser`'s error-tolerant tree only no
* browser APIs, no React, no Monaco so it keeps working while the draft is
* briefly unparseable mid-edit.
*
* Vega-Lite embeds the Vega expression language inside a handful of JSON string
* values; the JSON schema sees them as opaque strings. This module finds every
* such string so the editor can validate them ( markers) and so the completion /
* hover providers share one definition of "is the cursor in an expression?". The
* single-expression analysis itself lives in core/expr-validate; this module only
* *locates* the expressions in a document.
*/
import { parseTree, type Node } from 'jsonc-parser';
import { validateExpression } from './expr-validate';
/**
* Property keys whose **string** value is a Vega expression. `filter` and `test`
* also accept *object* predicates (`{field, gt}`, `{param}`) those are not
* expressions and are excluded by the string-value check, not by this set.
*/
export const EXPRESSION_KEYS: ReadonlySet<string> = new Set([
'calculate',
'filter',
'expr',
'test',
]);
/** One expression string located in the document. */
export interface ExpressionSpan {
/** The property the expression sits under (`calculate`, `filter`, `expr`, `test`). */
key: string;
/** The expression text (the JSON string's unescaped value). */
value: string;
/** Byte offset of the first character *inside* the quotes. */
offset: number;
/** Length of the characters between the quotes (the raw token, escapes intact). */
length: number;
}
/** This property node's key + value when it is `<expr-key>: <string>`, else null. */
function expressionValue(node: Node): { key: string; valueNode: Node } | null {
if (node.type !== 'property' || !node.children) return null;
const [keyNode, valueNode] = node.children;
const key = typeof keyNode?.value === 'string' ? keyNode.value : undefined;
if (key === undefined || !EXPRESSION_KEYS.has(key)) return null;
return valueNode?.type === 'string' && typeof valueNode.value === 'string'
? { key, valueNode }
: null;
}
/**
* Every Vega expression string in the document, in source order. The span covers
* the characters *between* the quotes (so a marker squiggles the expression, not
* the JSON punctuation); an empty string yields a zero-length span the caller can
* ignore. Returns `[]` for unparseable text.
*/
export function expressionStringsIn(text: string): ExpressionSpan[] {
const tree = parseTree(text);
if (!tree) return [];
const spans: ExpressionSpan[] = [];
const walk = (node: Node): void => {
const found = expressionValue(node);
if (found) {
// Inner span = the quoted token minus its two quotes. Computed from the raw
// token length (not value.length) so escapes inside the string stay aligned.
spans.push({
key: found.key,
value: found.valueNode.value as string,
offset: found.valueNode.offset + 1,
length: Math.max(0, found.valueNode.length - 2),
});
}
if (node.children) for (const child of node.children) walk(child);
};
walk(tree);
return spans;
}
/** A syntactically-invalid expression located in the document. */
export interface ExpressionError {
/** The property it sits under (`filter`, `calculate`, …) — to name the problem. */
key: string;
/** The offending expression text. */
expr: string;
/** The parser's message — the same text the editor marker and hover show. */
message: string;
/** 1-based line of the expression, so the message can point at it (matches the
* editor's line numbers). */
line: number;
}
/** The 1-based line number of a byte offset in `text` (newlines counted before it). */
function lineAtOffset(text: string, offset: number): number {
let line = 1;
for (let i = 0; i < offset && i < text.length; i++) {
if (text[i] === '\n') line++;
}
return line;
}
/**
* The first syntactically-invalid expression in the document, or null when every
* expression parses. Lets a failed render attribute itself to a broken expression
* a precise, located message that agrees with the editor's squiggle instead of
* the generic "check your JSON" fallback (the JSON is valid; the expression isn't).
*/
export function firstExpressionError(text: string): ExpressionError | null {
for (const span of expressionStringsIn(text)) {
if (span.value.trim() === '') continue;
const result = validateExpression(span.value);
if (!result.valid) {
return {
key: span.key,
expr: span.value,
message: result.error ?? 'Invalid expression.',
line: lineAtOffset(text, span.offset),
};
}
}
return null;
}
+70
View File
@@ -0,0 +1,70 @@
import { describe, expect, it } from 'vitest';
import { derivedFieldNamesAtPath } from './spec-fields';
describe('derivedFieldNamesAtPath', () => {
const spec = {
transform: [{ calculate: 'x', as: 'shared' }],
layer: [
{ transform: [{ calculate: 'y', as: 'inner' }], mark: 'bar' },
{ transform: [{ calculate: 'z', as: 'sibling' }], mark: 'line' },
],
};
it('collects calculate / timeUnit / bin as-names', () => {
const s = {
transform: [
{ calculate: 'datum.a + 1', as: 'plusOne' },
{ timeUnit: 'month', field: 'date', as: 'mo' },
{ bin: true, field: 'x', as: ['x_start', 'x_end'] },
],
};
expect(derivedFieldNamesAtPath(s, []).sort()).toEqual(['mo', 'plusOne', 'x_end', 'x_start']);
});
it('reaches into op-list transforms (aggregate/window/joinaggregate)', () => {
const s = {
transform: [
{ aggregate: [{ op: 'mean', field: 'v', as: 'meanV' }], groupby: ['g'] },
{ window: [{ op: 'rank', as: 'rk' }] },
{ joinaggregate: [{ op: 'sum', field: 'v', as: 'total' }] },
],
};
expect(derivedFieldNamesAtPath(s, []).sort()).toEqual(['meanV', 'rk', 'total']);
});
it('defaults fold output to key/value when as is omitted', () => {
expect(derivedFieldNamesAtPath({ transform: [{ fold: ['a', 'b'] }] }, []).sort()).toEqual([
'key',
'value',
]);
expect(
derivedFieldNamesAtPath({ transform: [{ fold: ['a'], as: ['k', 'v'] }] }, []).sort(),
).toEqual(['k', 'v']);
});
it('returns nothing for a spec with no transforms', () => {
expect(derivedFieldNamesAtPath({ mark: 'bar', encoding: { x: { field: 'a' } } }, [])).toEqual(
[],
);
});
it('includes ancestor transforms but not sibling-view transforms', () => {
// Inside layer 0: sees its own `inner` and the inherited top-level `shared`,
// but never layer 1's `sibling`.
expect(derivedFieldNamesAtPath(spec, ['layer', 0, 'mark']).sort()).toEqual(['inner', 'shared']);
expect(derivedFieldNamesAtPath(spec, ['layer', 1, 'mark']).sort()).toEqual([
'shared',
'sibling',
]);
});
it('at the root sees only the top-level transforms', () => {
expect(derivedFieldNamesAtPath(spec, [])).toEqual(['shared']);
});
it('descends defensively past a non-object on the path', () => {
expect(derivedFieldNamesAtPath(spec, ['transform', 0, 'calculate', 'nope'])).toEqual([
'shared',
]);
});
});
+80
View File
@@ -0,0 +1,80 @@
/**
* Fields a spec introduces through its own transforms (docs/architecture/08
* editor augmentation).
*
* Portable core: pure spec analysis, no data. The editor's field hints
* (completion/hover/inlay) suggest the bound dataset's source columns *plus* the
* fields the spec derives so a column you just created with a `calculate` is
* offered too. This collects the statically-named ones: every transform `as`
* (`calculate`, `timeUnit`, `bin`, `stack`, `fold`, `flatten`, `regression`, )
* and the nested `as` of the op-list transforms (`aggregate`, `window`,
* `joinaggregate`), scoped to the transforms in effect at a cursor the enclosing
* view's and its ancestors' (Vega-Lite applies a parent view's transforms upstream
* of its children; a sibling view's do not apply).
*
* Out of scope by design: **data-dependent** derived columns `pivot`'s
* one-column-per-value output and `lookup`'s imported fields which only exist
* once the pipeline runs over the actual data. Naming those would mean executing
* transforms, far beyond a static hint.
*/
/** Add an `as` value (a string, or the `[start, end]` / key-value pair array). */
function addAs(names: Set<string>, as: unknown): void {
if (typeof as === 'string') names.add(as);
else if (Array.isArray(as)) for (const a of as) if (typeof a === 'string') names.add(a);
}
/** Collect the field name(s) a single transform entry introduces. */
function collectFromTransform(transform: unknown, names: Set<string>): void {
if (transform === null || typeof transform !== 'object') return;
const t = transform as Record<string, unknown>;
addAs(names, t.as);
// `fold` defaults its output to ['key', 'value'] when `as` is omitted.
if ('fold' in t && !('as' in t)) {
names.add('key');
names.add('value');
}
// Op-list transforms nest their `as` inside each operation.
for (const key of ['aggregate', 'window', 'joinaggregate']) {
const ops = t[key];
if (Array.isArray(ops)) {
for (const op of ops) {
if (op !== null && typeof op === 'object') addAs(names, (op as Record<string, unknown>).as);
}
}
}
}
/** Add every field name a node's own `transform` array introduces. */
function collectNodeTransforms(node: unknown, names: Set<string>): void {
if (
node !== null &&
typeof node === 'object' &&
Array.isArray((node as { transform?: unknown }).transform)
) {
for (const t of (node as { transform: unknown[] }).transform) collectFromTransform(t, names);
}
}
/**
* The fields derived by transforms *in scope* at a JSON `path` into the spec the
* `as` outputs of every transform on the cursor's enclosing view and its ancestors
* (docs/architecture/08 editor augmentation). Vega-Lite applies a parent view's
* transforms upstream of its children, so a field a parent's `calculate` creates is
* visible to a nested view; sibling views' transforms are not. The walk descends
* `path` (a jsonc-parser location path) defensively, collecting along the way.
*/
export function derivedFieldNamesAtPath(
spec: unknown,
path: ReadonlyArray<string | number>,
): string[] {
const names = new Set<string>();
let node: unknown = spec;
collectNodeTransforms(node, names);
for (const key of path) {
if (node === null || typeof node !== 'object') break;
node = (node as Record<string | number, unknown>)[key];
collectNodeTransforms(node, names);
}
return [...names];
}
+105
View File
@@ -0,0 +1,105 @@
import { describe, expect, it } from 'vitest';
import {
inlineValuesOf,
rowsForDataBinding,
selfDefinedPayloadOf,
specHasExtractableData,
} from './spec-inline-data';
describe('rowsForDataBinding', () => {
it('reads inline values from the given binding', () => {
const spec = { data: { values: [{ a: 1 }, { a: 2 }] } };
expect(rowsForDataBinding(spec, spec.data)).toEqual([{ a: 1 }, { a: 2 }]);
});
it('resolves a self-defined named binding against top-level datasets', () => {
const spec = { datasets: { ds: [{ c: 1 }, { c: 2 }] }, data: { name: 'ds' } };
expect(rowsForDataBinding(spec, { name: 'ds' })).toEqual([{ c: 1 }, { c: 2 }]);
});
it('returns null for a library reference, url, generator, or CSV-string values', () => {
const spec = { datasets: { ds: [{ c: 1 }] } };
expect(rowsForDataBinding(spec, { name: 'LibraryRef' })).toBeNull(); // not self-defined
expect(rowsForDataBinding(spec, { url: 'x.csv' })).toBeNull();
expect(rowsForDataBinding(spec, { sequence: { start: 0, stop: 5 } })).toBeNull();
expect(rowsForDataBinding(spec, { values: 'a,b\n1,2' })).toBeNull();
expect(rowsForDataBinding({ mark: 'bar' }, { values: [] })).toBeNull(); // empty rows
});
});
describe('inlineValuesOf', () => {
it('returns the JSON array payload as json', () => {
expect(inlineValuesOf({ values: [{ a: 1 }] })).toEqual({ values: [{ a: 1 }], format: 'json' });
});
it('preserves a CSV/TSV string payload verbatim with its declared format', () => {
expect(inlineValuesOf({ values: 'a,b\n1,2', format: { type: 'csv' } })).toEqual({
values: 'a,b\n1,2',
format: 'csv',
});
expect(inlineValuesOf({ values: 'a\tb', format: { type: 'tsv' } })).toEqual({
values: 'a\tb',
format: 'tsv',
});
});
it('keeps a name riding on inline values (named-inline is still inline)', () => {
expect(inlineValuesOf({ name: 'x', values: [{ a: 1 }] })).toEqual({
values: [{ a: 1 }],
format: 'json',
});
});
it('returns null for a library reference, url, generator, or non-object', () => {
expect(inlineValuesOf({ name: 'sales' })).toBeNull();
expect(inlineValuesOf({ url: 'x.csv' })).toBeNull();
expect(inlineValuesOf({ sequence: { start: 0, stop: 5 } })).toBeNull();
expect(inlineValuesOf(null)).toBeNull();
});
});
describe('selfDefinedPayloadOf', () => {
it('returns the rows of a referenced self-defined datasets entry', () => {
const spec = { datasets: { sales: [{ a: 1 }, { a: 2 }] }, data: { name: 'sales' } };
expect(selfDefinedPayloadOf(spec, spec.data)).toEqual({
values: [{ a: 1 }, { a: 2 }],
format: 'json',
});
});
it('returns null for a library reference, inline values, or a non-array entry', () => {
const spec = { datasets: { sales: [{ a: 1 }] } };
expect(selfDefinedPayloadOf(spec, { name: 'LibraryRef' })).toBeNull(); // not self-defined
expect(selfDefinedPayloadOf(spec, { values: [{ a: 1 }] })).toBeNull(); // inline, not named
expect(
selfDefinedPayloadOf({ datasets: { s: 'a,b\n1,2' }, data: {} }, { name: 's' }),
).toBeNull();
});
});
describe('specHasExtractableData', () => {
it('is true for inline values and for a self-defined reference', () => {
expect(specHasExtractableData({ data: { values: [{ a: 1 }] } })).toBe(true);
expect(specHasExtractableData({ datasets: { ds: [{ a: 1 }] }, data: { name: 'ds' } })).toBe(
true,
);
});
it('is false for a library reference, url, or generator', () => {
expect(specHasExtractableData({ data: { name: 'sales' } })).toBe(false);
expect(specHasExtractableData({ data: { url: 'x.csv' } })).toBe(false);
expect(specHasExtractableData({ data: { sequence: { start: 0, stop: 5 } } })).toBe(false);
});
it('ignores a field literally named data inside a user data row (prune-the-payload)', () => {
// The walker prunes the `data`/`datasets` keys, so a row that carries a field
// named `data` is never mistaken for a binding — only the genuine top-level
// inline binding triggers, and an encoding field named `data` triggers nothing.
expect(
specHasExtractableData({ data: { values: [{ data: { name: 'x' } }] }, mark: 'bar' }),
).toBe(true);
expect(
specHasExtractableData({ layer: [{ mark: 'bar', encoding: { x: { field: 'data' } } }] }),
).toBe(false);
});
});

Some files were not shown because too many files have changed in this diff Show More