Compare commits

...

40 Commits

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

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