mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Compare commits
40 Commits
c19857b0a7
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 7846a8ec41 | |||
|
5ef3d402ab
|
|||
|
4925268168
|
|||
|
0928912c4d
|
|||
|
42717fe4f7
|
|||
|
194177c0c5
|
|||
|
e4ff442219
|
|||
|
acf14a8b13
|
|||
|
74b810f8c9
|
|||
|
f5b2620458
|
|||
|
65090da72c
|
|||
|
5ec7c3aa77
|
|||
|
db258a4e54
|
|||
|
552900dac5
|
|||
|
fb4b42a1a7
|
|||
|
e42a535726
|
|||
|
c5e4c4c76d
|
|||
|
87b1b98c12
|
|||
|
da6a675982
|
|||
|
9b2618cac6
|
|||
|
a8696f7195
|
|||
|
c019660692
|
|||
|
26b383ff33
|
|||
|
216797ff9b
|
|||
|
57604a80a6
|
|||
|
345ecef5a3
|
|||
|
4d1825b5ff
|
|||
|
01df58acf1
|
|||
|
8aa05e503d
|
|||
|
6656811b8e
|
|||
|
8cad80f738
|
|||
|
a75ea5b59e
|
|||
|
e69430834a
|
|||
|
31458114fb
|
|||
|
20e70bee0e
|
|||
|
fd5c51a761
|
|||
|
0c592fa81e
|
|||
|
64977f961b
|
|||
|
fff7ae98f2
|
|||
|
43d3b480d9
|
@@ -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
|
||||
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
|
||||
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
|
||||
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,
|
||||
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
|
||||
conflict, prefer in this order: **SOUL.md philosophy > `docs/spec/` behavioral contract >
|
||||
`docs/architecture/` patterns > local cleanup**. These instructions are not strictly
|
||||
conflict, prefer in this order: **SOUL.md philosophy > `docs/spec/` behavioral record >
|
||||
`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.
|
||||
|
||||
### 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
|
||||
unexported — `export type` the type, not its source array. Verify with Grep before
|
||||
exporting "for future use"; the future caller can add the export.
|
||||
- **Smell baseline** (Fowler, _Refactoring_ ch. 3 — judgement calls, never hard
|
||||
violations; a documented project rule overrides, and skip anything eslint/Prettier
|
||||
already enforces): mysterious name (rename — if no honest name comes, the design is
|
||||
murky); data clumps (the same few params traveling together → one type); primitive
|
||||
obsession (a string/number standing in for a domain concept); feature envy (a function
|
||||
reaching into another module's data more than its own); repeated switches (the same
|
||||
discriminant cascade at multiple sites → one shared map); message chains
|
||||
(`a.b().c().d()` → hide the walk behind the first object); middle man (a layer that
|
||||
only delegates → call the target directly). Duplication and speculative generality are
|
||||
covered by the cleanup rules above.
|
||||
|
||||
5. **Styles and UI**: When altering CSS or layout, follow or generalize existing patterns
|
||||
(CSS Modules + design tokens in `styles/tokens.css`) rather than writing from scratch. Don't
|
||||
@@ -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
|
||||
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`):
|
||||
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
|
||||
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
|
||||
|
||||
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
|
||||
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.
|
||||
@@ -188,7 +222,7 @@ role`) or the rule it demonstrates. - **Positional sub-section cross-refs.** Cit
|
||||
|
||||
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/IMPLEMENTATION-PLAN.md`** — mark milestone progress.
|
||||
Use the `/doc-update` skill for session-discovered gaps. The list is not exclusive.
|
||||
@@ -208,7 +242,7 @@ If `package.json` changed:
|
||||
### Alignment Check
|
||||
|
||||
- **SOUL.md** — philosophy (must not violate without good reason).
|
||||
- **`docs/spec/`** — behavioral contract.
|
||||
- **`docs/spec/`** — behavioral record.
|
||||
- **`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
|
||||
|
||||
- **SOUL.md** philosophy; **`docs/spec/`** behavioral contract; **`docs/architecture/`** patterns.
|
||||
- **SOUL.md** philosophy; **`docs/spec/`** behavioral record; **`docs/architecture/`** patterns.
|
||||
|
||||
---
|
||||
|
||||
@@ -269,9 +303,9 @@ Update JSDoc/inline comments if signatures or behavior changed.
|
||||
## Reference Documents
|
||||
|
||||
| Document | Purpose |
|
||||
| ------------------------------------------------------------------- | ----------------------------------------- |
|
||||
| ------------------------------------------------------------------- | ---------------------------------------- |
|
||||
| [SOUL.md](../../../SOUL.md) | Project philosophy and core values |
|
||||
| [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/IMPLEMENTATION-PLAN.md](../../../docs/IMPLEMENTATION-PLAN.md) | Milestone sequence and scope |
|
||||
|
||||
@@ -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)
|
||||
|
||||
- **`docs/spec/`** — the _what_: behavioral contract (what the app does, acceptance points).
|
||||
This is a **contract**. Only change it when product behavior genuinely changes, and do so
|
||||
deliberately — never as a casual "fill a doc gap" edit. A how-detail does NOT belong here.
|
||||
- **`docs/spec/`** — the _what_: behavioral record (what the app does, acceptance points).
|
||||
The code leads; the spec is kept rewritten to match what shipped. If the session added or
|
||||
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,
|
||||
modals, routing, rendering, inference, relationships). Most navigation maps and decision
|
||||
rules land here.
|
||||
@@ -60,8 +61,8 @@ not do Y"). If you can't state it concisely, it may be too implementation-specif
|
||||
Map each gap to the right document:
|
||||
|
||||
| Gap type | Target document |
|
||||
| ------------------------------------------------- | ----------------------------------------------------------------------------- |
|
||||
| Product behavior, capabilities, acceptance points | `docs/spec/` (the relevant 00–10 section) — **contract; change deliberately** |
|
||||
| ------------------------------------------------- | --------------------------------------------------------------------------------- |
|
||||
| Product behavior, capabilities, acceptance points | `docs/spec/` (the relevant 00–10 section) — **record; keep it matching the code** |
|
||||
| State / Zustand stores | `docs/architecture/01-state-and-stores.md` |
|
||||
| Persistence, IndexedDB, localStorage, migrations | `docs/architecture/02-persistence.md` |
|
||||
| Modals, dialog lifecycle | `docs/architecture/03-modal-system.md` |
|
||||
|
||||
@@ -126,7 +126,9 @@ that prevents data loss, or accessibility.
|
||||
**If it must be built — what shape?** Line up the existing instances of the kind (modal,
|
||||
store, service, hook, persistence path), name the canonical shape, list what to reuse, and
|
||||
flag what the new work might make deletable. An abstraction is earned only by ≥ 2 call
|
||||
sites that would use it today (deletion rule 3).
|
||||
sites that would use it today (deletion rule 3). The deletion test settles suspected
|
||||
pass-throughs: imagine the module deleted — if the complexity just vanishes, it was a
|
||||
shallow wrapper; only if it reappears across its callers was it earning its keep.
|
||||
|
||||
No report scaffolding — these two answers are the output.
|
||||
|
||||
|
||||
@@ -14,3 +14,7 @@ coverage
|
||||
|
||||
# M1.5 visual verification screenshots (local only)
|
||||
.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/*/
|
||||
|
||||
@@ -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
|
||||
local, offline-capable, no account.
|
||||
|
||||
It is a **spec-driven rebuild** on an architecture adapted from its sibling project Syto.
|
||||
The authoritative behavioral contract is **`docs/spec/`** (sections 00–10). Implement _to
|
||||
the spec_; do not port legacy code.
|
||||
It is a rebuild on an architecture adapted from its sibling project Syto. **`docs/spec/`**
|
||||
(sections 00–10) is the behavioral record: the code leads and the spec is kept rewritten
|
||||
to match — a user-facing change isn't done until its spec section describes it. Do not
|
||||
port legacy code.
|
||||
|
||||
### 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
|
||||
`/app/`, leaving the landing uncontrolled and always-fresh. **`/learn/` is a second such
|
||||
entry** (`src/learn/`) — the markdown-authored deep-dive section, under the same rules
|
||||
(see architecture 11).
|
||||
(see architecture 11). The landing depicts only shipped behavior: live demos run the
|
||||
real core, and staged visuals (the editor still) mirror the app's actual strings —
|
||||
CodeLens labels, validation messages — never invented UI.
|
||||
|
||||
See [`docs/architecture/`](docs/architecture/00-overview.md) for the patterns behind each
|
||||
layer (state, persistence, modals, routing, rendering, inference, relationships) and
|
||||
@@ -74,7 +77,7 @@ src/
|
||||
│ └── infrastructure/ # IndexedDB, localStorage, Monaco, settings adapters
|
||||
styles/ # Global CSS (tokens, base)
|
||||
docs/
|
||||
├── spec/ # Authoritative behavioral specification (00–10) — the WHAT
|
||||
├── spec/ # Behavioral specification (00–10) — the WHAT (record; code leads)
|
||||
├── architecture/ # Architecture playbook (00–11) — the HOW (self-contained)
|
||||
│ └── visual-specimen.html # Standalone token sandbox + reusable-primitive catalog
|
||||
├── 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
|
||||
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.)
|
||||
- **Spec is the contract** — when in doubt, read `docs/spec/`. If the spec is wrong or
|
||||
silent, raise it; change the spec deliberately rather than drifting from it.
|
||||
- **Spec follows code** — `docs/spec/` is the descriptive record of behavior; the code
|
||||
leads. When in doubt about existing behavior, read the spec. When you ship user-facing
|
||||
behavior, update the matching spec section in the same session; if spec and app
|
||||
disagree, the spec is stale — rewrite it deliberately, never drift silently.
|
||||
- **Reproduce before theorizing** — on a nontrivial bug, first build a command that goes
|
||||
red on the exact symptom (failing test, script, headless-browser driver) and is fast,
|
||||
deterministic, and runnable unattended; minimize the repro, then hypothesize against it.
|
||||
Reading code to build a theory before that command exists is the failure mode. Write the
|
||||
regression test before the fix; tag temporary debug logs with a unique prefix
|
||||
(e.g. `[DEBUG-x7]`) so cleanup is one grep.
|
||||
- **Core-first** — for each feature, build the pure `src/core/` logic with tests before UI.
|
||||
- **Session wrap-up** — when the user signals the session is wrapping, run the review
|
||||
pass before any commit: `/doc-update` in-session first (flush unrecorded rationale),
|
||||
@@ -144,6 +155,11 @@ Invoke with `/<name>` (defined in `.claude/skills/`):
|
||||
Recurring findings become `docs/architecture/` rules and new `/alignment` checks.
|
||||
- **`/release`** — bump version, update the changelog, prepare a git tag.
|
||||
|
||||
### Deployment
|
||||
|
||||
Push to `main` auto-deploys to **astrolabe-viz.com** (Cloudflare Pages, Git-connected).
|
||||
Hosting, analytics posture, and distribution facts: **[docs/deployment.md](docs/deployment.md)**.
|
||||
|
||||
### Versioning
|
||||
|
||||
Simplified semver `0.x.y` (pre-1.0): minor for features/behavior, patch for fixes. Single
|
||||
@@ -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
|
||||
test it there.
|
||||
|
||||
Expected values come from an independent source of truth — a known-good literal, a worked
|
||||
example, the spec — never recomputed the way the implementation computes them. A
|
||||
tautological assertion (`expect(add(a, b)).toBe(a + b)`) passes by construction and can
|
||||
never disagree with the code.
|
||||
|
||||
Component tests (happy-dom) share a harness shape: `createRoot` + `act` with
|
||||
`IS_REACT_ACT_ENVIRONMENT = true` set at module level, stores reset in `beforeEach`, and
|
||||
`vi.mock('../services/chart-renderer', …)` for anything that embeds a chart (vega-embed is
|
||||
|
||||
@@ -5,8 +5,9 @@ See @AGENTS.md for project overview, architecture rules, and the AI developer pr
|
||||
## Documentation Index
|
||||
|
||||
- **[SOUL.md](SOUL.md)** — project philosophy and identity. _Read first._
|
||||
- **[docs/spec/](docs/spec/)** — authoritative behavioral specification (sections 00–10):
|
||||
the **what**. This is the contract; implement to it.
|
||||
- **[docs/spec/](docs/spec/)** — behavioral specification (sections 00–10): the **what**.
|
||||
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
|
||||
(00–11): the **how** (state, persistence, modals, routing, rendering, inference,
|
||||
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
|
||||
|
||||
- Astrolabe is a **spec-driven rebuild** — the behavior is fixed in `docs/spec/`; the
|
||||
architecture is adapted from Syto. Implement to the spec; don't port legacy code.
|
||||
- Astrolabe is a **spec-recorded rebuild** — the architecture is adapted from Syto, and
|
||||
`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/app/infrastructure/`. UI is React + Zustand.
|
||||
- **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).
|
||||
|
||||
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:
|
||||
either accept them, or overrule them **and** record the missing rationale where the
|
||||
reviewer looked for it — an overruled finding without a writing-down will recur. Then
|
||||
commit only when invited.
|
||||
Relay each report back to the user, then arbitrate each finding to one of three ends —
|
||||
**accept and fix**, **accept but defer**, or **overrule** — and **write down the two you
|
||||
don't act on now**, because a subagent's report is ephemeral and chat is not a record. A
|
||||
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.
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
|
||||
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,
|
||||
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
|
||||
> project Syto. The authoritative behavioral contract lives in [`docs/spec/`](docs/spec/).
|
||||
> Astrolabe is a rebuild on an architecture adapted from its sibling project Syto. The
|
||||
> 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)
|
||||
> for the build sequence.
|
||||
|
||||
@@ -23,10 +24,30 @@ npm run typecheck
|
||||
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
|
||||
|
||||
**M0 — Skeleton.** Toolchain green (typecheck, tests, build, PWA). The three-pane shell
|
||||
renders; features land milestone by milestone per the implementation plan (MVP at end of M1).
|
||||
Active development, **pre-1.0** and not yet publicly released — well past the initial milestones
|
||||
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
|
||||
|
||||
|
||||
@@ -28,8 +28,10 @@ data lives in one place and the specs stay lean.
|
||||
### 1. Local-Only by Default
|
||||
|
||||
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
|
||||
URL-dataset fetches.
|
||||
No accounts, no uploads, no tracking, and the app contacts no third party on its own —
|
||||
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
|
||||
|
||||
@@ -71,15 +73,18 @@ clever.
|
||||
- **Not a collaboration platform.** No multi-user, no sync, no comments. Import/export
|
||||
moves data between machines.
|
||||
- **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
|
||||
|
||||
### Spec-Driven, Clean Implementation
|
||||
### Spec-Recorded, Clean Implementation
|
||||
|
||||
The behavioral contract 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
|
||||
code. When the spec and convenience conflict, the spec wins or the spec changes — never
|
||||
silent drift.
|
||||
The behavioral record lives in `docs/spec/`. Astrolabe is a deliberate rebuild on a
|
||||
robust architecture (adapted from Syto): behavior is designed deliberately and recorded
|
||||
in the spec, not ported from old code. The code leads; the spec is rewritten to match
|
||||
what ships — never silent drift.
|
||||
|
||||
### Portable Core, Thin Browser Shell
|
||||
|
||||
|
||||
+59
-472
@@ -1,8 +1,13 @@
|
||||
# Astrolabe — Incremental Implementation Plan
|
||||
|
||||
> A spec-driven rebuild of Astrolabe on Syto's architecture. The authoritative
|
||||
> behavioral contract is `docs/spec/` (sections 00–10). This document sequences
|
||||
> the build into the **quickest path to a usable MVP**, then layers the rest.
|
||||
> A rebuild of Astrolabe on Syto's architecture. The behavioral record is
|
||||
> `docs/spec/` (sections 00–10) — the code leads, and the spec is kept
|
||||
> rewritten to match.
|
||||
>
|
||||
> **The M0–M6 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 →
|
||||
> cover with tests → manual smoke check against the spec's acceptance points.
|
||||
@@ -12,7 +17,7 @@
|
||||
|
||||
## 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
|
||||
doc before implementing.
|
||||
|
||||
@@ -42,499 +47,79 @@ doc before implementing.
|
||||
|
||||
## Milestone map
|
||||
|
||||
The whole sequence is shipped. This table is the record; build notes for each live in the
|
||||
git history and the spec/architecture docs.
|
||||
|
||||
| # | Milestone | Outcome | Spec |
|
||||
| -------- | --------------------------- | ------------------------------------------------------------------------------ | ------------------------------------------- |
|
||||
| -------- | -------------------------------- | ------------------------------------------------------------------------------ | ------------------------------------------- |
|
||||
| **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, §03A–C, §04, §09A |
|
||||
| **M1** | MVP core loop ✅ | Author a Vega-Lite snippet, see it render live, it persists | §02, §03A–C, §04, §09A |
|
||||
| **M1.5** | Visual design foundation ✅ | Apply the design language: tokens, IBM Plex, restyled M1 surfaces, chart theme | [arch 09](architecture/09-visual-design.md) |
|
||||
| **M2** | Editor robustness ✅ | Draft/Published, validation, schema autocomplete, fit modes | §03D–E, §04, §07(editor) |
|
||||
| **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).
|
||||
M1.5 makes it _look right_; M2 makes it _robust_; M3–M6 make it _complete_.
|
||||
Ship/dogfood after M1, iterate.
|
||||
M1.5 made it _look right_; M2 made it _robust_; M3–M6 made it _complete_.
|
||||
|
||||
---
|
||||
|
||||
## M0 · Skeleton ✅ (done)
|
||||
|
||||
Vite + React + Zustand + TypeScript + Vitest (happy-dom) + vite-plugin-pwa.
|
||||
`src/core` ↔ `src/app` split, `useAppStore`, design tokens, three-pane placeholder
|
||||
shell, first core module (`format-detection`) with tests.
|
||||
|
||||
**Verified:** `npm run typecheck`, `npm test`, `npm run build` (PWA SW generated).
|
||||
|
||||
---
|
||||
|
||||
## M1 · MVP core loop → _the quickest usable Astrolabe_
|
||||
|
||||
**Goal:** select/create a snippet, edit its spec JSON, watch a live Vega-Lite
|
||||
preview, and have it survive reload. Single source kind: inline-data specs only
|
||||
(datasets come in M3). No draft/published yet — edits save directly.
|
||||
|
||||
**Core (`src/core/`)**
|
||||
|
||||
- `snippet.ts` — the Snippet type (spec §09A) + factory (`createSnippet`,
|
||||
default sample bar-chart template, auto-generated date/time name).
|
||||
- `rendering.ts` — `prepareSpecForRender(spec, { fitMode })` skeleton; in M1 it's
|
||||
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.
|
||||
|
||||
**Infrastructure (`src/app/infrastructure/`)**
|
||||
|
||||
- `idb.ts` — thin IndexedDB wrapper (open, get/put/delete/getAll by store).
|
||||
_(see [Architecture 02 · Persistence](architecture/02-persistence.md))_
|
||||
- `snippet-store.ts` — persist snippets (object store `snippets`).
|
||||
|
||||
**App**
|
||||
|
||||
- `stores/SnippetStore.ts` — `useSnippetStore` with `snippets`, `activeSnippetId`,
|
||||
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 M2–M6 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 M2–M4 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.
|
||||
## Live backlog
|
||||
|
||||
The M0–M6 sequence is done; what's left is post-M6 enhancement. The detail — rationale,
|
||||
citations, status logs — lives in the two scope docs below, which are the source of truth.
|
||||
This is the at-a-glance list; keep it in sync with them.
|
||||
|
||||
**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** (M1–M5): the
|
||||
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`),
|
||||
and live/interactive inspection. The durable contract is recorded in `docs/architecture`
|
||||
05 (live inspection) and 07 (reference detection + extraction, §3.1–3.2).
|
||||
- **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
|
||||
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
|
||||
gallery (`core/examples.ts` → `Onboarding.tsx`), which is Monaco-only.
|
||||
- **Chart theming · Color-panel swatch reorder** ([`chart-theming-scope.md`](exploration/chart-theming-scope.md) §5) —
|
||||
the last remaining slice-4b control: reorder a materialized scheme's swatches.
|
||||
|
||||
**Deferred / gated (have a home; not committed):**
|
||||
|
||||
- **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
|
||||
must clear the promotion test: a control enters the builder only when it is **both common and
|
||||
awkward in JSON**.
|
||||
- **Chart Builder** — field-chip drag-and-drop (click/keyboard-first shipped; drag deferred);
|
||||
calculated-field autocomplete popup (Monaco-style completion for expressions).
|
||||
- **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",
|
||||
"Terminal", "Sketch").
|
||||
|
||||
---
|
||||
|
||||
## Cross-cutting, do-as-you-go
|
||||
|
||||
- **Build to the design language:** the foundation lands in M1.5; from M2 on, every
|
||||
new component uses the [Architecture 09](architecture/09-visual-design.md) tokens
|
||||
and conventions — no placeholder styling, no raw hues. Staying on it is the
|
||||
do-as-you-go part.
|
||||
- **Build to the design language:** the foundation landed in M1.5; every new component uses the
|
||||
[Architecture 09](architecture/09-visual-design.md) tokens and conventions — no placeholder
|
||||
styling, no raw hues. Staying on it is the do-as-you-go part.
|
||||
- **i18n** (optional, deferred): if translation is wanted, split a portable i18n
|
||||
registry (no React) from the app-layer bindings, mirroring the `core` ↔ `app`
|
||||
boundary. M1–M6 can ship English-only with date formatting locale-aware (§10).
|
||||
Don't retrofit later if avoidable — keep user-facing strings centralized from M1.
|
||||
boundary. The app ships English-only with date formatting locale-aware (§10).
|
||||
Keep user-facing strings centralized so a later retrofit stays cheap.
|
||||
- **Versioning:** simplified semver `0.x.y`, `package.json` → `__APP_VERSION__`
|
||||
(already wired). **Not yet released publicly** — the working version stays pre-1.0 through
|
||||
M0–M6; the **first public release will be `1.0.0`**, cut on the maintainer's signal (don't
|
||||
auto-bump in the meantime).
|
||||
(already wired). **Not yet released publicly** — the working version stays pre-1.0; the
|
||||
**first public release will be `1.0.0`**, cut on the maintainer's signal (don't auto-bump in
|
||||
the meantime).
|
||||
- **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
|
||||
|
||||
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) |
|
||||
| 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) |
|
||||
| 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) |
|
||||
|
||||
@@ -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
|
||||
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
|
||||
**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.
|
||||
|
||||
## 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. |
|
||||
| 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. |
|
||||
| 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). |
|
||||
| 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`). |
|
||||
|
||||
@@ -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
|
||||
> 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
|
||||
|
||||
@@ -284,8 +284,8 @@ export interface UserSettings {
|
||||
formatting: { dateFormat: 'smart' | 'iso' | 'custom'; customDateFormat: string };
|
||||
}
|
||||
|
||||
// Defaults must match the authoritative spec §07 table exactly — that is the
|
||||
// contract; this is just where it's encoded.
|
||||
// The spec §07 table records these defaults — keep the two matching; this is
|
||||
// just where they're encoded.
|
||||
const DEFAULTS: UserSettings = {
|
||||
version: CURRENT_SETTINGS_VERSION,
|
||||
editor: {
|
||||
|
||||
@@ -75,7 +75,10 @@ export interface ModalConfig {
|
||||
component: ComponentType<any>; // the body rendered inside the shell
|
||||
|
||||
/** 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;
|
||||
|
||||
/** 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
|
||||
self-corrects to the `dataset-build` form.
|
||||
|
||||
**One-shot action links are not view states.** A hash form that _requests an
|
||||
action_ — `#example-<id>` (add that gallery example) and `#spec-<payload>` (add
|
||||
the spec carried in the payload; `@core/spec-link` owns the base64url encoding,
|
||||
shared with the learn pages that build such links) — stays out of the
|
||||
`ViewState` union: each is parsed by its own function in `url-hash.ts`,
|
||||
consumed once at startup (`orchestration/startup.ts`, after persistence wiring
|
||||
so the created record write-throughs, before `startRouting`), and then
|
||||
routing's settle step replaces the hash with the resulting view. Action links
|
||||
never serialize back and never participate in Back/Forward; `parseHash`
|
||||
degrades them like any unknown hash. Any future action link follows the same
|
||||
shape rather than growing the view-state union.
|
||||
|
||||
### 1.2 The adapter: `infrastructure/url-hash.ts`
|
||||
|
||||
This is the only file that reads or writes `window.location` / `history`. It
|
||||
|
||||
@@ -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 (the Live Preview and Chart Builder panel showing the chart's input
|
||||
vs. resolved rows — spec §04) reads runtime rows through the handle, never the raw view:
|
||||
`RenderHandle.inspectData()` returns the input + resolved tables (`{ input, resolved }`,
|
||||
or `null` when no chart is up), wrapping the view exactly like `toImageURL`. It works in
|
||||
two layers:
|
||||
The data inspector (the Live Preview and Chart Builder panel showing each drawn table's
|
||||
input vs. resolved rows — spec §04) reads runtime rows through the handle, never the raw
|
||||
view: `RenderHandle.inspectData()` returns the inspectable tables (`{ tables }`, or `null`
|
||||
when no chart is up), wrapping the view exactly like `toImageURL`. It works in two layers:
|
||||
|
||||
- **Enumerate + pick (`view.getState` + `core/result-data`).** A compiled Vega dataflow
|
||||
holds many named datasets; Vega-Lite names them by convention — `source_<n>` per parsed
|
||||
source, `data_<n>` per transform stage. The pure `pickSourceDataset` / `pickResultDataset`
|
||||
choose the **most-upstream source** (the input) and **most-downstream output** (what the
|
||||
marks draw), skipping dataflow internals (`marks`, `root`, layout, selection `*_store`s).
|
||||
A spec with no transforms resolves both to the same table. The picking is pure (in `core`,
|
||||
unit-tested); only the enumeration touches the view.
|
||||
- **Read lazily.** `getState` serializes the datasets it lists, so it is **only called while
|
||||
the panel is open** — a collapsed inspector costs nothing, which is why the panel reads on
|
||||
demand rather than on every render.
|
||||
|
||||
Limitation: one name per direction can't represent a multi-view spec (layer/concat/facet
|
||||
produce several `data_<n>`); the most-downstream/upstream ones are returned, and a full
|
||||
dataset selector is left as a future option.
|
||||
- **Enumerate from the compiled spec (`core/inspect-views`).** A composed spec draws
|
||||
several tables; `inspectableViews` walks the compiled Vega spec — the marks tree's
|
||||
`from.data` (what each mark draws) and `data[].source` (the lineage, the documented Vega
|
||||
format) — to list, in document order, one entry per **distinct drawn table** with its
|
||||
`resolved` (post-transform, what the marks draw) and `input` (most-upstream source) ends.
|
||||
Enumerating by drawn table, not by authored view, is forced by Vega-Lite desugaring (a
|
||||
`point: true` line compiles to two layers — a compiled table can't be traced back to one
|
||||
authored view). Selection `*_store`s and `facet_domain*` layout tables aren't drawn, so
|
||||
they fall out for free. The walk is pure (in `core`, unit-tested); the boundary reads each
|
||||
table's rows via `view.data(name)`.
|
||||
- **Read lazily.** Reading serializes rows, so it happens **only while the panel is open** —
|
||||
a collapsed inspector costs nothing, which is why the panel reads on demand rather than on
|
||||
every render. (A multi-view spec yields several tables; the panel's `SelectControl` picker
|
||||
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
|
||||
|
||||
The editor's `LivePreview` is **bound to the snippet editor** — it reads `SnippetStore`
|
||||
(shown spec), `AppStore` (fit mode/theme), and `PreviewStore` (shared error). The
|
||||
**Chart Builder modal** needs a preview of a _different_ spec source (its config), so it
|
||||
does **not** reuse `LivePreview`; it runs its own small debounced render over the same
|
||||
`chart-renderer.renderSpec` + `prepareSpecForRender`, with **local** error state (never the
|
||||
shared `PreviewStore`, which would cross-talk with the editor). Two preview surfaces, one
|
||||
(shown spec) and `AppStore` (fit mode/theme), and keeps its render status (`error`/`busy`) in
|
||||
its own local state. The **Chart Builder modal** needs a preview of a _different_ spec source
|
||||
(its config), so it does **not** reuse `LivePreview`; it runs its own small debounced render
|
||||
over the same `chart-renderer.renderSpec` + `prepareSpecForRender`, with its own **local**
|
||||
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`
|
||||
(config + create) → `ChartBuilderModal`'s `BuilderPreview`. Reach for a reusable preview
|
||||
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),
|
||||
recursing the same way.
|
||||
|
||||
The fit recursion has one Vega-Lite limit: **`"container"` sizing only works on
|
||||
single and layered views** — facet children fall back with a warning (panel
|
||||
widths become timing-dependent) and concat children fall back to pad-autosize
|
||||
(axes overflow a fixed card). A surface that renders a composed spec at a fixed
|
||||
size must ask for `fitMode: 'default'` and let the spec's declared per-view
|
||||
`width`/`height` stand — the landing's `LandingChart` exposes this as a prop
|
||||
for its composed demos.
|
||||
|
||||
This is _content_ preparation, not embedding, and it is fully covered by the
|
||||
_Live Preview_ spec. The only invariant this doc cares about:
|
||||
|
||||
@@ -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
|
||||
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 |
|
||||
| -------------------------------- | -------------------------------------- | ---------------------- |
|
||||
| Parse | Invalid JSON | "Invalid JSON: …" |
|
||||
| Prepare (`prepareSpecForRender`) | Referenced dataset missing/unfetchable | "Dataset not found: …" |
|
||||
| Embed (`vega-embed`) | Vega-Lite compile / data error | "Rendering error: …" |
|
||||
| -------------------------------- | -------------------------------------- | --------------------------------- |
|
||||
| Parse | Invalid JSON | `Invalid JSON · …` |
|
||||
| Prepare (`prepareSpecForRender`) | Referenced dataset missing/unfetchable | `Dataset "x" not found · …` |
|
||||
| Embed (`vega-embed`) | Vega-Lite compile, or a bad expression | `Line N · …` / `Render error · …` |
|
||||
|
||||
```ts
|
||||
// inside render(), driven by the debounced renderer
|
||||
@@ -615,7 +629,7 @@ async function render(): Promise<void> {
|
||||
if (!text) {
|
||||
current?.destroy();
|
||||
current = null;
|
||||
usePreviewStore.getState().setError(null);
|
||||
setError(null); // `error`/`busy` are the pane's local state, not a store
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -623,7 +637,7 @@ async function render(): Promise<void> {
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} 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
|
||||
}
|
||||
|
||||
@@ -633,14 +647,9 @@ async function render(): Promise<void> {
|
||||
const config = chartConfigFor(uiTheme);
|
||||
current?.destroy();
|
||||
current = await renderSpec(node, prepared, config);
|
||||
usePreviewStore.getState().setError(null); // success clears any prior error
|
||||
setError(null); // success clears any prior error
|
||||
} catch (e) {
|
||||
usePreviewStore
|
||||
.getState()
|
||||
.setError(
|
||||
`Rendering error: ${(e as Error).message}. ` +
|
||||
`Check your JSON syntax and that the spec is valid Vega-Lite.`,
|
||||
);
|
||||
setError(`Render error · ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -655,10 +664,10 @@ manual retry, no reload.
|
||||
- **Do** treat empty/blank spec text as "render nothing" — finalize the current
|
||||
view, clear the error, show a clean empty pane.
|
||||
- **Do** clear the error state on every successful render.
|
||||
- **Do** make messages legible and actionable (the underlying reason plus a hint
|
||||
to check JSON/Vega-Lite validity), never a raw stack trace dump.
|
||||
- **Do** distinguish the failing stage in the message (Invalid JSON vs Dataset
|
||||
not found vs Rendering error).
|
||||
- **Do** make messages legible and actionable — lead with the location or the failing
|
||||
stage, then the underlying reason — never a raw stack trace dump.
|
||||
- **Do** distinguish the failing stage in the message (invalid JSON vs missing dataset
|
||||
vs a bad expression or render error).
|
||||
- **Don't** show a broken/partial chart — replace the chart area with the
|
||||
message.
|
||||
- **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` |
|
||||
| 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_) |
|
||||
| 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`) |
|
||||
|
||||
@@ -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
|
||||
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
|
||||
`data`, per-layer `data`, `data` inside `spec`/`facet`/`hconcat`/`vconcat`, and a
|
||||
lookup transform's `from.data`. A spec may also define its OWN inline datasets via
|
||||
a top-level `datasets` map — those are self-defined, not library references.
|
||||
Rather than enumerate Vega-Lite's grammar, we walk the spec recursively and
|
||||
collect every `{ data: { name } }` — but **prune two keys**: never recurse into a
|
||||
`data` object's payload (its `values`/rows) or the top-level `datasets` map,
|
||||
because those 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.
|
||||
This is pure, deterministic, and the most heavily unit-tested function here.
|
||||
A library reference is exactly Vega-Lite **named data**: a `data` block with a
|
||||
string `name` and no `values`, `url`, or generator key (`sequence`/`sphere`/
|
||||
`graticule`), whose name the spec does not define for itself via a top-level
|
||||
`datasets` map. A `name` riding on inline `values` or a `url` is Vega-Lite's
|
||||
runtime-rebind label — not a dependency — and self-defined `datasets` names
|
||||
resolve natively; both are left untouched. This mirrors Vega-Lite's own
|
||||
`isNamedData`, so Astrolabe **extends** Vega-Lite rather than diverging: every
|
||||
native data form keeps working, and only true references are tracked and resolved.
|
||||
|
||||
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
|
||||
// src/core/spec-refs.ts
|
||||
// src/core/spec-data.ts — the shared classifier (mirrors vega-lite/src/data.ts)
|
||||
|
||||
type Json = unknown;
|
||||
|
||||
/** Collects every library dataset name referenced by `{ data: { name } }`, excluding self-defined ones. */
|
||||
export function extractDatasetRefs(spec: Json): string[] {
|
||||
const root = typeof spec === 'string' ? safeParse(spec) : spec;
|
||||
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
|
||||
}
|
||||
/** 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 {
|
||||
if (classifyData(data) !== 'named') return null; // url | values | generator → not a reference
|
||||
const { name } = data as { name: string };
|
||||
return selfDefined.has(name) ? null : name;
|
||||
}
|
||||
```
|
||||
|
||||
```ts
|
||||
// src/core/spec-refs.ts — thin wrapper, run on every draft change and on publish
|
||||
References appear in several places — top-level `data`, per-layer `data`, `data`
|
||||
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. */
|
||||
export function recomputeDatasetRefs(spec: Json): string[] {
|
||||
return extractDatasetRefs(spec).sort();
|
||||
}
|
||||
```
|
||||
That walk is **one shared pair** in `core/spec-data`, not re-implemented per pass:
|
||||
`forEachDataBinding(spec, visit)` (read-only, `visit` returns `true` to stop early)
|
||||
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,
|
||||
> 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
|
||||
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** —
|
||||
but only through the parse-gated, debounced auto-save (`commitDraft`) and the
|
||||
programmatic extract/revert rewrites, never on raw keystrokes. That keeps the
|
||||
links in step with the edited draft while never recomputing from a
|
||||
transiently-invalid spec.
|
||||
- Prune the **same two keys** (`data`, `datasets`) in all three ref walks —
|
||||
extraction here, `renameDatasetInSpec`, and the renderer's `resolveDatasetRefs`
|
||||
(`src/core/rendering.ts`). They must agree on what counts as a reference; if one
|
||||
descends into data payloads and another doesn't, extraction and rendering
|
||||
disagree and a row field named `data` either gets counted, rewritten, or throws
|
||||
`DatasetNotFoundError`.
|
||||
- Route every binding pass through the shared `forEachDataBinding` /
|
||||
`mapDataBindings`. They share both halves that must agree — the `libraryRefName`
|
||||
classifier (what is a reference) and the prune of the **same two keys** (`data`,
|
||||
`datasets`) — so extraction, rename, and the reverse extraction cannot drift. If
|
||||
one classified differently, or descended into data payloads while another didn't,
|
||||
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 let two code paths each have their own idea of "referenced names".
|
||||
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
|
||||
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.
|
||||
|
||||
### 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?
|
||||
@@ -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
|
||||
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:
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
> borrowing and the gotchas worth avoiding, so we don't rediscover them from scratch in
|
||||
> 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).
|
||||
> This doc is the bridge: "here is how the canonical implementation does the editor/renderer
|
||||
> 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)
|
||||
|
||||
| Technique | Lands in | Milestone |
|
||||
|
||||
@@ -329,7 +329,7 @@ Carbon and drawn `fill: currentColor`.
|
||||
**Core set** — recurring, cross-surface:
|
||||
|
||||
| Meaning | Carbon glyph | Form | Surfaces |
|
||||
| -------------------- | --------------- | ------------------------ | -------------------------------------------------------------------------------------------- |
|
||||
| --------------------- | --------------- | ------------------------ | -------------------------------------------------------------------------------------------- |
|
||||
| Close / dismiss | `Close` (✕) | icon-only ⭐ | `ModalShell`, `Toaster` |
|
||||
| Theme → dark | `Asleep` (moon) | icon-only ⭐ | `ThemeToggle` (shown when light) |
|
||||
| Theme → light | `Light` (sun) | icon-only ⭐ | `ThemeToggle` (shown when dark) |
|
||||
@@ -343,6 +343,7 @@ Carbon and drawn `fill: currentColor`.
|
||||
| Live search | `Search` | icon-in-field⁵ | Library search box (leading magnifier; the input's `aria-label`/placeholder names the field) |
|
||||
| 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) |
|
||||
| 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
|
||||
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) |
|
||||
| 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
|
||||
the `Icon` registry**:
|
||||
|
||||
@@ -379,7 +390,8 @@ the `Icon` registry**:
|
||||
|
||||
⭐ = **icon-only set** (the glyph alone names the control, via `aria-label`): the
|
||||
**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
|
||||
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
|
||||
|
||||
@@ -35,10 +35,10 @@ interchangeable; picking the wrong one is the most common interaction bug. Choos
|
||||
nature of the message, not by convenience.
|
||||
|
||||
| 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` |
|
||||
| **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) |
|
||||
|
||||
**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
|
||||
owned globally by the EventRouter (arch 04), so the helper is the only place the outcome is
|
||||
confirmed.
|
||||
- **The same failure can light up two channels.** An unrenderable spec shows the _same_
|
||||
message inline in both the editor (§03E) and the preview (§04) — one producer
|
||||
(`PreviewStore`), two subscribers. That's intentional, not duplication.
|
||||
- **A render failure has one message home: the preview.** An unrenderable spec shows its
|
||||
message in the preview pane (§04), where the chart would be — error _xor_ chart, since the
|
||||
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
|
||||
|
||||
@@ -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;
|
||||
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
|
||||
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
|
||||
@@ -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
|
||||
the source.)_
|
||||
|
||||
**Resolved — one live region per shared message.** When the same error feeds two surfaces
|
||||
(the §1 "one producer, two subscribers" case — render errors via `PreviewStore`), exactly
|
||||
**one** subscriber is the live region (`role="alert"` on the editor, where focus is); the
|
||||
other shows the text visually with no live role. Two live regions would announce the same
|
||||
message twice.
|
||||
**Resolved — a render error lives in one place, the preview.** The render-error message
|
||||
(`LivePreview`'s local `error` state) shows only in the preview pane, where the chart would be
|
||||
(error _xor_ chart), and that single surface is the `role="alert"` live region — assertive, since the user
|
||||
just caused it. It is announced regardless of where focus sits, so it needs no duplicate near
|
||||
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
|
||||
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
|
||||
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
|
||||
@@ -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
|
||||
router.
|
||||
- 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.
|
||||
|
||||
**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
|
||||
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).
|
||||
- Don't use absolutes in product claims (never/always/fully/everything) or promise what the
|
||||
platform can't keep — say "saved," not "permanent."
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
@@ -14,6 +17,12 @@ teach the grammar's dormant power and funnel readers into the app.
|
||||
stays light.
|
||||
- **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.
|
||||
- **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
|
||||
|
||||
@@ -22,22 +31,34 @@ A lesson is a `.md` file in `src/learn/lessons/`, discovered with `import.meta.g
|
||||
document of ordered blocks_, not a fixed template:
|
||||
|
||||
| Authored as | Block | Rendered by |
|
||||
| ---------------------------------------------------- | ------------- | --------------------------- |
|
||||
| ---------------------------------------------------- | ------------- | ----------------------------- |
|
||||
| plain markdown | `prose` | `Markdown` |
|
||||
| `:::progression` wrapping `##` stages + fenced specs | `progression` | `SpecProgression` |
|
||||
| a bare fenced `vega-lite` block | `chart` | `LandingChart` |
|
||||
| `:::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,
|
||||
the following fenced `vega-lite` block → spec. Inline data repeats per fenced block — there
|
||||
is no shared-data construct.
|
||||
the following fenced `vega-lite` block → spec. A `:::data` block names datasets once for the
|
||||
whole lesson; specs reference them with `{ "data": { "name": … } }` and `injectDatasets`
|
||||
merges the rows in at render time — so a shared dataset isn't repeated per stage, and the
|
||||
source pane keeps a stage's grammar legible instead of burying it under data. 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
|
||||
|
||||
`lessons/*.md` → `import.meta.glob` (in `LearnPage`) → `parseLesson` (`core/lesson-parse`) →
|
||||
`LessonBlock[]` → `LearnPage` block dispatch → `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).
|
||||
`lessons/*.md` → `import.meta.glob` + `parseLesson` (`src/learn/lessons.ts`, using
|
||||
`core/lesson-parse`) → `LESSONS`. The `src/learn` entry reads the path: `/learn/` →
|
||||
`LearnIndex`, `/learn/<slug>/` → `LessonView`, both inside `LearnLayout` (shared
|
||||
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
|
||||
|
||||
@@ -52,3 +73,7 @@ highlights what the stage changed with `changedLines` (`core/spec-diff`, an LCS
|
||||
us), never user input — not an XSS surface.
|
||||
- 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.
|
||||
- **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>
|
||||
@@ -0,0 +1,24 @@
|
||||
# Deployment & Distribution
|
||||
|
||||
Operational facts that live nowhere in the code.
|
||||
|
||||
## Hosting
|
||||
|
||||
- **astrolabe-viz.com** runs on **Cloudflare Pages**, project `astrolabe-viz`,
|
||||
Git-connected to the private GitHub repo `olehomelchenko/astrolabe`.
|
||||
- **Push to `main` auto-builds and deploys** (`npm run build`; the post-build
|
||||
`check-light-entries` gate runs as part of it). There are no GitHub Actions.
|
||||
- Live since 2026-06-20.
|
||||
- **No analytics.** The app ships no beacon or tracking of any kind; Cloudflare keeps
|
||||
standard aggregate access logs as any host does. The About modal's privacy copy states
|
||||
exactly this — keep them in agreement.
|
||||
|
||||
## Distribution posture
|
||||
|
||||
- **Free to use; the code is private.** Astrolabe is not open source. User-facing copy
|
||||
(landing, About, learn) must never claim or imply otherwise — the open thing is the
|
||||
_format_ (Vega-Lite JSON), and copy attributes openness to it deliberately.
|
||||
- **Feedback channel** is the branded address `feedback@astrolabe-viz.com`
|
||||
(`src/app/feedback.ts`), not a public issue tracker.
|
||||
- **Release cadence**: unreleased pre-1.0; the first public release is `1.0.0`, cut on
|
||||
the maintainer's signal (see `docs/IMPLEMENTATION-PLAN.md` and the `/release` skill).
|
||||
@@ -0,0 +1,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,
|
||||
~500–5000ms); 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.
|
||||
@@ -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-theming-scope.md` — chart theming plan and slice breakdown.
|
||||
- `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
|
||||
(~71–77% resolution, usually within 0–3 days, at least 10 by dedicated commits), but every
|
||||
TODO that survived its first week is still alive. Oldest two are from 06-12:
|
||||
`src/app/modals/ModalCoordinator.ts:36` (a **latent init-ordering bug**, not polish) and
|
||||
`ChartBuilderModal.tsx:524`. The open set is increasingly the hard/ambiguous residue no
|
||||
session claims.
|
||||
|
||||
**Fix direction (maintainer to ratify):** give each instrument a trigger — re-sweep when
|
||||
source LOC grows ~25% past the last metrics row; batch council pass when ux-second-pass open
|
||||
items exceed a count or age past two weeks; a spec-reconciliation clause in `/doc-update`
|
||||
("did this session add user-facing behavior? name the spec section or write it"); TODO
|
||||
gardening as a standing sweep agenda item.
|
||||
|
||||
### S2. The scaffold insertion math is untested, duplicated, and validity-critical
|
||||
|
||||
Verified: `runAddTransform` (`spec-transform-scaffold.ts:130-146`, committed `c5e4c4c`) and
|
||||
`runAddParam` (`spec-param-scaffold.ts:109-142`, WIP) compute insert offsets, indentation,
|
||||
and the trailing-comma decision; a bug here writes **invalid JSON into the user's editor**.
|
||||
No test exercises the composition — no `SpecEditor.test.tsx` exists, so there is no indirect
|
||||
path either. The create-property block is a verbatim 7-line duplicate between the two files;
|
||||
the completion providers share ~35 structurally identical lines. Hand-walking the current
|
||||
logic found **no live bug** — this is a coverage hole, not a defect — but a future inversion
|
||||
of the ternary or offset drift ships silently. The repo's own TODOs
|
||||
(`spec-param-scaffold.ts:33`, `spec-params.test.ts:6`) already point at the fix: extract a
|
||||
neutral core module (working name `spec-snippet`) computing
|
||||
`(text, host offset, existing-array?) → {insertOffset, snippetText}` and table-test it over
|
||||
empty-host / host-with-following-key / compact-one-line / host-as-last-property.
|
||||
|
||||
Related: `spec-transform-actions.ts` (663 lines, largest service, no test file) carries pure
|
||||
unexported helpers with real branching — `reindent`, `defaultFacet`, `defaultRepeat` (:106,
|
||||
:123, :136) — one layer short of tested core, mitigated by `buildNext` being a thin
|
||||
dispatcher over tested core wrappers.
|
||||
|
||||
### S3. Documentation drift cluster (6/6 claims verified)
|
||||
|
||||
All in otherwise highly accurate docs; each is a fossil a maintainer would act on:
|
||||
|
||||
1. **5 MB budget fossil.** `docs/spec/09-data-model.md` §E and `08-import-export.md` describe
|
||||
a budget-with-fill-warnings storage monitor that spec 02/10, arch 02 §6, and the code all
|
||||
contradict (the monitor is a composition breakdown; the only 5 MB logic is the import
|
||||
pre-check in `transfer.ts:42`). Arch 02:445's "80% threshold" Do-line contradicts the same
|
||||
doc's line 425 and matches nothing in code.
|
||||
2. **"Not installable" is false.** Arch 10:625 claims the manifest ships no icons; four icons
|
||||
ship (`vite.config.ts:85-89`, files in `public/`), and manual-verification actively tests
|
||||
install.
|
||||
3. **The ajv layer was never built.** Arch 08 presents it as planned-M2 and claims "we
|
||||
deliberately do better: map ajv errors to editor positions" — no such module exists; ajv
|
||||
isn't even in `package.json`. The current WIP on arch 08 does not touch this.
|
||||
4. **Wrong store name in three docs.** Arch 01/03/04 name `useSettingsPopoverStore` /
|
||||
`openSettingsPopover`; the code is `usePopoverStore` / `openPopover`
|
||||
(`PopoverStore.ts:22,30`, called from `EventRouter.ts:96`). Grep by the documented name
|
||||
finds nothing.
|
||||
5. **Arch 03's "closed union" is stale.** Five modals listed; `modals/types.ts` has six
|
||||
(`themeBuilder`). Same doc's add-a-modal checklist instructs `hasError`/`getError` on
|
||||
`ModalConfig` — fields the real type doesn't have; following it fails typecheck.
|
||||
6. **Spec 09's UserSettings omits `ui.dataInspectorOpen`/`ui.dataInspectorHeight`**, which
|
||||
spec 04 mandates and `settings-store.ts:158-183` persists (they're also absent from
|
||||
`core/settings.ts`'s type — the code half of the same gap).
|
||||
|
||||
### S4. Delivery gates never observe build output, and caching is misconfigured
|
||||
|
||||
- All quality gates live in the (excellent) pre-commit hook — lint-staged + full typecheck +
|
||||
the whole test suite — but nothing anywhere runs or inspects `vite build`. Cloudflare's
|
||||
build fails safe on compile errors (previous deploy stays live) but silently; the escape
|
||||
class is green-but-wrong output, which is exactly how C1 shipped. Cheapest durable fix: the
|
||||
C1 post-build assertion in the `build` script; optionally a minimal CI workflow
|
||||
(typecheck + lint + test + build + assertion) to cover `--no-verify` and web edits.
|
||||
- Hashed `/assets/*` are served `cache-control: max-age=14400, must-revalidate` (Cloudflare
|
||||
Pages default; no `public/_headers` exists). Content-hashed files are the textbook
|
||||
`max-age=31536000, immutable` case; today every returning visitor revalidates a 3.6 MB
|
||||
chunk every 4 hours. HTML cache headers are correct.
|
||||
|
||||
### S5. Module-size outliers in the chart builder
|
||||
|
||||
`src/core/chart-builder.ts` (1,851 lines: catalog + rules + smart defaults + assembly/parse)
|
||||
and `ChartBuilderModal.tsx` (1,653 lines, ~25 private subcomponents; 2.1× the next-largest
|
||||
component). Internally cohesive, well-documented — maintainability risk, not defect. Split on
|
||||
next substantive touch (`chart-builder/{catalog,rules,defaults,assemble}.ts`; hoist the
|
||||
modal's sections into sibling files).
|
||||
|
||||
### S6. Smaller verified items
|
||||
|
||||
- `noUncheckedIndexedAccess` is off in a parser-heavy codebase (jsonc-parser node walking in
|
||||
`spec-params.ts`, `spec-cursor.ts`, `spec-insert.ts`) that is exactly its target class.
|
||||
Enable and sweep; if parser noise proves disproportionate, record the rejection.
|
||||
- `ajv` is imported by `spec-params.test.ts:1` but undeclared — a phantom transitive
|
||||
dependency; add it to devDependencies (found during verification).
|
||||
- `npm audit`: monaco 0.54.0 bundles a vulnerable DOMPurify (moderate; fix is 0.55.1, flagged
|
||||
breaking — plan deliberately); esbuild advisory is dev-only/Windows-only.
|
||||
- Landing/learn light-dark toggle duplicated since 06-25, TODO'd in both copies and reworded
|
||||
06-28 instead of extracted — a breadcrumb treated as the deliverable.
|
||||
|
||||
## Minor findings
|
||||
|
||||
- Per-editor CodeLens providers register on the global `'json'` language
|
||||
(`editor-cursor-lens.ts:83`) while closing over one editor's cursor — safe with today's
|
||||
single editor (verified: one `monaco.editor.create`), latent cross-wiring if a second JSON
|
||||
editor appears. One guard line: `if (model !== editor.getModel()) return`.
|
||||
- One avoidable non-null assertion in the WIP: `spec-transform-scaffold.ts:176`.
|
||||
- `navigator.platform` (deprecated) in `EventRouter.ts:38`.
|
||||
- Hooks are untested as a class; `useFocusTrap` (a11y-load-bearing, shared by all overlays)
|
||||
is the one worth a cheap happy-dom test. `ModalCoordinator`'s open/close/replace sequencing
|
||||
likewise (~5 tests).
|
||||
- No coverage reporting exists; for a philosophy that is explicitly proportional ("core
|
||||
hardest"), nothing measures the proportion. `@vitest/coverage-v8` scoped to
|
||||
`src/core` + `src/app/{stores,services}`, no thresholds, visibility only.
|
||||
- localStorage write failures are console-only (`settings-store.ts:80`, `ux-prefs.ts:80`) —
|
||||
judged an acceptable, explicit low-stakes swallow; noted so the asymmetry with the
|
||||
IndexedDB path stays a decision.
|
||||
- `/learn/` is reachable only from the landing; nothing in the app links it. Deliberate or
|
||||
forgotten is unrecorded — which is itself the gap.
|
||||
- Doc/housekeeping nits: pre-commit hook is stronger than AGENTS.md documents (says eslint;
|
||||
runs typecheck + tests too — drift in the good direction); PWA precache carries ~150–200 kB
|
||||
of landing/learn assets the `/app/`-scoped SW can never serve; spec 00 still calls settings
|
||||
a modal; spec 02 overstates the "imported" tag (only foreign/older shapes are tagged — spec
|
||||
08 has it right); `docs/embedding-vega-lite.md` (447 lines) is orphaned — index it with a
|
||||
charter line or move it; `core/settings.ts:64` and `HeaderControls.tsx:2` cite the old
|
||||
pre-move path of the theming scope doc; IMPLEMENTATION-PLAN files a completed item under
|
||||
"Next"; empty `ops/` directory; `vega-themes` pinned exact with no recorded rationale;
|
||||
manual-verification.md lacks checks for the shipped theming/export surface (Theme Builder
|
||||
gallery, font upload incl. variable fonts, PNG/SVG export, clipboard).
|
||||
|
||||
## Strengths (verified, and worth keeping deliberate)
|
||||
|
||||
1. **The architecture contract verifies by grep, both directions.** No browser APIs, React,
|
||||
or Monaco in `src/core/`; storage APIs confined to `infrastructure/`; landing/learn import
|
||||
no stores/orchestration/components.
|
||||
2. **TypeScript rigor at the top of the distribution.** Zero `any`, zero
|
||||
`@ts-ignore`/`@ts-expect-error`, exactly one non-null assertion across ~30k lines;
|
||||
type-aware ESLint (incl. `no-floating-promises`) passes clean; every intentionally
|
||||
unawaited promise is an explicit `void` with a comment.
|
||||
3. **Error-handling discipline is real.** Every swallowed catch inspected carries a rationale
|
||||
naming the sanctioned fallback; real failures route to notifications with actionable copy;
|
||||
quota errors are normalized and surfaced.
|
||||
4. **Concurrency/resource engineering above typical app code.** `LivePreview`'s generation
|
||||
token + render mutex (with reasoning written down), full Monaco disposable cleanup, the
|
||||
finalize-before-unsubscribe edge handled in chart-renderer.
|
||||
5. **The test suite is engineered, not accumulated.** 1,366 tests / 8.5s, zero snowflake
|
||||
harnesses across all 17 component test files, injected clocks and fake timers keyed to
|
||||
exported constants, fresh `IDBFactory` per test (including interrupted-upgrade
|
||||
self-healing), tests that assert contracts with the why inline
|
||||
(`spec-refs.test.ts`, `chart-renderer.test.ts`).
|
||||
6. **Session-scale coherence is solved.** All 12 stores share one canonical shape and cite
|
||||
their canonical sibling in doc comments; modals go through one registry/coordinator/shell;
|
||||
subtraction happens (`PreviewStore` deleted when obsoleted). The "written by strangers"
|
||||
failure mode is absent.
|
||||
7. **The breadcrumb→fix pipeline works.** At least 10 TODOs resolved by dedicated commits
|
||||
(quota rollback, hash routing, splitter ARIA, field-name escaping…); the eng-council→law
|
||||
loop is real (sweep findings became alignment rules #15–#17; skills keep evolving).
|
||||
8. **Docs are accurate at symbol level at scale** — of ~35 spot-checked claims (constants,
|
||||
record shapes, hash grammar, keyboard maps, API surfaces), nearly all exact — and the
|
||||
"shipped divergence" fencing discipline keeps pedagogical sketches from lying.
|
||||
9. **PWA configuration is best-practice**: `registerType: 'prompt'` properly consumed with a
|
||||
durable update toast, SW scope narrowed to `/app/`, sophisticated font precache strategy
|
||||
with reasoning in the config.
|
||||
|
||||
## Recommended sequence
|
||||
|
||||
1. **Record the regime** (C2): CLAUDE.md/AGENTS.md paragraph + alignment-skill update +
|
||||
soften spec README/arch 00. Smallest fix, unblocks every future clean-context review.
|
||||
_Done same day: CLAUDE.md, AGENTS.md, SOUL.md, spec README, and alignment rules #3/#17
|
||||
now state the spec-follows-code regime; #17 extended to cover never-specified additions._
|
||||
2. **Fix the landing bundle and add the post-build assertion** (C1): the only
|
||||
production-visible defect, and the assertion closes the gate gap (S4) for free. Add
|
||||
`public/_headers` for immutable assets while in there.
|
||||
_Done same day: `schemeColors` split into `core/scheme-colors.ts`; function-form
|
||||
`manualChunks` with explicit homes for the preload helper and the shared light packages
|
||||
(vega-themes, vega-expression, vega-util, json-stringify-pretty-compact — Rollup was
|
||||
absorbing each into the nearest vendor chunk); `scripts/check-light-entries.mjs` gates
|
||||
every build; `public/_headers` ships immutable caching. Landing eager JS measured
|
||||
~98 kB gzipped, down from ~1,300 kB; all three entries smoke-tested on the production
|
||||
build (charts + Monaco render, zero console errors)._
|
||||
3. **Spec back-fill sprint** (C3): specify /learn/, the wireframe, editor actions/scaffolds,
|
||||
and the view picker; add the spec-reconciliation clause to `/doc-update`.
|
||||
4. **Give the periodic instruments triggers** (S1) and run the overdue ones: an eng-council
|
||||
re-sweep (which naturally absorbs S5's splits and the `spec-*` family consolidation), a
|
||||
ux-second-pass batch pass, and TODO gardening — starting with `ModalCoordinator.ts:36`.
|
||||
5. **Extract and test the scaffold insertion core** (S2) before the params WIP is committed —
|
||||
the TODOs already name the module.
|
||||
6. **Doc drift batch** (S3 + minors): one session, mostly deletions of fossils.
|
||||
|
||||
---
|
||||
|
||||
## Addendum: subtraction audit (same day)
|
||||
|
||||
Follow-up requested by the maintainer: how much of the codebase is duplication, boilerplate,
|
||||
or unnecessary wrapping — with the explicit instruction that **deliberate architecture layers
|
||||
are not exempt**; "less LoC = less surface for bugs" outranks ceremony. Two auditors (a
|
||||
jscpd-quantified duplication pass and a wrapper/indirection pass with the contract layers on
|
||||
trial); the largest verbatim-copy claims were independently re-diffed before inclusion.
|
||||
|
||||
### The numbers
|
||||
|
||||
- **Production duplication is low: ~0.9%** by jscpd at min-tokens 50 (268 duplicated lines
|
||||
over ~29.5k production TS/TSX; roughly double counting renamed semantic twins). Test-file
|
||||
duplication is ~5.4% but is the documented harness convention — benign. CSS Modules are
|
||||
the highest-duplication format at 5.4% and deserve one deliberate pass.
|
||||
- **Pure ceremony is ~1.5% of non-test LoC.** The plumbing layers (infrastructure +
|
||||
orchestration + modals + hooks, ~2.8k lines, ~9% of src) are overwhelmingly load-bearing:
|
||||
`db.ts`'s promise wrapping/self-healing/quota normalization, `url-hash.ts`'s total-parse
|
||||
grammar, `remote-data.ts`'s size-cap and error classification, the modal coordinator's
|
||||
snapshot/confirm/URL lifecycle all earn their lines on inspection. No generic machinery
|
||||
with a single instantiation was found — `editor-cursor-lens` has three real consumers,
|
||||
`wireEntityWriteThrough` four, and every modal-registry field varies across entries.
|
||||
- **Confidently deletable today, behavior identical: ~450–500 LoC**, plus ~100–150 more
|
||||
behind design decisions.
|
||||
|
||||
The diagnosis, verbatim from the audit because it generalizes: **the abstractions are
|
||||
right; the residue is photocopied instantiation files and hand-unrolled adapter pairs that
|
||||
the abstractions should have absorbed.** Nothing calls for architectural change — only
|
||||
tidying inside the architecture. Notably, one suspicion from the main review was stale: the
|
||||
orchestration wirers were already consolidated behind `wireEntityWriteThrough`; the leftover
|
||||
per-entity files are the residue that consolidation should have deleted.
|
||||
|
||||
### The cut list (verified; ranked by value)
|
||||
|
||||
Do right away — all mechanical, most protected by existing tests:
|
||||
|
||||
1. **Per-preference persistence chain** (~90–110 LoC; the compounding win). Five
|
||||
near-identical load/save pairs in `settings-store.ts:116-184`, four identical 10-line
|
||||
init/wire pairs in `orchestration/preferences.ts`, same shape again in
|
||||
`orchestration/settings.ts`/`snippet-sort.ts`, ten sequential calls in `main.tsx`.
|
||||
Collapse: a `uiSlicePref(key, validate, fallback)` adapter factory + a
|
||||
`wireStorePref(store, selector, save)` orchestration helper. Every future preference then
|
||||
costs ~8 lines instead of ~30 across four files. `theme.ts` (FOUC ordering) and
|
||||
`panes.ts` (debounce) stay bespoke — they carry real variation.
|
||||
2. **Entity-adapter photocopies** (~55–60 LoC). `snippet-store.ts` / `dataset-store.ts` /
|
||||
`theme-store.ts` / `font-store.ts` are the identical 28-line load/save/delete triple
|
||||
differing only in store name, migrate fn, and version constant (re-diffed: confirmed).
|
||||
Collapse: `makeEntityAdapter<T>(storeName, version, migrate)` in `db.ts` + four 3-line
|
||||
instantiations. Migration files stay — real per-entity logic.
|
||||
3. **Wirer residue files** (~55 LoC). `dataset-persistence.ts` / `theme-persistence.ts` /
|
||||
`font-persistence.ts` are 25-line modules whose body is one 6-line
|
||||
`wireEntityWriteThrough` call. Move the calls into `entity-persistence.ts` or
|
||||
`startup.ts` (their only caller). `snippet-persistence.ts` stays — the debounced
|
||||
autosave is real logic.
|
||||
4. **`editor.addAction` ceremony** (~55 LoC). Thirteen copies of the same 6-line
|
||||
registration object across `spec-transform-actions.ts:524-585` and
|
||||
`spec-config-actions.ts:210-235` → one table + `.map(addAction)`.
|
||||
5. **Scaffold-twin merge** (~40–50 LoC; overlaps main-review S2 and shares its fix). The
|
||||
completion prologue, suggestion mapping, and create-property block are verbatim between
|
||||
`spec-transform-scaffold.ts` and `spec-param-scaffold.ts`. Collapse into
|
||||
`editor-snippet.ts` helpers; the pure indent math moves to the TODO'd core module, where
|
||||
it also becomes testable.
|
||||
6. **Core jsonc-helper copies** (~25–30 LoC). `objectKeys` byte-identical between
|
||||
`spec-params.ts:85-93` and `spec-data-transforms.ts:103-111` (re-diffed: confirmed);
|
||||
`paramsArrayNode`/`transformArrayNode` identical modulo key string; two more shared
|
||||
shapes. Extract/share — both sides have strong tests.
|
||||
7. **localStorage adapter shell** (~28 LoC). `readRaw`/`writeRaw`/`available` identical
|
||||
between `settings-store.ts` and `ux-prefs.ts` modulo type name and log tag (re-diffed:
|
||||
confirmed) → `jsonLocalRecord<T>(key, tag)` in the same layer.
|
||||
8. **Batch of micro-cuts** (~90 LoC): `UrlStateSync`'s argument-ignoring
|
||||
`syncModalToUrl`/`clearModalFromUrl` wrappers (delete when fixing the
|
||||
`ModalCoordinator.ts:36` ordering TODO they paper over); `DatasetStore`'s
|
||||
thrice-repeated save epilogue; `readTextFile`/`readBinaryFile` 1:1 renames of `File`
|
||||
methods; `initPersistentStorage` pass-through; `modals/types.ts` folded into the
|
||||
registry; `SpecEditor`'s seven subscribe/dispose pairs → array; the landing's four-times
|
||||
repeated `shot` block + TODO'd `UiTheme` redeclaration; `Icon.tsx`'s four-times copied
|
||||
panel frame; one dead CSS rule (`ChartBuilderModal.module.css:213`).
|
||||
|
||||
Needs design thought (~100–150 LoC more): the splitter triple (`ResizeHandle` /
|
||||
`PaneSplitHandle` / `InspectorSplitHandle` share a near-verbatim `role="separator"` block —
|
||||
worthwhile but untested a11y surface, do with manual keyboard verification); the theme-panel
|
||||
Color+Size+Weight row groups (60–100 LoC vs greppability of accessible names); the modal
|
||||
master-detail CSS block; the landing/learn theme-toggle hook (blocked on where a shared
|
||||
React hook may live under the entry-isolation rule — needs a maintainer call).
|
||||
|
||||
Adjudicated and acquitted (challenged per the widened mandate, earn their lines): the modal
|
||||
registry/coordinator/shell trio (every registry field varies; flattest honest version saves
|
||||
only ~35 lines); the Zustand store shapes (a generic collection-slice helper would cost more
|
||||
in typing than the ~60 lines it saves and obscure real per-store invariants); core purity
|
||||
shims (minimal — the altitude problem runs the other way, app services holding pure logic);
|
||||
the non-photocopy infrastructure adapters (real normalization, migration, error mapping).
|
||||
|
||||
### Wrap-up follow-ups (recorded from the params-feature review passes, same day)
|
||||
|
||||
The params WIP was closed through the full wrap-up protocol (alignment + eng-council, both
|
||||
clean-context). Fixed in that pass: an `isUnitNode` bug (nested layer containers offered
|
||||
schema-invalid selection params), the S2 extraction (`core/spec-snippet.ts`, insertion math
|
||||
now table-tested applied-and-reparsed), spec §03 scaffolding coverage, the transforms
|
||||
catalog upgraded to real-schema validation, `ajv` declared. Deferred with recorded homes:
|
||||
|
||||
- `src/core/lesson-parse.ts` exports five unused types (knip) — verify and unexport. Same
|
||||
check for `LensFactory` in `editor-cursor-lens.ts:24` (exported, consumed only in-module).
|
||||
- `spec-transform-actions.ts` has an internal ~8-line clone (~:269-276 vs ~:346-353) —
|
||||
fold into the existing 663-LOC consolidation item.
|
||||
- The Ajv compile boilerplate now lives in two test files; a third schema-validating
|
||||
catalog test should extract a shared helper — and at that point promote the rule
|
||||
"seeded editor catalogs validate against the bundled VL schema, not just JSON.parse"
|
||||
to an `/alignment` check.
|
||||
- A small knip config (ignore `@fontsource/*`, `marked` false positives) would make
|
||||
future dead-export sweeps one command.
|
||||
@@ -0,0 +1,177 @@
|
||||
# Landing & Onboarding Scope
|
||||
|
||||
_Point-in-time scope memo, 2026-07-04. Consolidates the onboarding/landing review: audience
|
||||
model, claims audit, objection map, target landing architecture, and the implementation
|
||||
phases. Supersedes nothing; feeds the next landing/onboarding sessions._
|
||||
|
||||
_Status (2026-07-04, same session): **Phases 1 and 2 shipped** — deep links, paste door,
|
||||
learn links, brushed-scatter example, and the full landing overhaul (showcase block,
|
||||
editor proof, objection beats, reweighted blocks, trust creed, voice pass). Phase 3
|
||||
(learn growth) remains. Gotcha recorded at the code site: faceted/concat specs need
|
||||
`fitMode: 'default'` in `LandingChart` — the width-fit contract can't size their
|
||||
children._
|
||||
|
||||
## Audience & thesis
|
||||
|
||||
The goal of the public surfaces is to **popularize Vega-Lite's capabilities**, not to serve
|
||||
a niche. Two audiences, one page, two pitches:
|
||||
|
||||
- **Beginners / the declarative-curious** — sold on **Vega-Lite itself**: charts written as
|
||||
text, interactive by declaration, beautiful out of the box (something users rarely have
|
||||
time to achieve themselves). Their doors: the Chart Builder, examples, `/learn/`.
|
||||
- **Practitioners** (already write specs, often via wrappers) — sold on **the editor**: a
|
||||
home for specs (vs. the Vega editor's scratchpad), schema-aware Monaco, dataset library,
|
||||
themes/fonts, export parameters the vega-embed kebab menu never offers.
|
||||
|
||||
The surface stays **general-purpose**: learners are welcome underneath, but nothing reads
|
||||
as a teaching tool or classroom product. Blocks alternate between the two pitches; the
|
||||
strongest moves serve both at once (a themed, interactive chart sells VL capability to the
|
||||
beginner and the theming machinery to the practitioner in the same pixels).
|
||||
|
||||
Hero-copy consequence: "A home for your Vega-Lite charts" addresses only people who
|
||||
already have Vega-Lite charts. The headline must admit the beginner too — positive case
|
||||
first (charts as text: interactive, themeable, durable), "home for them" as the second
|
||||
beat.
|
||||
|
||||
## Claims audit (2026-07-04)
|
||||
|
||||
Every claim on the current landing verifies against the code — no overclaims. The page
|
||||
**underclaims**: shipped capabilities absent from it, in order of missed leverage:
|
||||
|
||||
1. **Interactivity** — the page contains zero interactive charts (live-rendered, yes;
|
||||
interactive, no), while interactivity is VL's headline capability for popularization.
|
||||
2. **Composition wireframe** — drag-editable multi-view editing; unique in the Vega
|
||||
ecosystem; unmentioned.
|
||||
3. **Data inspector** — input vs. resolved rows per view; the answer to "why is my chart
|
||||
empty"; unmentioned.
|
||||
4. **CodeLens scaffolds** — one-click working params/transforms/view blocks; the bridge
|
||||
feature (beginners get working code to study, practitioners get speed); unmentioned.
|
||||
5. **Theme Builder breadth** — landing lists colour/type/axes/legend/layout; it also does
|
||||
marks, titles, number formats.
|
||||
6. **`/learn/`** — a capability, currently only a nav link.
|
||||
|
||||
Nitpick: "Sixteen presets" counts "Stock Vega-Lite (no theme)" as a preset.
|
||||
|
||||
Page-wide visual finding: nearly every chart renders in default blue. The page's imagery
|
||||
_is_ its charts; they must carry themed variety — the page itself is the proof of
|
||||
"out-of-the-box beauty without the time investment".
|
||||
|
||||
Pacing findings (desktop 1440, light): theme block ≈ a quarter of total scroll (three
|
||||
tall charts stacked); builder demo's default state is the most boring chart it can produce
|
||||
(count-by-channel, plain blue); datasets — the core "home, not scratchpad" argument — gets
|
||||
the weakest visual (small static mock); export gets a full peer block for what is partly
|
||||
table-stakes; the hero app window reads as a screenshot (nothing signals it is live).
|
||||
|
||||
## Objection map
|
||||
|
||||
Objections cluster two ways; each gets **one compact moment** on the page, not a FAQ
|
||||
sprawl. The best answers are either **on-ramps** or **stances stated before suspicion
|
||||
forms**.
|
||||
|
||||
**Habit cluster** — "I already have a way" — one positioning block near the editor
|
||||
section:
|
||||
|
||||
- _Vega editor?_ A scratchpad, not a home. (One-slot, no library, no fonts/themes.)
|
||||
- _Altair / wrappers?_ Their output **is** a Vega-Lite spec — paste it in, polish, keep.
|
||||
Most real-world VL usage is via Altair; this is the largest single objection. Nobody
|
||||
hand-writes specs from a blank buffer, and Astrolabe never asks them to (examples,
|
||||
builder, paste + autocomplete/scaffolds/inspector). Last-mile polish (label exprs, axis
|
||||
formats) is often faster in the spec than translated back through a wrapper API.
|
||||
- _LLMs write specs?_ Yes — paste it here; this is where an almost-right spec gets
|
||||
diagnosed (preview, validation, inspector). AI-free is a privacy feature: the model
|
||||
never sees the real data.
|
||||
|
||||
**Trust cluster** — generated by the local-only stance itself — lives at/near the creed:
|
||||
|
||||
- _Local = fragile?_ The library exports as one JSON file; back it up like any file you
|
||||
own. Copy must stay on export/import — never imply sync (none exists).
|
||||
- _Closed source, so why believe "no data leaves"?_ Falsifiable claim instead: static
|
||||
site, no backend, works fully offline once installed — airplane mode is the audit.
|
||||
(Never claim open source.)
|
||||
- _Solo project longevity?_ Lock-in-free is the honest answer: everything is ordinary
|
||||
Vega-Lite JSON; specs outlive the tool; the PWA keeps working offline.
|
||||
- _Sharing?_ Exports are the sharing story; spec-with-data-inlined is quietly the share
|
||||
feature and should be framed as one.
|
||||
|
||||
Beginner-adjacent beat (one line, no comparison table): vs. Datawrapper/Flourish — no
|
||||
account, no hosting dependency, real interactivity, a growing library you own.
|
||||
|
||||
**Deliberately not addressed on the landing**: storage limits / huge datasets (real
|
||||
constraint, edge concern; the in-app storage monitor is the right surface — raising it in
|
||||
marketing plants a worry most visitors never had).
|
||||
|
||||
## Target landing architecture
|
||||
|
||||
1. **Hero + app window** — dual-audience headline; default example themed and
|
||||
interactive; explicit "this is the real app — try it" affordance; window's snippets
|
||||
deep-link into the app.
|
||||
2. **"What Vega-Lite can do"** — NEW, the popularization centerpiece: 2–3 interactive
|
||||
charts (tooltip; brush → linked filter; a facet) in distinct themes, each beside its
|
||||
short spec. Argument: _this is a text file._
|
||||
3. **"A serious editor"** — practitioner proof: autocomplete/validation/inline docs
|
||||
shown (staged, NOT real Monaco — the post-build gate keeps Monaco off the landing),
|
||||
CodeLens scaffold shown, data inspector named. Positioning block (habit cluster)
|
||||
attaches here.
|
||||
4. **"Don't want to start from JSON?"** — builder, explicitly the second door; demo
|
||||
defaults to a colourful non-trivial state (an intent applied, colour encoding on).
|
||||
5. **"One library"** — datasets promoted and made vivid (extract-inline-data
|
||||
before/after is the honest demo).
|
||||
6. **"Make it yours"** — half current height: one chart + theme switcher, gallery as a
|
||||
compact grid, fonts in the first sentence.
|
||||
7. **"Get it out the way you need it"** — export reframed as practitioner pain relief
|
||||
(parameters the vega-embed kebab menu lacks), compact; inline-data export framed as
|
||||
sharing.
|
||||
8. **Creed** — plus the trust cluster (backup/export, offline-as-proof, portability-as-
|
||||
longevity) → learn pointer → close.
|
||||
|
||||
Verify on mobile and dark before committing layout.
|
||||
|
||||
## App-side changes
|
||||
|
||||
- **Deep links in**: `#build` already exists in the hash grammar — the landing builder
|
||||
demo can link today. Add `#example-<id>` (consumed once at startup: create that
|
||||
example as a snippet, open it, clear the param) so landing demos hand off momentum
|
||||
instead of resetting at the onboarding canvas. Spec §01 hash table updates with it.
|
||||
- **Onboarding canvas third door — "Paste a spec"**: serves Altair users, LLM users,
|
||||
Vega-editor migrants; today they must Create → select-all → delete → paste. Also a
|
||||
quiet "Restoring from an export? Import your workspace" line (Import is header-icon-only
|
||||
during onboarding). Spec §02 updates.
|
||||
- **Example gallery showpieces**: add 1–2 interactive/composed examples (brushable
|
||||
scatter + linked histogram; ties to the existing linked-views lesson). Single source of
|
||||
truth pays twice: the landing hero window shows them automatically.
|
||||
- **Learn wiring**: the app currently has zero links to `/learn/`. Add: About modal, and
|
||||
a low-key onboarding-canvas line.
|
||||
|
||||
## Learn direction
|
||||
|
||||
- Lessons gain "Open in Astrolabe" (mechanism: example ids where possible; arbitrary
|
||||
stage specs need a spec-payload deep link — decide at implementation, watch hash size).
|
||||
- Content growth (later): lessons keyed to examples ("what to try next with the
|
||||
scatter"), draft/publish workflow, theming walkthrough. Until grown, keep nav billing
|
||||
consistent with a two-lesson section.
|
||||
|
||||
## Implementation phases
|
||||
|
||||
**Phase 1 — connective tissue (app-side, one session):**
|
||||
`#example-<id>` deep link (core parse + startup consumption + tests) · paste-a-spec door
|
||||
(+ import line) on the onboarding canvas · learn links (About + canvas) · showpiece
|
||||
example(s) in `CHART_EXAMPLES` with compile test · spec §01/§02 updates in-session.
|
||||
|
||||
**Phase 2 — landing overhaul (design-involved):**
|
||||
New showcase block (#2) first — it is the thesis · editor-proof block (#3, staged
|
||||
visuals, no Monaco import) · resequence/reweight (#4–#7) · hero copy + live-affordance ·
|
||||
objection + trust beats · themed-chart pass across every chart on the page · mobile/dark
|
||||
pass.
|
||||
|
||||
**Phase 3 — learn growth (ongoing content):**
|
||||
"Open in Astrolabe" from lessons · new lessons per the direction above.
|
||||
|
||||
## Parked / deferred
|
||||
|
||||
- Post-first-snippet discoverability (draft/publish, extract, theming are invisible until
|
||||
stumbled on; no tours — against the app's grain) → parked in `docs/ux-second-pass.md`
|
||||
for the batched council pass.
|
||||
- Multi-device sync → separate exploration (see monetization/sync memo); landing copy
|
||||
must not imply it.
|
||||
- URL-encoded spec sharing (à la Vega editor) → plausible future feature if the sharing
|
||||
objection keeps surfacing; product, not copy.
|
||||
@@ -0,0 +1,272 @@
|
||||
# Learn Section — Lesson Roadmap
|
||||
|
||||
_Point-in-time planning memo, 2026-07-04. Detailed briefs for the next lessons, written to
|
||||
be authorable one at a time (draft → maintainer edit, like the why-astrolabe workflow).
|
||||
Format for every lesson: the existing progression machinery — hook, `:::data`, staged
|
||||
specs with diff highlighting, `:::sharp-edge`, a "take it further" closer that uses the
|
||||
per-stage "Open in Astrolabe" links._
|
||||
|
||||
## Positioning
|
||||
|
||||
The Vega-Lite docs are an example gallery plus a property reference — hundreds of
|
||||
_finished_ specs. Lessons that showcase charts compete with that and lose. What the docs
|
||||
don't have, and where middle+ users plateau, is the **invisible machinery and its failure
|
||||
modes**: where data actually flows, how selections resolve, why scales unify or don't.
|
||||
Every lesson below is organized around a _misconception_, not a chart type. The
|
||||
progression format (the path from almost-right to right) and the sharp edges (documented
|
||||
failure modes) are the moat; keep both in every lesson.
|
||||
|
||||
## Tracks & ordering
|
||||
|
||||
Two informal tracks once the roster grows past ~4 lessons (add a `level` field to lesson
|
||||
frontmatter; the index groups by it — "foundations" and "deeper", not course-like
|
||||
numbering):
|
||||
|
||||
- **Foundations**: binning, labels-on-bars, long-vs-wide, data-flow.
|
||||
- **Deeper**: linked-views, highlight-vs-filter, faceting, resolution, time,
|
||||
interactive-binning.
|
||||
|
||||
Cross-link map (a lesson references another only once it exists): linked-views' "gap
|
||||
math" stage → long-vs-wide; long-vs-wide → data-flow; highlight-vs-filter ← linked-views'
|
||||
closer; interactive-binning ← binning's sharp edge; resolution ← faceting's scale caveats.
|
||||
|
||||
The sharp-edge blocks are accumulating into a corpus nothing else on the Vega-Lite
|
||||
internet has; once there are ~8, an "edges" index page (auto-collected from lesson
|
||||
frontmatter or the parsed callouts) becomes a destination of its own.
|
||||
|
||||
---
|
||||
|
||||
## 1. `highlight-vs-filter` — the two answers to a selection
|
||||
|
||||
**Thesis / misconception**: a selection can drive a view two fundamentally different ways
|
||||
— _highlight_ (conditional encoding: context preserved, scales still) or _filter_ (rows
|
||||
removed: everything recomputes, scales included). Most people know one and reach for it
|
||||
everywhere; the choice is the actual design decision.
|
||||
|
||||
**Scenario**: six product lines' weekly revenue — a spaghetti chart where the reader
|
||||
cares about one line at a time.
|
||||
|
||||
**Stages**:
|
||||
|
||||
1. _spaghetti_ — six lines, one color scale; unreadable but honest baseline.
|
||||
2. _+ point selection on the legend_ — `params: [{select: {type: "point", fields:
|
||||
["product"], on: legend-binding}}]`; nothing reads it yet.
|
||||
3. _+ highlight_ — `opacity: {condition: {param, value: 1}, value: 0.2}`: the chosen line
|
||||
pops, the rest stay as context. Note: the y-scale did not move.
|
||||
4. _+ the filter variant_ — same selection, second view (or swapped response) with
|
||||
`transform: [{filter: {param}}]`: the y-axis re-fits the chosen line. Note the trade
|
||||
explicitly: filtering _loses the comparison_ but gains resolution.
|
||||
5. _polished_ — both responses side by side over one selection; titles that name the
|
||||
difference ("in context" / "re-scaled").
|
||||
|
||||
**Sharp edges**: `empty` default makes condition + filter behave differently before any
|
||||
click (highlight: everything full-opacity; filter: everything shown) — set `empty:
|
||||
"none"` deliberately. Point selections toggle on re-click (shift-click accumulates);
|
||||
that's `toggle` and it surprises people.
|
||||
|
||||
**Take it further**: change the condition channel from opacity to color/size; try
|
||||
`select: {type: "point", on: "pointerover"}` for hover-highlight.
|
||||
|
||||
## 2. `long-vs-wide` — reshaping rows for the chart you want
|
||||
|
||||
**Thesis / misconception**: "my data is in columns" is a data-_shape_ problem, not a
|
||||
chart problem. Vega-Lite wants long rows for encoding channels (`fold` gets you there);
|
||||
row-wise arithmetic wants columns (`pivot` gets you back). SQL framing for the
|
||||
analyst audience: fold ≈ UNPIVOT, pivot ≈ crosstab/GROUP BY columns.
|
||||
|
||||
**Scenario**: a spreadsheet-shaped budget — one row per team, columns `jan feb mar` —
|
||||
then a two-step signup funnel where the metric is a _ratio between rows_.
|
||||
|
||||
**Stages**:
|
||||
|
||||
1. _the spreadsheet wall_ — wide data charted naively: one bar per team, months
|
||||
inaccessible to color/facet. The failure is the hook.
|
||||
2. _+ fold_ — `fold: ["jan","feb","mar"]` → key/value rows; suddenly month is an
|
||||
encoding channel like any other.
|
||||
3. _tidy names_ — fold's `as: ["month","spend"]`; real field names, temporal parse.
|
||||
4. _+ pivot for row math_ — the funnel: long rows (step, count) pivoted to columns so
|
||||
`calculate: datum.purchase / datum.visit` can produce a conversion rate per cohort.
|
||||
5. _polished_ — both charts labelled; the note states the rule of thumb: _encode long,
|
||||
compute wide_.
|
||||
|
||||
**Sharp edges**: pivot drops rows with missing keys silently — the `isValid` patching
|
||||
dance (exactly what linked-views' gap stage does; link back). Fold keeps _other_ columns
|
||||
duplicated per folded row — aggregate afterwards or double-count.
|
||||
|
||||
**Retro-link**: linked-views' "+ the gap math" note gains a pointer here once shipped.
|
||||
|
||||
## 3. `data-flow` — where your data actually flows
|
||||
|
||||
**Thesis / misconception**: transforms run in _array order_, and encoding-level
|
||||
`aggregate`/`bin` run _after_ the transform array — so "why is my filter not working"
|
||||
is usually "your filter runs at a different point in the pipeline than you think".
|
||||
|
||||
**Scenario**: percent-of-total by category — the one chart that needs the pipeline
|
||||
understood, because it needs a total _alongside_ rows, not instead of them.
|
||||
|
||||
**Stages**:
|
||||
|
||||
1. _encoding aggregate_ — plain `sum` bar chart; fine, but a dead end for percent-of.
|
||||
2. _transform aggregate_ — the same chart via `transform: [{aggregate}]`; identical
|
||||
pixels, different pipeline position — now downstream transforms can read the result.
|
||||
3. _+ joinaggregate_ — the star of the lesson: totals attached to every row (rows kept,
|
||||
unlike `aggregate`), then `calculate` percent.
|
||||
4. _order matters_ — move a `filter` before vs after the joinaggregate; percentages of
|
||||
the filtered subset vs of the whole. Same transforms, opposite meanings.
|
||||
5. _polished_ — percent-of-total bars with a `window` rank ordering.
|
||||
|
||||
**Sharp edges**: `window` without `sort` is row-order-dependent (works in the example,
|
||||
breaks on real data); `frame: [null, 0]` means "start through current row" — the
|
||||
cumulative default everyone copies without reading.
|
||||
|
||||
## 4. `labels-on-bars` — layering, taught by the most-searched task
|
||||
|
||||
**Thesis**: putting values on bars is the internet's most-asked Vega-Lite question, and
|
||||
the answer is layer mechanics: a `text` mark sharing the bar's encodings, plus the
|
||||
handful of properties that make labels sit right.
|
||||
|
||||
**Scenario**: a ranked horizontal bar chart (top categories by value) — the chart people
|
||||
actually want labels on.
|
||||
|
||||
**Stages**:
|
||||
|
||||
1. _bars, sorted_ — includes the `sort: "-x"` idiom in passing.
|
||||
2. _+ a text layer_ — same data, `mark: "text"`, x/y duplicated; labels land ON the bar
|
||||
ends, ugly but working. Note how shared encodings could be hoisted to the layer root.
|
||||
3. _+ placement_ — `align`, `dx`, `baseline`: labels just past the bar end.
|
||||
4. _+ formatted_ — `format`/`formatType`, and a `calculate` for compact units (e.g.
|
||||
"1.2k").
|
||||
5. _polished_ — de-emphasized axis (the labels now carry the values; drop the x-axis
|
||||
grid/ticks — say why: double-encoding).
|
||||
|
||||
**Sharp edges**: labels don't avoid each other or the bar end — there is no collision
|
||||
avoidance in Vega-Lite; for inside-vs-outside placement use a `condition` on bar length.
|
||||
Dual-axis via `resolve: {scale: {y: "independent"}}` looks adjacent but is a trap —
|
||||
mention, defer detail to `resolution`.
|
||||
|
||||
## 5. `time` — the sharpest axis
|
||||
|
||||
**Thesis / misconception**: temporal data has two independent honesty problems — _when
|
||||
is a date_ (timezone parsing: the off-by-one-day bug) and _what is a time bucket_
|
||||
(`timeUnit` vs binning vs exact timestamps).
|
||||
|
||||
**Scenario**: daily signups spanning a year, authored as date-only strings — the exact
|
||||
shape that triggers the UTC/local trap.
|
||||
|
||||
**Stages**:
|
||||
|
||||
1. _the off-by-one_ — date-only strings (`"2026-03-01"`) charted naively; for viewers
|
||||
west of Greenwich every point sits on the previous day. Explain: JS parses date-only
|
||||
strings as UTC midnight, then Vega-Lite renders in _local_ time.
|
||||
2. _+ honest parsing_ — the fixes shown together: `utcyearmonthdate` timeUnits (stay in
|
||||
UTC end-to-end) vs. explicit local parsing; pick one side and stay on it.
|
||||
3. _+ timeUnit bucketing_ — `yearmonth` collapses days to months _in the chart_, no
|
||||
transform needed; contrast with `timeUnit` as a transform when the bucket must feed
|
||||
later steps.
|
||||
4. _+ axis formatting_ — `axis.format` vs `axis.formatType`, and why tick labels lie
|
||||
when format and timeUnit disagree.
|
||||
5. _polished_ — a monthly chart that renders identically in Kyiv and California.
|
||||
|
||||
**Sharp edges**: the date-only/datetime parsing asymmetry (date-only → UTC, with-time →
|
||||
local) is the single most-reported "bug" that isn't one; `timeUnit` without `utc` prefix
|
||||
re-buckets per-viewer-timezone — dashboards that disagree between offices.
|
||||
|
||||
## 6. `resolution` — one legend or two
|
||||
|
||||
**Thesis / misconception**: who shares scales by default differs by composition kind —
|
||||
**layer: shared; facet: shared; concat/repeat: independent** — and `resolve` is the knob.
|
||||
Most "my colors don't match between panels" bugs are this table, unknown.
|
||||
|
||||
**Scenario**: the same two-metric dashboard composed three ways (layer, concat, facet),
|
||||
watching the color scale and legends merge or split.
|
||||
|
||||
**Stages**:
|
||||
|
||||
1. _concat, two legends_ — the bug as the baseline: same field, two views, two legends,
|
||||
possibly two color assignments.
|
||||
2. _+ resolve shared_ — `resolve: {scale: {color: "shared"}}`: one legend, one truth.
|
||||
3. _manual pinning_ — `scale: {domain: [...], range: [...]}` per view as the other
|
||||
route (what linked-views does — call it out); when explicit domains beat resolve.
|
||||
4. _layer's inverse problem_ — layered dual-metric where _sharing_ is the bug;
|
||||
`resolve: {scale: {y: "independent"}}`, and the honest warning about dual axes.
|
||||
5. _polished_ — the corrected dashboard with a note naming the default-by-kind table.
|
||||
|
||||
**Sharp edges**: axis resolution is separate from scale resolution (shared scale can
|
||||
still draw two axes); legends for `condition`-driven encodings don't exist (conditions
|
||||
have no legend — a recurring surprise after the highlight lesson).
|
||||
|
||||
## 7. `interactive-binning` — the promised binning sequel
|
||||
|
||||
**Thesis**: which knobs of a spec are _live_ (parameterizable) and which are
|
||||
compile-time. Binning is the teaching case: `step`/`maxbins` are frozen; `extent` is
|
||||
live.
|
||||
|
||||
**Scenario**: the delivery-times histogram from the binning lesson, upgraded to an
|
||||
overview-detail pair.
|
||||
|
||||
**Stages**:
|
||||
|
||||
1. _the slider that does nothing_ — **show the failure live**: a `param` bound to a
|
||||
slider, referenced where `step` wants a number; compiles, renders, slider moves,
|
||||
bars don't. The strongest inoculation the format can deliver.
|
||||
2. _the working knob_ — overview histogram with an interval brush.
|
||||
3. _+ extent_ — detail histogram whose `bin: {extent: {param: "brush"}, maxbins: 20}`
|
||||
re-bins inside the brushed range: coarse overview, fine detail.
|
||||
4. _polished_ — labeled pair, brush styling, tooltips.
|
||||
|
||||
**Sharp edges**: recap step/maxbins immutability (now demonstrated, not asserted);
|
||||
`extent` re-bins but does not _filter_ — pair it with a `filter: {param}` or the detail
|
||||
view still draws out-of-range rows at the edges.
|
||||
|
||||
**Retro-link**: binning's sharp edge gains "see the sequel" once this ships.
|
||||
|
||||
## 8. `faceting` — the goldmine: small multiples and their caveats
|
||||
|
||||
**Thesis / misconception**: there are _three_ ways to repeat a chart — the `facet`
|
||||
channel/operator (split by a field's values), `repeat` (split by _different fields_),
|
||||
and hand-built `concat` — and most frustration comes from using one where another is
|
||||
meant, then fighting its constraints.
|
||||
|
||||
**Scenario**: sales by region — first split by region (facet), then the same chart
|
||||
repeated across _metrics_ (revenue, units, margin — repeat), showing why facet can't do
|
||||
the latter.
|
||||
|
||||
**Stages**:
|
||||
|
||||
1. _the encoding facet_ — `encoding.facet` / `row` / `column`: one extra line, small
|
||||
multiples with shared scales ("honest comparison for free" — link the landing's
|
||||
showcase claim).
|
||||
2. _wrapped + sorted_ — `columns: 3`, and sorting facets by a data value (`sort` on the
|
||||
facet field def) — the "my panels are alphabetical but I want by total" fix.
|
||||
3. _the operator form_ — `facet: {...}, spec: {...}`: same output, but now the child can
|
||||
be a _layered_ spec — the form you need the moment panels contain more than one mark.
|
||||
4. _repeat, not facet_ — the metrics case: `repeat: {column: [fields]}` +
|
||||
`{repeat: "column"}` field references; facet cannot do this (it splits by values, not
|
||||
by fields).
|
||||
5. _polished_ — headers styled (`header` vs axis titles), spacing, independent y where
|
||||
metrics differ in unit (`resolve` callback to lesson 6).
|
||||
|
||||
**Sharp edges** (the caveat goldmine — candidates, pick 2–3 for the block and push the
|
||||
rest into stage notes):
|
||||
|
||||
- Facet children can't take `width/height: "container"` — faceted charts size by
|
||||
per-panel `width`/`height` and ignore fit-to-container (the app's own fit modes fall
|
||||
back to `pad`; arch 05 records the engine-side truth).
|
||||
- A facet spec can't be layered _over_ (facet must be outermost; layer inside the child,
|
||||
never outside).
|
||||
- Selections in facets resolve per-panel by default — a brush in one panel doesn't
|
||||
select in the others until `resolve: "union"`/`"global"` on the param.
|
||||
- `header` vs `axis`: facet labels live on headers; styling them via axis config
|
||||
silently does nothing.
|
||||
|
||||
---
|
||||
|
||||
## Authoring workflow
|
||||
|
||||
One lesson per session-slice: I draft the full markdown (scenario data included, specs
|
||||
verified rendering via the dev server before review), the maintainer edits voice and
|
||||
pedagogy, then it ships with its retro-links applied to earlier lessons. Order proposed:
|
||||
**highlight-vs-filter → long-vs-wide → faceting → time → labels → data-flow → resolution
|
||||
→ interactive-binning** (interest-first: interactivity and faceting are the section's
|
||||
strongest differentiation; data-flow and resolution are load-bearing but drier, better
|
||||
once the section has gravity).
|
||||
@@ -0,0 +1,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.1–3.2); this memo stays the point-in-time record.
|
||||
@@ -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 A–C — 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.
|
||||
@@ -0,0 +1,71 @@
|
||||
# VS Code / Positron Extension — Exploration
|
||||
|
||||
_Point-in-time memo, 2026-07-04. Option recorded for post-1.0; nothing is being built.
|
||||
Assesses shipping Astrolabe's authoring intelligence as an editor extension._
|
||||
|
||||
## The idea
|
||||
|
||||
Port the editor-intelligence layer — scaffolding CodeLenses, spec-aware completions, the
|
||||
data inspector, live preview — to a VS Code extension (and Positron via OpenVSX), working
|
||||
on `.vl.json` files in the user's own workspace.
|
||||
|
||||
## Why it's cheap: the core/adapter boundary
|
||||
|
||||
Everything interesting is already editor-agnostic. `src/core/` (spec analysis, scaffold
|
||||
construction, site detection over JSON offsets, rendering preparation) has no Monaco
|
||||
imports; the Monaco layer (`spec-param-scaffold`, `spec-transform-scaffold`,
|
||||
`editor-cursor-lens`) is thin adapters. VS Code's extension API has one-to-one
|
||||
counterparts:
|
||||
|
||||
| Astrolabe piece | VS Code counterpart |
|
||||
| ------------------------------- | ---------------------------------------------------- |
|
||||
| CodeLens scaffolds | `languages.registerCodeLensProvider` + core verbatim |
|
||||
| Param/transform completions | `registerCompletionItemProvider` / code actions |
|
||||
| Live preview (`chart-renderer`) | Webview panel running vega-embed (same browser ctx) |
|
||||
| Data inspector (`inspectData`) | Same webview, message bridge to the render handle |
|
||||
| Bundled schema (offline) | `jsonValidation` contribution |
|
||||
| Dataset-by-name (`data.name`) | Resolve against a workspace `datasets/` folder |
|
||||
|
||||
Both editors expose offset↔position conversion, so the core's offset-based site
|
||||
detection transfers without change.
|
||||
|
||||
## The marketplace gap
|
||||
|
||||
Preview exists; authoring help does not.
|
||||
|
||||
- [Vega Viewer](https://marketplace.visualstudio.com/items?itemName=RandomFractalsInc.vscode-vega-viewer)
|
||||
and [Vega Preview](https://marketplace.visualstudio.com/items?itemName=mdk.vega-preview)
|
||||
render specs — preview-only.
|
||||
- The [official vega plugin](https://github.com/vega/vega-vscode) is deprecated; its note
|
||||
points out VS Code's built-in JSON service already gives `$schema`-driven completion and
|
||||
validation. **Schema completion/validation are therefore table stakes in VS Code, not
|
||||
differentiators** — unlike on the web, where Astrolabe had to build them.
|
||||
- Nothing in the marketplace offers scaffolds, an input-vs-resolved data inspector,
|
||||
dataset references, or theme configs.
|
||||
|
||||
Positron (Posit's VS Code fork, OpenVSX-distributed) concentrates the Altair audience —
|
||||
people whose wrapper output is already Vega-Lite and who live in an editor. For them an
|
||||
extension is more native than any website.
|
||||
|
||||
## v1 scope, if built
|
||||
|
||||
In: bundled-schema validation (offline), scaffold CodeLenses (params/transforms), live
|
||||
preview panel, data inspector, `data.name` resolution against a workspace folder, theme
|
||||
as an apply-able config file. Out, deliberately: the library, drafts/publish (git covers
|
||||
it), the chart builder UI, the theme builder, fonts UI. The extension is "Astrolabe's
|
||||
authoring intelligence, detached" — the _home_ identity does not port, because in an
|
||||
editor the workspace already is the home.
|
||||
|
||||
## Strategic read
|
||||
|
||||
For: real organic discovery (people search "vega" in the marketplace; nobody web-searches
|
||||
for a snippet manager they don't know exists); every extension user is a lead for the
|
||||
app; the port validates the core-first architecture.
|
||||
|
||||
Against: a second product surface for a single maintainer; it showcases the commodity
|
||||
part of Astrolabe (editing) rather than the unique part (the home); Positron/OpenVSX
|
||||
publishing is an extra channel to maintain.
|
||||
|
||||
**Decision: defer until after the web app's 1.0.** The only standing cost of keeping the
|
||||
option open is one we already pay by conviction: keep `src/core/` free of editor and
|
||||
browser imports.
|
||||
@@ -0,0 +1,47 @@
|
||||
# Why Astrolabe exists
|
||||
|
||||
I've worked with visualization tools for close to a decade, and for the last few years
|
||||
I've also taught data work in most of its forms — SQL, Python, spreadsheets, Power BI,
|
||||
Tableau, the list goes on.
|
||||
|
||||
Out of everything I've used in that time, Vega-Lite is one of my favorite ways to make a
|
||||
chart. It combines things that almost never come together in one tool:
|
||||
|
||||
- **It's an open standard.** A chart is a plain JSON file. No registration, no account,
|
||||
no service that can be discontinued out from under it. Specs I wrote years ago still
|
||||
render today, and I have every reason to believe they'll render in ten.
|
||||
- **It's web-native.** Most popular tools have to be installed (Tableau, Power BI) or run
|
||||
inside a particular environment (Altair, ggplot). For teaching, that raises the floor
|
||||
twice: technically — students on Linux can't install most BI suites at all — and
|
||||
conceptually. I want to teach _visualization_, not programming.
|
||||
- **It's declarative.** This point is really an ode to the Grammar of Graphics that
|
||||
ggplot popularized: you describe what the chart is, not how to draw it. My favorite
|
||||
tool for preparing data is SQL, another declarative language, and Vega-Lite scratches
|
||||
exactly the same itch.
|
||||
- **It's interactive.** This is what sets it apart from workhorses like matplotlib and
|
||||
ggplot: cross-filtering, tooltips, and brushing are a few lines of JSON, not an
|
||||
afternoon of event-handler code. Interactivity multiplies one chart into dozens — a
|
||||
different view for every filter state a reader can choose.
|
||||
|
||||
And yet, when I went looking for a comfortable place to write and keep these charts, I
|
||||
couldn't settle on anything. Vega-Lite came buried inside heavier BI tools, or in the
|
||||
official editor — a scratchpad that holds one spec at a time and keeps nothing. And using
|
||||
the full power of the format was fiddly in practice: to attach a custom font to a chart,
|
||||
you had to be handy with web development first. The low floor I praise to my students
|
||||
wasn't there for me either.
|
||||
|
||||
So I decided to build my ideal tool for Vega-Lite charts :)
|
||||
|
||||
At first it was just a snippet editor: a list of specs, and clicking one opens an editor
|
||||
with a live preview. Then datasets wanted to be their own thing — stored once, referenced
|
||||
by name from any chart, instead of pasted into every spec. Then a visual chart builder,
|
||||
for the days when you'd rather click than type. And then custom themes with your own
|
||||
fonts — the exact itch that used to require a web developer, now a file-upload away.
|
||||
|
||||
Somewhere along the way the tool also acquired a stance. Everything stays in your
|
||||
browser: no account, no server, and no AI model reading your charts. Part of that is
|
||||
principle. Most of it is the plain reality that the data I chart at work is confidential,
|
||||
and I wanted a tool where that question simply never comes up.
|
||||
|
||||
If you make charts with Vega-Lite — or you've been looking for a reason to start —
|
||||
[Astrolabe](/) is where mine live now.
|
||||
@@ -0,0 +1,16 @@
|
||||
## Що надихнуло на цей проєкт
|
||||
|
||||
Я використовую інструменти візуалізації в роботі вже майже десятиліття, а також вже декілька років викладаю роботу з даними та візуалізацію в багатьох аспектах - SQL, Python, Google SHeets, Power BI Tableau - список не вичерпний.
|
||||
|
||||
Маючи доволі широкий досвід як в інструментах, так і в оточеннях візуалізації, можу сказати що Vega-Lite - це один із моїх найулюбленіших інструментів з створення візуалізації. Він поєднує багато речей, які складаються в ідеальний інструмент для багатьох цілей:
|
||||
|
||||
- опенсорс. Відсутність необхідності реєструватись є сильним аргументом на користь того, що мої візуалізації будуть доступні довгий час та не поламаються, або для сервісу, в якому я їх створив, буде припинена підтримка.
|
||||
- крос-платформеність та веб-орієнтованість. Більшість популярних засобів з візуалізації мають бути інстальовані (Tableau, Power BI) або запускатись в певному оточенні (Altair, ggplot) - відповідно, якщо ми говоримо про використання їх для навчання, це підіймає поріг входу як технічно (студенти з Linux не можуть встановити більшість BI систем), так і концептуально (я хочу викладати _візуалізацію_ а не _програмування_).
|
||||
- декларативність. Цей пункт - скоріше ода до Grammar of Graphics, популяризований пакетами типу ggplot. Також моїм улюбленим інструментом для аналізу/підготовки даних є SQL, що теж є декларативною мовою.
|
||||
- інтерактивність. Те, що вигідно відрізняє Vega-Lite від популярних мастодонтів типу Matplotlib або ggplot - те, що я можу доволі легко і невимушено створювати інтерактивні графіки з крос-фільтрацією., додавати тултіпи тощо. Це посилює можливості інструменту візуалізації на порядки, адже інтерактивність одразу "помножує" один графік на десятки чи сотні залежно від обраного фільтру.
|
||||
|
||||
Разом з тим, коли я шукав зручний інструмент для створення та редагування візуалізацій, було доволі складно зупинитись на чомусь конкретному. Vega-Lite або був частиною якогось більш складного BI-інструменту, або мав надто скорочене застосування (Vega-Editor), або було недостатньо зручно користуватись всіма можливостями інструменту (щоб підключити кастомний шрифт, треба було бути підкованим в веб-розробці).
|
||||
|
||||
Отже, я вирішив створити свій ідеальний інструмент для створення графіків в Vega-Lite :)
|
||||
|
||||
Спершу це була ідея просто редактор "сніппетів" - список специфікацій, по вибору відкривається редактор і превʼю. Потім зʼявилась ідея додавати/витягувати набори даних в окремі сутності. Згодом виникла і ідея побудувати візуальний редактор, а ще згодом - можливість створення та збереження кастомних тем
|
||||
@@ -102,6 +102,11 @@ Behavior:
|
||||
- On load, the app reads the hash and restores the corresponding state (selected snippet, Datasets list, a specific dataset, the new-dataset form, or the Chart Builder).
|
||||
- An empty/absent hash opens the default snippets view with no modal.
|
||||
|
||||
**One-shot action links.** Two hash forms are not view states but requests, consumed on load: the app adds a snippet, opens it, then replaces the hash with the created snippet's view — so reloading does not re-add it, and the link never appears in Back/Forward history. Landing at one with an empty library skips the onboarding canvas and lays the workspace out at the same default split leaving the canvas would. Each visit deliberately creates a new copy.
|
||||
|
||||
- `#example-<id>` adds the matching gallery example (see _Snippet Library → First-Run & Empty Workspace_), named as in the gallery (same as pressing its **Add**). An unknown id is ignored and the hash degrades to the default view. The landing uses these links (the hero's "Open in Astrolabe") to hand a visitor into the app carrying the chart they were just looking at.
|
||||
- `#spec-<payload>` carries a spec's own text (base64url-encoded), so any sender — a lesson stage's "Open in Astrolabe", a shared link — can hand a self-contained spec into the app. The snippet's name derives from the spec (its `title`, else a "Mark chart of y by x" phrase, else "Shared spec"), like a pasted spec. A malformed payload is ignored and the hash degrades to the default view.
|
||||
|
||||
## F. Toast Notifications
|
||||
|
||||
Transient toast messages appear in a corner of the screen to confirm actions or report problems, without interrupting the workflow.
|
||||
|
||||
@@ -24,7 +24,9 @@ When the library is empty — on first run, or after the user deletes their last
|
||||
- The canvas briefly identifies what Astrolabe is, then offers the ways to begin.
|
||||
- **Create your first snippet** — the primary action; starts a new snippet from the sample bar-chart template and opens it in the editor (identical to _Create New_ under _Snippet Operations_).
|
||||
- **Build a chart from your data** — the data-first door beside the primary; opens the **Chart Builder** over the canvas. With no datasets yet, the builder's no-datasets state explains itself and leads to "Add a dataset" (see _Chart Builder → Opening_) — the path never dead-ends. Opening the builder also lays the workspace out at the default split below, since creating from the builder leaves the canvas directly.
|
||||
- An **example gallery** of a few simple snippets showcasing distinct Vega-Lite capabilities (e.g. a bar chart, a time-series line, a scatter plot, a stacked area, a donut, a binned histogram). Each example shows a **live preview** of the chart and a one-line description.
|
||||
- **Paste a spec you already have** — the bring-your-own door for users arriving with existing Vega-Lite JSON (a notebook, the Vega editor, an AI chat). A disclosure button (not a modal) reveals a labelled paste area in place; **Add to library** creates the snippet from the pasted text and opens it in the editor, **Cancel** collapses the panel and returns focus to the button. The panel stays mounted while collapsed, so a draft paste survives. Pasted text is accepted as-is — the editor's live validation is where an almost-right spec gets fixed — and the snippet's name derives from the spec (its `title`, else a "Mark chart of y by x" phrase), falling back to "Pasted spec".
|
||||
- Below the doors, two quiet secondary links: **Import your workspace** (for users restoring a workspace export — same file-picker import as the header control, see _Import & Export_) and **Read the deep dives** (the `/learn/` section, opened in a new tab).
|
||||
- An **example gallery** of a few simple snippets showcasing distinct Vega-Lite capabilities (e.g. a bar chart, a time-series line, a scatter plot, an interactive brushed scatter, a stacked area, a donut, a binned histogram). Each example shows a **live preview** of the chart and a one-line description.
|
||||
- **Add** on an example creates it as an ordinary snippet and makes it active (opening it in the editor).
|
||||
- **Add all** creates the whole set at once and makes one of them active.
|
||||
- Added examples are **ordinary snippets**: meaningfully named (not auto-generated timestamps), and thereafter editable, duplicable, and deletable like any other — they are the user's, not a special class (_own your data_).
|
||||
|
||||
@@ -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 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
|
||||
|
||||
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.
|
||||
- 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.
|
||||
- The error message is plainly legible (monospaced, distinct from normal content) and conveys what went wrong.
|
||||
- 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 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.
|
||||
- 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
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 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_.
|
||||
|
||||
|
||||
@@ -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.
|
||||
- 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.
|
||||
- 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
|
||||
|
||||
|
||||
@@ -8,6 +8,13 @@ record the resolution into the contract (`docs/architecture/09`+`10` and the rel
|
||||
|
||||
## 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
|
||||
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
|
||||
@@ -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`
|
||||
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)
|
||||
|
||||
- **Drag-and-drop field assignment** — chips are click/keyboard-first by design; drag would
|
||||
|
||||
+3
-3
@@ -44,10 +44,10 @@ export default tseslint.config(
|
||||
languageOptions: { globals: { ...globals.node } },
|
||||
},
|
||||
|
||||
// Plain JS config files (this file, etc.) are not part of the TS project —
|
||||
// run them through the untyped ruleset only.
|
||||
// Plain JS config files and Node scripts (this file, scripts/*.mjs, etc.) are
|
||||
// not part of the TS project — run them through the untyped ruleset only.
|
||||
{
|
||||
files: ['**/*.js'],
|
||||
files: ['**/*.{js,mjs}'],
|
||||
extends: [tseslint.configs.disableTypeChecked],
|
||||
languageOptions: { globals: { ...globals.node } },
|
||||
},
|
||||
|
||||
@@ -6,6 +6,10 @@
|
||||
<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="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>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
Generated
+8
@@ -22,6 +22,7 @@
|
||||
"@fontsource/space-mono": "^5.2.9",
|
||||
"@fontsource/spectral": "^5.2.8",
|
||||
"json-stringify-pretty-compact": "^4.0.0",
|
||||
"jsonc-parser": "^3.3.1",
|
||||
"marked": "^18.0.5",
|
||||
"monaco-editor": "^0.54.0",
|
||||
"react": "^19.2.7",
|
||||
@@ -39,6 +40,7 @@
|
||||
"@types/react": "^19.2.16",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.2.0",
|
||||
"ajv": "^8.20.0",
|
||||
"eslint": "^10.4.1",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
@@ -6148,6 +6150,12 @@
|
||||
"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": {
|
||||
"version": "6.2.1",
|
||||
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz",
|
||||
|
||||
+3
-1
@@ -6,7 +6,7 @@
|
||||
"description": "A browser-based snippet manager for Vega-Lite visualizations.",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"build": "tsc --noEmit && vite build && node scripts/check-light-entries.mjs",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
@@ -38,6 +38,7 @@
|
||||
"@fontsource/space-mono": "^5.2.9",
|
||||
"@fontsource/spectral": "^5.2.8",
|
||||
"json-stringify-pretty-compact": "^4.0.0",
|
||||
"jsonc-parser": "^3.3.1",
|
||||
"marked": "^18.0.5",
|
||||
"monaco-editor": "^0.54.0",
|
||||
"react": "^19.2.7",
|
||||
@@ -55,6 +56,7 @@
|
||||
"@types/react": "^19.2.16",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.2.0",
|
||||
"ajv": "^8.20.0",
|
||||
"eslint": "^10.4.1",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
|
||||
@@ -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
|
||||
@@ -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)`);
|
||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
* Rendered inside ModalShell — no backdrop, close button, or focus trap here;
|
||||
* the shell owns all of that (docs/architecture/03 → Layer 3). This component
|
||||
* 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.
|
||||
*/
|
||||
|
||||
@@ -47,9 +47,15 @@ export function AboutModal() {
|
||||
v<span className={styles.version}>{__APP_VERSION__}</span>
|
||||
</p>
|
||||
<p className={styles.body}>
|
||||
A local-first workspace for authoring, organizing, and previewing Vega-Lite charts. Edit
|
||||
JSON, see the chart update live, and keep a personal library of snippets — with no
|
||||
account, no server, and full offline support.
|
||||
A local-first workspace for authoring and organizing Vega-Lite charts. Edit the JSON,
|
||||
watch it render live, and keep a personal library of snippets.
|
||||
</p>
|
||||
<p className={styles.body}>
|
||||
New to Vega-Lite, or want to go deeper? Read the{' '}
|
||||
<a className={styles.link} href="/learn/" target="_blank" rel="noopener noreferrer">
|
||||
deep dives
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
@@ -74,17 +80,23 @@ export function AboutModal() {
|
||||
<section className={styles.section}>
|
||||
<h3 className={styles.heading}>Privacy</h3>
|
||||
<p className={styles.body}>
|
||||
Astrolabe runs entirely in your browser. Your snippets, datasets, and settings are stored
|
||||
locally and never leave your machine.
|
||||
Astrolabe runs in your browser. Your snippets, datasets, and settings are stored locally
|
||||
on your device — there’s no server to send them to. Work that has to stay
|
||||
confidential is safe here.
|
||||
</p>
|
||||
<ul className={styles.list}>
|
||||
<li>No account, no sign-in, no server-side storage.</li>
|
||||
<li>The app runs no analytics or tracking — no cookies, no telemetry, no profiling.</li>
|
||||
<li>No account or sign-in.</li>
|
||||
<li>
|
||||
The only outbound network requests are ones you create: URL-sourced datasets you add
|
||||
yourself.
|
||||
No AI. No model authors, edits, or critiques your charts, and nothing is sent to one.
|
||||
The chart builder’s suggestions are computed locally from your data.
|
||||
</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>
|
||||
Use the header’s import / export buttons to move your library between devices.
|
||||
</li>
|
||||
|
||||
@@ -25,7 +25,12 @@ vi.mock('../services/chart-renderer', () => {
|
||||
}
|
||||
return {
|
||||
renderSpec: vi.fn(() =>
|
||||
Promise.resolve({ destroy() {}, resize() {}, inspectData: () => null }),
|
||||
Promise.resolve({
|
||||
destroy() {},
|
||||
resize() {},
|
||||
inspectData: () => null,
|
||||
onDataChange: () => () => {},
|
||||
}),
|
||||
),
|
||||
ChartTooLargeError,
|
||||
};
|
||||
|
||||
@@ -21,6 +21,7 @@ vi.mock('../services/chart-renderer', () => ({
|
||||
resize() {},
|
||||
toImageURL: () => Promise.resolve(''),
|
||||
inspectData: () => null,
|
||||
onDataChange: () => () => {},
|
||||
}),
|
||||
),
|
||||
}));
|
||||
|
||||
@@ -20,13 +20,8 @@
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import type { JsonObject } from '@core/spec-config';
|
||||
import {
|
||||
type ConfigPath,
|
||||
countSet,
|
||||
getConfigValue,
|
||||
schemeColors,
|
||||
schemesByKind,
|
||||
} from '@core/theme-controls';
|
||||
import { type ConfigPath, countSet, getConfigValue, schemesByKind } from '@core/theme-controls';
|
||||
import { schemeColors } from '@core/scheme-colors';
|
||||
import { Button } from './Button';
|
||||
import { ColorField } from './ColorField';
|
||||
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 view’s 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 view’s 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 sibling’s 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 sibling’s 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');
|
||||
});
|
||||
});
|
||||
@@ -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 view’s edge to stack · into a frame’s 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>
|
||||
);
|
||||
}
|
||||
@@ -32,12 +32,38 @@ afterEach(() => {
|
||||
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 viewButton = (label: string) =>
|
||||
Array.from(container.querySelectorAll<HTMLButtonElement>('[role="radio"]')).find(
|
||||
(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]> = {}) =>
|
||||
act(() => {
|
||||
@@ -68,26 +94,33 @@ describe('DataInspectorPanel', () => {
|
||||
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', () => {
|
||||
const data: InspectedData = {
|
||||
input: [{ region: 'West', sales: '1204' }],
|
||||
resolved: [{ region: 'West', total: 1204 }],
|
||||
};
|
||||
render({ getData: () => data });
|
||||
render({
|
||||
getData: () =>
|
||||
oneTable([{ region: 'West', sales: '1204' }], [{ region: 'West', total: 1204 }]),
|
||||
});
|
||||
// Resolved is the default — its column ("total"), not the input's ("sales").
|
||||
const headers = Array.from(container.querySelectorAll('th')).map((th) => th.textContent);
|
||||
expect(headers).toEqual(['region', 'total']);
|
||||
});
|
||||
|
||||
test('switching to Input shows the source rows', () => {
|
||||
const data: InspectedData = {
|
||||
input: [
|
||||
render({
|
||||
getData: () =>
|
||||
oneTable(
|
||||
[
|
||||
{ region: 'West', sales: '1204' },
|
||||
{ region: 'East', sales: '980' },
|
||||
],
|
||||
resolved: [{ region: 'West', total: 1204 }],
|
||||
};
|
||||
render({ getData: () => data });
|
||||
[{ region: 'West', total: 1204 }],
|
||||
),
|
||||
});
|
||||
act(() => viewButton('Input').click());
|
||||
const headers = Array.from(container.querySelectorAll('th')).map((th) => th.textContent);
|
||||
expect(headers).toEqual(['region', 'sales']);
|
||||
@@ -96,25 +129,25 @@ describe('DataInspectorPanel', () => {
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
|
||||
test('input empty: names the empty source', () => {
|
||||
render({ getData: () => ({ input: [], resolved: [] }) });
|
||||
render({ getData: () => oneTable([], []) });
|
||||
act(() => viewButton('Input').click());
|
||||
expect(text()).toContain('source data has no rows');
|
||||
});
|
||||
|
||||
test('caps the table and reports the total', () => {
|
||||
const resolved = Array.from({ length: 120 }, (_, i) => ({ i }));
|
||||
render({ getData: () => ({ input: [], resolved }) });
|
||||
render({ getData: () => oneTable([], resolved) });
|
||||
expect(container.querySelectorAll('tbody tr')).toHaveLength(50);
|
||||
expect(text()).toContain('first 50 of 120');
|
||||
});
|
||||
|
||||
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 });
|
||||
const before = getData.mock.calls.length;
|
||||
render({ getData, renderEpoch: 1 });
|
||||
@@ -122,7 +155,7 @@ describe('DataInspectorPanel', () => {
|
||||
});
|
||||
|
||||
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;
|
||||
expect(panel.style.height).toBe('240px');
|
||||
});
|
||||
@@ -133,6 +166,44 @@ describe('DataInspectorPanel', () => {
|
||||
act(() => toggle().click());
|
||||
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', () => {
|
||||
|
||||
@@ -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
|
||||
* view: **Input** (the parsed source rows, before the spec's transforms) and
|
||||
* **Resolved** (the rows the chart draws, after filters / calculated fields /
|
||||
* aggregation). Seeing input → output side by side is how you answer "why is my
|
||||
* chart empty/wrong" — look at what the transforms did to the data. Both tables
|
||||
* 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
|
||||
* embedding boundary, arch 05).
|
||||
* Shows the chart's data with a toggle between two ends of a table's pipeline:
|
||||
* **Input** (the parsed source rows, before the view's transforms) and
|
||||
* **Resolved** (the rows the marks draw, after filters / calculated fields /
|
||||
* aggregation). Seeing input → output is how you answer "why is my chart
|
||||
* empty/wrong" — look at what the transforms did to the data.
|
||||
*
|
||||
* A composed spec (layer/concat/facet/repeat) draws several tables, so a **view
|
||||
* 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
|
||||
* 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 —
|
||||
* only while expanded — because listing the view's datasets serializes them (see
|
||||
* `RenderHandle.inspectData`), so a collapsed inspector costs nothing.
|
||||
* preview-pane open state for the live-preview pane.
|
||||
*/
|
||||
|
||||
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 { DataTable } from './DataTable';
|
||||
import { SegmentedControl, type SegmentedOption } from './SegmentedControl';
|
||||
import { SelectControl, type SelectControlOption } from './SelectControl';
|
||||
import styles from './DataInspector.module.css';
|
||||
|
||||
/** Rows shown before truncating — matches the builder's source-preview cap (spec §06). */
|
||||
const ROW_LIMIT = 50;
|
||||
/** Columns named in a table's picker `detail` before eliding the rest. */
|
||||
const DETAIL_COLUMNS = 4;
|
||||
|
||||
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>> = [
|
||||
// `title` doubles as the accessible name (WCAG 2.5.3 label-in-name; leads with
|
||||
// the visible label).
|
||||
{ value: 'input', label: 'Input', title: 'Input — the source rows before the spec’s transforms' },
|
||||
{ value: 'input', label: 'Input', title: 'Input — the source rows before the view’s transforms' },
|
||||
{
|
||||
value: 'resolved',
|
||||
label: 'Resolved',
|
||||
title: 'Resolved — the rows the chart draws, after its transforms',
|
||||
title: 'Resolved — the rows the marks draw, after the view’s 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 {
|
||||
/** Whether the panel is expanded. */
|
||||
open: boolean;
|
||||
@@ -52,7 +73,12 @@ interface DataInspectorPanelProps {
|
||||
* `renderEpoch`).
|
||||
*/
|
||||
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;
|
||||
/**
|
||||
* Explicit panel height (px) — the live-preview pane sets this from its
|
||||
@@ -83,13 +109,20 @@ export function DataInspectorPanel({
|
||||
id,
|
||||
}: DataInspectorPanelProps) {
|
||||
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`
|
||||
// is an intentional refresh trigger — not read in the body (getData is stable and
|
||||
// always reads the latest view), so exhaustive-deps sees it as unnecessary.
|
||||
// Read tables only while open; re-read when a render settles. `renderEpoch` is an
|
||||
// intentional refresh trigger — not read in the body (getData is stable and always
|
||||
// reads the latest view), so exhaustive-deps sees it as unnecessary.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
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 (
|
||||
<div
|
||||
@@ -111,8 +144,21 @@ export function DataInspectorPanel({
|
||||
</button>
|
||||
{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
|
||||
label="Data view"
|
||||
label="Pipeline stage"
|
||||
options={VIEW_OPTIONS}
|
||||
value={view}
|
||||
onChange={setView}
|
||||
@@ -127,12 +173,14 @@ export function DataInspectorPanel({
|
||||
</div>
|
||||
|
||||
{open &&
|
||||
(rows === null ? (
|
||||
(data === null ? (
|
||||
<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 ? (
|
||||
<p className={styles.stateNote}>
|
||||
{view === 'resolved'
|
||||
? 'No rows — the spec’s filters or transforms left nothing to draw.'
|
||||
? 'No rows — the view’s filters or transforms left nothing to draw.'
|
||||
: 'The source data has no rows.'}
|
||||
</p>
|
||||
) : (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* 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
|
||||
* 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
|
||||
@@ -38,13 +38,13 @@ export function ExtractModal() {
|
||||
};
|
||||
|
||||
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 (
|
||||
<div className={styles.extract}>
|
||||
<p className={styles.intro}>
|
||||
Save this snippet’s inline data as a reusable dataset. The spec will be rewritten to
|
||||
Save this snippet’s embedded data as a reusable dataset. The spec will be rewritten to
|
||||
reference it by name.
|
||||
</p>
|
||||
|
||||
|
||||
@@ -34,6 +34,21 @@ export type IconName =
|
||||
| 'export' // export the workspace to a file — Carbon Download (a file comes out)
|
||||
| 'info' // about / information — Carbon Information (outline)
|
||||
| '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
|
||||
// glyph shows *which* pane it controls by position (left / centre / right).
|
||||
| '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" />
|
||||
</>
|
||||
),
|
||||
// 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: (
|
||||
<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" />
|
||||
),
|
||||
|
||||
@@ -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
|
||||
* busy OVERLAY itself is purely a function of `PreviewStore.busy`. These tests
|
||||
* set that flag directly and assert the DOM result — no timing, no mocking of the
|
||||
* async render path.
|
||||
* The render pipeline is integration-heavy (vega-embed, IndexedDB, Monaco), so
|
||||
* `renderSpec` is mocked to park each embed in `H.pending` — a test decides when
|
||||
* embeds settle. Render status (`error`/`busy`) is the pane's own local state, so
|
||||
* 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';
|
||||
@@ -12,7 +13,6 @@ import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { chartConfigForSelection } from '@core/vega-themes';
|
||||
import { useAppStore } from '../stores/AppStore';
|
||||
import { usePreviewStore } from '../stores/PreviewStore';
|
||||
import { useSnippetStore } from '../stores/SnippetStore';
|
||||
import { useDatasetStore } from '../stores/DatasetStore';
|
||||
import { LivePreview } from './LivePreview';
|
||||
@@ -27,10 +27,6 @@ const H = vi.hoisted(() => ({
|
||||
pending: [] as Array<() => void>,
|
||||
destroyed: [] as number[],
|
||||
configs: [] as unknown[],
|
||||
inspected: null as {
|
||||
input: ReadonlyArray<Record<string, unknown>>;
|
||||
resolved: ReadonlyArray<Record<string, unknown>>;
|
||||
} | null,
|
||||
}));
|
||||
vi.mock('../services/chart-renderer', () => ({
|
||||
renderSpec: (node: HTMLElement, _spec: unknown, config: unknown) => {
|
||||
@@ -48,7 +44,8 @@ vi.mock('../services/chart-renderer', () => ({
|
||||
H.destroyed.push(id);
|
||||
},
|
||||
resize() {},
|
||||
inspectData: () => H.inspected,
|
||||
inspectData: () => null,
|
||||
onDataChange: () => () => {},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -74,7 +71,6 @@ beforeEach(() => {
|
||||
H.pending.length = 0;
|
||||
H.destroyed.length = 0;
|
||||
H.configs.length = 0;
|
||||
usePreviewStore.setState({ error: null, busy: false });
|
||||
useSnippetStore.getState().reset();
|
||||
useDatasetStore.getState().reset();
|
||||
|
||||
@@ -87,11 +83,12 @@ beforeEach(() => {
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
usePreviewStore.setState({ error: null, busy: false });
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
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
|
||||
// bare [aria-hidden] query would also match decorative bits of the header
|
||||
// controls (e.g. the chart-theme select's caret).
|
||||
@@ -100,33 +97,44 @@ describe('LivePreview busy overlay', () => {
|
||||
/rendering/i.test(el.textContent ?? ''),
|
||||
) ?? null;
|
||||
|
||||
test('does not render the busy overlay when busy=false', () => {
|
||||
// The overlay element should not be in the DOM at all during normal operation.
|
||||
// Start a render and leave it parked in `H.pending`; advancing past the debounce
|
||||
// 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();
|
||||
});
|
||||
|
||||
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.
|
||||
expect(container.querySelector('[aria-busy]')).toBeNull();
|
||||
});
|
||||
|
||||
test('overlay disappears when busy returns to false', () => {
|
||||
act(() => usePreviewStore.setState({ busy: true }));
|
||||
test('a render in flight past ~1s shows the overlay and sets aria-busy', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
await startSlowRender();
|
||||
expect(overlay()).not.toBeNull();
|
||||
act(() => usePreviewStore.setState({ busy: false }));
|
||||
expect(container.querySelector('[aria-busy="true"]')).not.toBeNull();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
test('the overlay clears once the render settles', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
await startSlowRender();
|
||||
expect(overlay()).not.toBeNull();
|
||||
act(() => H.pending[0]()); // the parked embed resolves → busy cleared on settle
|
||||
await tick(0);
|
||||
expect(overlay()).toBeNull();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -7,9 +7,10 @@
|
||||
* mode applied) → renderSpec (vega-embed). A render-generation token guards
|
||||
* against a slow render resolving after a newer one.
|
||||
*
|
||||
* The pane header carries the Fit control (4 sizing modes, §04). Render errors
|
||||
* are published to the shared PreviewStore so the editor pane mirrors them
|
||||
* (§03E); the preview shows the same message in place of the chart.
|
||||
* The pane header carries the Fit control (4 sizing modes, §04). A render or
|
||||
* parse error shows here in place of the chart (error xor chart) — its single
|
||||
* 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
|
||||
* (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 type { FitMode } from '@core/rendering';
|
||||
import { DatasetNotFoundError, prepareSpecForRender } from '@core/rendering';
|
||||
import { firstExpressionError } from '@core/spec-expressions';
|
||||
import {
|
||||
chartConfigForSelection,
|
||||
chartThemeOptions,
|
||||
@@ -33,10 +35,10 @@ import { useAppStore } from '../stores/AppStore';
|
||||
import { useCustomThemeStore } from '../stores/CustomThemeStore';
|
||||
import { useDatasetStore } from '../stores/DatasetStore';
|
||||
import { useFontStore } from '../stores/FontStore';
|
||||
import { usePreviewStore } from '../stores/PreviewStore';
|
||||
import { selectShownText, useSnippetStore } from '../stores/SnippetStore';
|
||||
import { useUserSettingsStore } from '../stores/UserSettingsStore';
|
||||
import { ChartExport } from './ChartExport';
|
||||
import { CompositionWireframe } from './CompositionWireframe';
|
||||
import { DataInspector } from './DataInspector';
|
||||
import { InspectorSplitHandle } from './InspectorSplitHandle';
|
||||
import { SegmentedControl, type SegmentedOption } from './SegmentedControl';
|
||||
@@ -197,10 +199,11 @@ export function LivePreview() {
|
||||
const renderDebounce = useUserSettingsStore((s) => s.saved.performance.renderDebounce);
|
||||
// Seed with a sentinel epoch so the very first paint counts as a load (immediate).
|
||||
const lastLoadRef = useRef({ bufferEpoch: -1, editorView });
|
||||
const error = usePreviewStore((s) => s.error);
|
||||
const setError = usePreviewStore((s) => s.setError);
|
||||
const busy = usePreviewStore((s) => s.busy);
|
||||
const setBusy = usePreviewStore((s) => s.setBusy);
|
||||
// Render status is local to this pane — it is both the only producer and the
|
||||
// only consumer, so it needs no store (arch 01). `error` is the render/parse
|
||||
// message (null = clean or blank); `busy` gates the >1s render overlay.
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
// Mirrors whether `handleRef` currently holds a live view, so the per-chart
|
||||
// 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
|
||||
@@ -211,6 +214,13 @@ export function LivePreview() {
|
||||
// not `chartReady` — consecutive successful renders keep `chartReady` true, but
|
||||
// each one is new data the inspector must pick up.
|
||||
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
|
||||
// overlay (arch §10.2 NN/g: >1s owes a busy indication; <1s shows nothing to
|
||||
@@ -245,7 +255,7 @@ export function LivePreview() {
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
@@ -325,19 +335,25 @@ export function LivePreview() {
|
||||
setChartReady(false);
|
||||
setRenderEpoch((e) => e + 1);
|
||||
clearBusy();
|
||||
// A missing dataset reference is not a JSON/spec problem, so it gets a
|
||||
// tailored, fixable message instead of the generic syntax hint (council:
|
||||
// GOV.UK error-message + NN/g #9 — name the problem, give the real fix).
|
||||
// All render-failure messages share a line-led / noun-led terse shape
|
||||
// (`<location|noun> · <detail>`, arch 10 §1). A missing dataset and a
|
||||
// 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) {
|
||||
setError(
|
||||
`Dataset "${e.datasetName}" not found. Create it from Datasets ` +
|
||||
`(⌘/Ctrl+K), or check the dataset name in your spec.`,
|
||||
`Dataset "${e.datasetName}" not found · create it from Datasets (⌘/Ctrl+K)`,
|
||||
);
|
||||
} else {
|
||||
setError(
|
||||
`Rendering error: ${(e as Error).message}. ` +
|
||||
`Check your JSON syntax and that the spec is valid Vega-Lite.`,
|
||||
);
|
||||
// A malformed Vega expression is located by line (the editor also
|
||||
// squiggles it) and carries the same parser message the hover shows.
|
||||
// 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).
|
||||
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
|
||||
// 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;
|
||||
@@ -413,16 +442,14 @@ export function LivePreview() {
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
// Finalize the live view on unmount, and clear the shared error + busy state so
|
||||
// stale transient state never outlives this pane.
|
||||
// Finalize the live view on unmount so its timers/listeners don't outlive the
|
||||
// pane. Render status is local state and dies with the component.
|
||||
useEffect(
|
||||
() => () => {
|
||||
handleRef.current?.destroy();
|
||||
handleRef.current = null;
|
||||
setChartReady(false);
|
||||
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.header}>
|
||||
<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}>
|
||||
<ChartThemeControl />
|
||||
<ChartExport chartReady={chartReady} getImageUrl={getImageUrl} />
|
||||
<CompositionWireframe />
|
||||
<PreviewSettings />
|
||||
</div>
|
||||
</div>
|
||||
@@ -451,10 +480,15 @@ export function LivePreview() {
|
||||
<div className={`${styles.frame} ${FIT_CLASS[fitMode]}`} hidden={error !== null}>
|
||||
<div className={styles.host} ref={hostRef} />
|
||||
</div>
|
||||
{/* Visual only — no live region. The same error is announced once by the
|
||||
editor pane's role="alert" (one producer, two subscribers; doc §10.1),
|
||||
so adding one here would double-announce it. */}
|
||||
{error !== null && <pre className={styles.error}>{error}</pre>}
|
||||
{/* The single home for a render/parse error (arch 10 §1): it sits where the
|
||||
chart would be — error XOR chart — and is the lone live region, assertive
|
||||
since the user just caused it. The editor pinpoints the spot via its squiggle,
|
||||
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
|
||||
* or the editor (arch §10.2; spec §04/§10). Shown only after the ~1s threshold
|
||||
@@ -478,7 +512,7 @@ export function LivePreview() {
|
||||
<DataInspector
|
||||
id="preview-data-inspector"
|
||||
getData={getInspectData}
|
||||
renderEpoch={renderEpoch}
|
||||
renderEpoch={renderEpoch + liveEpoch}
|
||||
heightPx={inspectorOpen ? inspectorHeight : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -37,6 +37,79 @@
|
||||
|
||||
/* The two CTAs and the per-example Adds are shared Buttons (arch 09 §4). */
|
||||
|
||||
/* Paste-a-spec disclosure panel: revealed below the doors, form-shaped
|
||||
(label above field, hint between — GOV.UK textarea). */
|
||||
.pastePanel {
|
||||
margin-top: var(--space-4);
|
||||
padding: var(--space-4);
|
||||
border: var(--border-width) solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--layer-01);
|
||||
/* Elevated surface: the textarea's field fill steps off --layer-01 (arch 09 §4). */
|
||||
--field: var(--field-02);
|
||||
--field-hover: var(--field-hover-02);
|
||||
}
|
||||
|
||||
.pasteLabel {
|
||||
display: block;
|
||||
margin-bottom: var(--space-1);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.pasteHint {
|
||||
margin: 0 0 var(--space-3);
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* The field look (fill, underline, focus ring) comes from the base.css element
|
||||
baseline; the module adds only sizing idiosyncrasies (arch 09 §4). */
|
||||
.pasteInput {
|
||||
display: block;
|
||||
width: 100%;
|
||||
resize: vertical;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.pasteActions {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
margin-top: var(--space-3);
|
||||
}
|
||||
|
||||
/* Secondary paths (import / learn) as one quiet line of links below the doors. */
|
||||
.altPaths {
|
||||
margin: var(--space-4) 0 0;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Inline link styling shared by the import action (a button semantically —
|
||||
it triggers the file picker) and the learn anchor. Focus ring comes from the
|
||||
base.css baseline.
|
||||
TODO: this accent-link recipe now exists in four modules (AboutModal .link,
|
||||
DonateModal .email, DatasetsModal .linkButton, here) — past the "third site"
|
||||
threshold AboutModal.module.css records for promoting it to a shared
|
||||
primitive; consolidate into one home (arch 09 §4's four mechanisms). */
|
||||
.linkButton {
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
font: inherit;
|
||||
color: var(--accent);
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.hiddenInput {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* The "or start from an example" header row, with Add all pushed to the end. */
|
||||
.galleryHead {
|
||||
display: flex;
|
||||
|
||||
@@ -18,7 +18,13 @@ import { Onboarding } from './Onboarding';
|
||||
// never touches vega-embed. A resolved no-op handle is enough — Onboarding only
|
||||
// finalizes it on unmount.
|
||||
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;
|
||||
@@ -69,6 +75,41 @@ describe('Onboarding', () => {
|
||||
expect(activeSnippetId).toBe(snippets[0].id);
|
||||
});
|
||||
|
||||
test('the paste door creates a snippet from pasted text with a derived name', () => {
|
||||
click('Paste a spec you already have');
|
||||
const textarea = container.querySelector<HTMLTextAreaElement>('#onboarding-paste-input')!;
|
||||
const pasted = JSON.stringify({ title: 'My chart', mark: 'bar' });
|
||||
act(() => {
|
||||
// React reads the value through the native setter; assign then dispatch.
|
||||
Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')!.set!.call(
|
||||
textarea,
|
||||
pasted,
|
||||
);
|
||||
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
});
|
||||
click('Add to library');
|
||||
const { snippets, activeSnippetId } = useSnippetStore.getState();
|
||||
expect(snippets).toHaveLength(1);
|
||||
expect(snippets[0].spec).toBe(pasted);
|
||||
expect(snippets[0].name).toBe('My chart'); // deriveSnippetName: title wins
|
||||
expect(activeSnippetId).toBe(snippets[0].id);
|
||||
});
|
||||
|
||||
test('the paste door is a disclosure: hidden until toggled, empty paste disabled', () => {
|
||||
const panel = container.querySelector<HTMLElement>('#onboarding-paste-panel')!;
|
||||
expect(panel.hidden).toBe(true);
|
||||
click('Paste a spec you already have');
|
||||
expect(panel.hidden).toBe(false);
|
||||
// Add is disabled while the paste area is empty — nothing is created.
|
||||
const add = Array.from(container.querySelectorAll('button')).find((b) =>
|
||||
b.textContent?.includes('Add to library'),
|
||||
)!;
|
||||
expect(add.disabled).toBe(true);
|
||||
click('Cancel');
|
||||
expect(panel.hidden).toBe(true);
|
||||
expect(useSnippetStore.getState().snippets).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('"Add all" adds every example and makes the bar chart active', () => {
|
||||
click('Add all');
|
||||
const { snippets, activeSnippetId } = useSnippetStore.getState();
|
||||
|
||||
@@ -14,13 +14,14 @@
|
||||
* independent of `LivePreview`'s single-host render serialization.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { VisualizationSpec } from 'vega-embed';
|
||||
import { CHART_EXAMPLES, exampleSpecText, type ChartExample } from '@core/examples';
|
||||
import { createSnippet as createSnippetRecord } from '@core/snippet';
|
||||
import { createSnippet as createSnippetRecord, deriveSnippetName } from '@core/snippet';
|
||||
import { chartConfigFor } from '@core/vega-themes';
|
||||
import { openModal } from '../modals/ModalCoordinator';
|
||||
import { renderSpec, type RenderHandle } from '../services/chart-renderer';
|
||||
import { importWorkspace } from '../services/transfer';
|
||||
import { useAppStore } from '../stores/AppStore';
|
||||
import { usePanesStore } from '../stores/PanesStore';
|
||||
import { useSnippetStore } from '../stores/SnippetStore';
|
||||
@@ -91,6 +92,23 @@ export function Onboarding() {
|
||||
const addSnippets = useSnippetStore((s) => s.addSnippets);
|
||||
const applyOnboardingSplit = usePanesStore((s) => s.applyOnboardingSplit);
|
||||
|
||||
// The paste door (spec §02): a disclosure (WAI-ARIA APG disclosure pattern —
|
||||
// button + aria-expanded/aria-controls, no focus trap), not a modal: the canvas
|
||||
// has the whole workspace to itself, so revealing the paste surface in place
|
||||
// costs no context. The panel stays mounted (`hidden`) so a draft paste
|
||||
// survives a collapse.
|
||||
const [pasteOpen, setPasteOpen] = useState(false);
|
||||
const [pasteText, setPasteText] = useState('');
|
||||
const pasteTriggerRef = useRef<HTMLButtonElement>(null);
|
||||
const pasteAreaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const importInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// The revealed panel exists only for immediate input, so focus follows the
|
||||
// expand; Cancel returns it to the trigger (NN/g #3 — a clearly marked exit).
|
||||
useEffect(() => {
|
||||
if (pasteOpen) pasteAreaRef.current?.focus();
|
||||
}, [pasteOpen]);
|
||||
|
||||
// Leaving onboarding lays the workspace out at the default 25·25·50 split, so
|
||||
// the first chart opens with a generous preview (spec §02). The canvas fills
|
||||
// the window here, so its width is a good proxy for the panes container.
|
||||
@@ -116,6 +134,36 @@ export function Onboarding() {
|
||||
openModal('chartBuilder');
|
||||
};
|
||||
|
||||
// Pasted text is accepted as-is: the editor is the product's validator, and an
|
||||
// almost-right spec opening with live schema errors is the feature working.
|
||||
// The name derives from the spec (title → "Mark chart of y by x"), with
|
||||
// `nameSource: 'auto'` so it keeps tracking the spec until the user renames.
|
||||
const handlePasteAdd = () => {
|
||||
const text = pasteText.trim();
|
||||
if (!text) return;
|
||||
createSnippet({
|
||||
name: deriveSnippetName(text) ?? 'Pasted spec',
|
||||
nameSource: 'auto',
|
||||
spec: text,
|
||||
});
|
||||
layoutWorkspace();
|
||||
};
|
||||
const closePaste = () => {
|
||||
setPasteOpen(false);
|
||||
pasteTriggerRef.current?.focus();
|
||||
};
|
||||
|
||||
// Same hidden-picker pattern as the header's Import (spec §08 — the browser
|
||||
// file dialog is the only chrome); reset so re-picking the same file re-fires.
|
||||
// TODO: third hidden-file-picker site (App.tsx header, TypeControls.tsx) — past
|
||||
// the shared-piece threshold; eng-council proposed a useFilePicker/HiddenFileInput
|
||||
// shape. Consult before building (new hook/component kind).
|
||||
const handleImportFile = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = '';
|
||||
if (file) void importWorkspace(file);
|
||||
};
|
||||
|
||||
const handleAddAll = () => {
|
||||
// Stagger the timestamps so the first example (the bar chart) is the newest:
|
||||
// it then sorts to the top of the library and `addSnippets` makes it active
|
||||
@@ -137,8 +185,8 @@ export function Onboarding() {
|
||||
<div className={styles.inner}>
|
||||
<h2 className={styles.title}>Welcome to Astrolabe</h2>
|
||||
<p className={styles.tagline}>
|
||||
A local library for your Vega-Lite charts — authored as JSON, rendered live, and kept on
|
||||
your device.
|
||||
A local library for your Vega-Lite charts — write the spec as JSON, watch it render live,
|
||||
and keep it all on your device.
|
||||
</p>
|
||||
|
||||
<div className={styles.ctaRow}>
|
||||
@@ -148,8 +196,74 @@ export function Onboarding() {
|
||||
<Button size="lg" onClick={handleBuild}>
|
||||
<Icon name="chart" /> Build a chart from your data
|
||||
</Button>
|
||||
<Button
|
||||
size="lg"
|
||||
ref={pasteTriggerRef}
|
||||
aria-expanded={pasteOpen}
|
||||
aria-controls="onboarding-paste-panel"
|
||||
onClick={() => setPasteOpen((open) => !open)}
|
||||
>
|
||||
<Icon name="import" /> Paste a spec you already have
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div id="onboarding-paste-panel" className={styles.pastePanel} hidden={!pasteOpen}>
|
||||
{/* Visible label above the field (GOV.UK textarea — placeholder text is
|
||||
not a substitute for a label). */}
|
||||
<label className={styles.pasteLabel} htmlFor="onboarding-paste-input">
|
||||
Paste a Vega-Lite spec
|
||||
</label>
|
||||
<p className={styles.pasteHint} id="onboarding-paste-hint">
|
||||
Vega-Lite JSON from anywhere — a notebook, the Vega editor, an AI chat. It becomes your
|
||||
first snippet and opens in the editor, live errors and all.
|
||||
</p>
|
||||
<textarea
|
||||
ref={pasteAreaRef}
|
||||
id="onboarding-paste-input"
|
||||
className={styles.pasteInput}
|
||||
aria-describedby="onboarding-paste-hint"
|
||||
rows={8}
|
||||
spellCheck={false}
|
||||
value={pasteText}
|
||||
onChange={(e) => setPasteText(e.target.value)}
|
||||
/>
|
||||
<div className={styles.pasteActions}>
|
||||
<Button onClick={handlePasteAdd} disabled={pasteText.trim() === ''}>
|
||||
Add to library
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={closePaste}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Secondary paths as quiet links below the doors (Carbon empty-states —
|
||||
secondary calls to action are links, not more buttons). */}
|
||||
<p className={styles.altPaths}>
|
||||
Restoring from an export?{' '}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.linkButton}
|
||||
onClick={() => importInputRef.current?.click()}
|
||||
>
|
||||
Import your workspace
|
||||
</button>
|
||||
<span aria-hidden="true"> · </span>
|
||||
New to Vega-Lite?{' '}
|
||||
<a className={styles.linkButton} href="/learn/" target="_blank" rel="noopener noreferrer">
|
||||
Read the deep dives
|
||||
</a>
|
||||
</p>
|
||||
<input
|
||||
ref={importInputRef}
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
className={styles.hiddenInput}
|
||||
onChange={handleImportFile}
|
||||
aria-hidden="true"
|
||||
tabIndex={-1}
|
||||
/>
|
||||
|
||||
<div className={styles.galleryHead}>
|
||||
<h3 className={styles.galleryTitle}>Or start from an example</h3>
|
||||
<Button className={styles.addAll} onClick={handleAddAll}>
|
||||
|
||||
@@ -79,20 +79,3 @@
|
||||
background: var(--bg);
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -9,8 +9,9 @@
|
||||
*
|
||||
* 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 for reference; all editing happens on the draft. Render problems surface
|
||||
* inline near the editor (spec §03E), mirroring the preview via PreviewStore.
|
||||
* spec for reference; all editing happens on the draft. Render/parse problems
|
||||
* 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';
|
||||
@@ -26,20 +27,44 @@ import '../infrastructure/monaco-env'; // side-effect: wire workers before creat
|
||||
import { configureVegaLiteJson } from '../infrastructure/monaco-schema';
|
||||
import { configureJsonFormatter, installFormatOnPaste } from '../infrastructure/monaco-format';
|
||||
import { parseChartSpecText } from '@core/chart-builder';
|
||||
import { openChartBuilderForEdit, openModal } from '../modals/ModalCoordinator';
|
||||
import { openChartBuilderForEdit } from '../modals/ModalCoordinator';
|
||||
import {
|
||||
installSpecConfigActions,
|
||||
runExtractConfig,
|
||||
runExtractConfigToTheme,
|
||||
runMergeChartTheme,
|
||||
} 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 { confirm } from '../stores/ConfirmStore';
|
||||
import { useDatasetStore } from '../stores/DatasetStore';
|
||||
import { hasInlineData } from '../stores/ExtractStore';
|
||||
import { hasExtractableData } from '../stores/ExtractStore';
|
||||
import { publishActiveSnippet } from '../services/snippet-actions';
|
||||
import { notify } from '../stores/NotificationStore';
|
||||
import { usePreviewStore } from '../stores/PreviewStore';
|
||||
import { selectActiveSnippet, selectShownText, useSnippetStore } from '../stores/SnippetStore';
|
||||
import { useUserSettingsStore } from '../stores/UserSettingsStore';
|
||||
import { Icon } from './Icon';
|
||||
@@ -149,6 +174,17 @@ function EditorSettings() {
|
||||
configureVegaLiteJson();
|
||||
// Register the compact JSON formatter once (Format Document + format-on-paste, §03A).
|
||||
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 spec↔config operations, surfaced as an overflow menu (council:
|
||||
* Carbon menu-buttons — overflow for additional options under space
|
||||
@@ -174,6 +210,26 @@ const CONFIG_ACTIONS = [
|
||||
|
||||
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({
|
||||
editorRef,
|
||||
}: {
|
||||
@@ -190,10 +246,11 @@ function EditorToolbar({
|
||||
const draft = s.editorView === 'draft' ? s.draftText : active.draftSpec;
|
||||
return draft !== active.spec;
|
||||
});
|
||||
// Offer Extract only when the live draft carries top-level inline data to lift
|
||||
// out (spec §03F → hidden when the spec has no inline data).
|
||||
// Offer Extract only when the live draft carries data to lift out — inline
|
||||
// `values` in any view, or a reference to a self-defined `datasets` entry (spec
|
||||
// §03F → hidden when there is nothing extractable).
|
||||
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
|
||||
@@ -216,6 +273,14 @@ function EditorToolbar({
|
||||
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
|
||||
// the button and the Cmd/Ctrl+S shortcut (EventRouter) behave identically.
|
||||
const handlePublish = publishActiveSnippet;
|
||||
@@ -228,6 +293,13 @@ function EditorToolbar({
|
||||
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 ok = await confirm({
|
||||
title: 'Revert draft',
|
||||
@@ -276,14 +348,24 @@ function EditorToolbar({
|
||||
{canExtract && (
|
||||
<Button
|
||||
className={styles.collapsible}
|
||||
onClick={() => openModal('extract')}
|
||||
title="Extract inline data into a reusable dataset"
|
||||
onClick={handleExtract}
|
||||
title="Extract embedded data into a reusable dataset"
|
||||
aria-label="Extract to Dataset"
|
||||
>
|
||||
<Icon name="dataset" className={styles.actionIcon} />
|
||||
<span className={styles.actionLabel}>Extract to Dataset</span>
|
||||
</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
|
||||
id="editor-config-actions"
|
||||
label="Spec config actions"
|
||||
@@ -325,7 +407,8 @@ export function SpecEditor() {
|
||||
const editorView = useSnippetStore((s) => s.editorView);
|
||||
const bufferEpoch = useSnippetStore((s) => s.bufferEpoch);
|
||||
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.
|
||||
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.
|
||||
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 →
|
||||
// "bind listeners in exactly one place"), which publishes before the
|
||||
// interactive-context gate so it works while the editor has focus. Monaco
|
||||
@@ -385,6 +490,11 @@ export function SpecEditor() {
|
||||
sub.dispose();
|
||||
pasteSub.dispose();
|
||||
configActionsSub.dispose();
|
||||
transformActionsSub.dispose();
|
||||
codeLensSub.dispose();
|
||||
scaffoldLensSub.dispose();
|
||||
paramScaffoldLensSub.dispose();
|
||||
exprMarkersSub.dispose();
|
||||
editor.dispose();
|
||||
editorRef.current = null;
|
||||
};
|
||||
@@ -425,6 +535,49 @@ export function SpecEditor() {
|
||||
monaco.editor.setTheme(effective === 'dark' ? 'vs-dark' : 'vs');
|
||||
}, [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 (
|
||||
<div className={styles.editorPane}>
|
||||
<EditorToolbar editorRef={editorRef} />
|
||||
@@ -432,14 +585,8 @@ export function SpecEditor() {
|
||||
{activeId === null && <div className={styles.placeholder}>Select or create a snippet</div>}
|
||||
<div className={styles.editor} ref={hostRef} />
|
||||
</div>
|
||||
{/* The single live region for render/parse errors: assertive, since the
|
||||
user just caused it. The preview shows the same text visually but is
|
||||
not a live region, so the message is announced once (doc §10.1). */}
|
||||
{error !== null && (
|
||||
<pre className={styles.error} role="alert">
|
||||
{error}
|
||||
</pre>
|
||||
)}
|
||||
{/* Render/parse errors surface in the preview pane (arch 10 §1), where the
|
||||
chart would be; the editor pinpoints the spot with its squiggle. */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ const okHandle = () => ({
|
||||
resize() {},
|
||||
toImageURL: () => Promise.resolve(''),
|
||||
inspectData: () => null,
|
||||
onDataChange: () => () => {},
|
||||
});
|
||||
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
@@ -22,6 +22,7 @@ vi.mock('../services/chart-renderer', () => ({
|
||||
resize() {},
|
||||
toImageURL: () => Promise.resolve(''),
|
||||
inspectData: () => null,
|
||||
onDataChange: () => () => {},
|
||||
}),
|
||||
),
|
||||
}));
|
||||
|
||||
@@ -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
@@ -1,10 +1,10 @@
|
||||
/**
|
||||
* Project feedback channel.
|
||||
*
|
||||
* There is no server and no tracker (see the About modal — no telemetry of any
|
||||
* kind); feedback is a plain email the user composes and sends from their own
|
||||
* client. The address is a Cloudflare Email Routing alias that forwards to the
|
||||
* author, so it can be retired without exposing or churning a personal inbox.
|
||||
* The app sends no telemetry of its own (see the About modal); feedback is a
|
||||
* plain email the user composes and sends from their own client. The address is
|
||||
* a Cloudflare Email Routing alias that forwards to the author, so it can be
|
||||
* retired without exposing or churning a personal inbox.
|
||||
*
|
||||
* 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.
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import {
|
||||
parseHash,
|
||||
parseExampleHash,
|
||||
parseSpecHash,
|
||||
serializeHash,
|
||||
readView,
|
||||
replaceView,
|
||||
@@ -75,6 +77,41 @@ describe('parseHash', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseExampleHash', () => {
|
||||
it('extracts the example id from the one-shot action link', () => {
|
||||
expect(parseExampleHash('#example-brush')).toBe('brush');
|
||||
expect(parseExampleHash('example-bar')).toBe('bar'); // leading "#" optional
|
||||
});
|
||||
|
||||
it('returns null for anything that is not an example link', () => {
|
||||
expect(parseExampleHash('')).toBeNull();
|
||||
expect(parseExampleHash('#example-')).toBeNull(); // empty id
|
||||
expect(parseExampleHash('#snippet-abc')).toBeNull();
|
||||
expect(parseExampleHash('#build')).toBeNull();
|
||||
});
|
||||
|
||||
it('is not a ViewState: parseHash degrades an example link to the default view', () => {
|
||||
expect(parseHash('#example-brush')).toEqual({ kind: 'snippets' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseSpecHash', () => {
|
||||
it('extracts the payload from the one-shot spec link', () => {
|
||||
expect(parseSpecHash('#spec-eyJtYXJrIjoiYmFyIn0')).toBe('eyJtYXJrIjoiYmFyIn0');
|
||||
expect(parseSpecHash('spec-AAAA')).toBe('AAAA'); // leading "#" optional
|
||||
});
|
||||
|
||||
it('returns null for anything that is not a spec link', () => {
|
||||
expect(parseSpecHash('')).toBeNull();
|
||||
expect(parseSpecHash('#spec-')).toBeNull(); // empty payload
|
||||
expect(parseSpecHash('#example-brush')).toBeNull();
|
||||
});
|
||||
|
||||
it('is not a ViewState: parseHash degrades a spec link to the default view', () => {
|
||||
expect(parseHash('#spec-eyJtYXJrIjoiYmFyIn0')).toEqual({ kind: 'snippets' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('round-trip identity', () => {
|
||||
it('parseHash(serializeHash(v)) deep-equals v for every variant', () => {
|
||||
for (const v of ALL_VIEWS) {
|
||||
|
||||
@@ -88,6 +88,43 @@ export function serializeHash(view: ViewState): string {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One-shot action link, parsed separately from `ViewState`: `#example-<id>`
|
||||
* asks the app to add that gallery example (`@core/examples`) as a snippet at
|
||||
* startup and open it. It is consumed once — routing's settle step then
|
||||
* replaces the hash with the created snippet's view — so it never serializes
|
||||
* back and never participates in Back/Forward. `parseHash` degrades it (like
|
||||
* any unknown hash) to the default view, which is exactly the fallback for an
|
||||
* id that no longer exists.
|
||||
*/
|
||||
export function parseExampleHash(rawHash: string): string | null {
|
||||
const m = /^example-(.+)$/.exec(rawHash.replace(/^#/, ''));
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
/** Read the one-shot example id from the current URL, if any. */
|
||||
export function readExampleId(): string | null {
|
||||
return parseExampleHash(window.location.hash);
|
||||
}
|
||||
|
||||
/**
|
||||
* The second action link: `#spec-<payload>` carries a spec's text itself
|
||||
* (base64url — `@core/spec-link` owns the encoding), so a lesson stage or any
|
||||
* sender can hand a self-contained spec into the app. Same one-shot contract
|
||||
* as `#example-<id>`. The base64url alphabet has no percent-escapes, so
|
||||
* reading `location.hash` is safe even in Firefox (which returns the hash
|
||||
* percent-decoded).
|
||||
*/
|
||||
export function parseSpecHash(rawHash: string): string | null {
|
||||
const m = /^spec-(.+)$/.exec(rawHash.replace(/^#/, ''));
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
/** Read the one-shot spec payload from the current URL, if any. */
|
||||
export function readSpecPayload(): string | null {
|
||||
return parseSpecHash(window.location.hash);
|
||||
}
|
||||
|
||||
/** Read the current view from `window.location.hash`. */
|
||||
export function readView(): ViewState {
|
||||
return parseHash(window.location.hash);
|
||||
|
||||
@@ -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: {
|
||||
name: 'extract',
|
||||
title: 'Extract to Dataset',
|
||||
init: () => useExtractStore.getState().init(),
|
||||
getState: () => ({ name: useExtractStore.getState().name }),
|
||||
},
|
||||
|
||||
|
||||
@@ -12,6 +12,10 @@ import type { Snippet } from '@core/snippet';
|
||||
import type { Dataset } from '@core/dataset';
|
||||
import type { CustomTheme } from '@core/custom-theme';
|
||||
import type { FontAsset } from '@core/font-asset';
|
||||
import { CHART_EXAMPLES, exampleSpecText } from '@core/examples';
|
||||
import { deriveSnippetName } from '@core/snippet';
|
||||
import { decodeSpecPayload } from '@core/spec-link';
|
||||
import { readExampleId, readSpecPayload } from '../infrastructure/url-hash';
|
||||
import { loadSnippets } from '../infrastructure/snippet-store';
|
||||
import { loadDatasets } from '../infrastructure/dataset-store';
|
||||
import { loadCustomThemes } from '../infrastructure/theme-store';
|
||||
@@ -23,6 +27,7 @@ import {
|
||||
} from '../services/storage-errors';
|
||||
import { notify } from '../stores/NotificationStore';
|
||||
import { useSnippetStore } from '../stores/SnippetStore';
|
||||
import { usePanesStore } from '../stores/PanesStore';
|
||||
import { useDatasetStore } from '../stores/DatasetStore';
|
||||
import { useCustomThemeStore } from '../stores/CustomThemeStore';
|
||||
import { useFontStore } from '../stores/FontStore';
|
||||
@@ -95,6 +100,47 @@ export async function initApp(): Promise<void> {
|
||||
wireThemePersistence();
|
||||
wireFontPersistence();
|
||||
|
||||
// One-shot action links (spec §01E): `#example-<id>` (the landing's hand-off
|
||||
// links) adds that gallery example; `#spec-<payload>` (lesson stages, shared
|
||||
// links) carries the spec text itself. Both add an ordinary snippet and open
|
||||
// it. They run after persistence wiring (so the new snippet write-throughs;
|
||||
// anything created before wiring would be treated as loaded baseline and
|
||||
// never saved) and before routing (whose settle step below replaces the hash
|
||||
// with the created snippet's view, so a reload doesn't re-add it). An unknown
|
||||
// id or malformed payload is ignored and the hash degrades to the default view.
|
||||
// TODO: the ordering constraints above (after wiring, before routing) have no
|
||||
// automated coverage — an initApp integration test (fake-indexeddb + stubbed
|
||||
// location.hash) would catch a reorder silently breaking the marketing links.
|
||||
const addLinkedSnippet = (options: {
|
||||
name: string;
|
||||
spec: string;
|
||||
nameSource?: 'auto' | 'user';
|
||||
}): void => {
|
||||
const firstSnippet = useSnippetStore.getState().snippets.length === 0;
|
||||
useSnippetStore.getState().createSnippet(options);
|
||||
// Entering the workspace directly, the onboarding canvas never shows — so
|
||||
// give a first chart the same generous default split leaving it would
|
||||
// (spec §02 → First-Run & Empty Workspace).
|
||||
if (firstSnippet) usePanesStore.getState().applyOnboardingSplit(window.innerWidth);
|
||||
};
|
||||
const exampleId = readExampleId();
|
||||
const example = exampleId ? CHART_EXAMPLES.find((e) => e.id === exampleId) : undefined;
|
||||
if (example) {
|
||||
// Same semantics as the gallery's Add: a curated name the user chose.
|
||||
addLinkedSnippet({ name: example.name, spec: exampleSpecText(example) });
|
||||
} else {
|
||||
const payload = readSpecPayload();
|
||||
const specText = payload !== null ? decodeSpecPayload(payload) : null;
|
||||
if (specText !== null) {
|
||||
// Same semantics as the paste door: derived name that tracks the spec.
|
||||
addLinkedSnippet({
|
||||
name: deriveSnippetName(specText) ?? 'Shared spec',
|
||||
nameSource: 'auto',
|
||||
spec: specText,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Routing starts AFTER hydrate so the on-load hash restore can resolve snippet
|
||||
// / dataset ids against the loaded stores (spec §01E, docs/architecture/04).
|
||||
startRouting();
|
||||
|
||||
@@ -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 view’s', () => {
|
||||
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 view’s', () => {
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import type { VisualizationSpec } from 'vega-embed';
|
||||
import type { Config } from 'vega-lite';
|
||||
import { collectFontFamilies } from '@core/custom-theme';
|
||||
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';
|
||||
|
||||
/** Options for `RenderHandle.toImageURL` (spec §08 → Per-chart export). */
|
||||
@@ -44,15 +44,82 @@ interface ImageExportOptions {
|
||||
embedFonts?: ReadonlyArray<FontAsset>;
|
||||
}
|
||||
|
||||
/** The two ends of the chart's data pipeline, for the data inspector (spec §04). */
|
||||
export interface InspectedData {
|
||||
/** Parsed source rows, before the spec's transforms run — the input. */
|
||||
/** One inspectable drawn table — the two ends of its pipeline (spec §04). */
|
||||
export interface InspectableTable {
|
||||
/** 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>>;
|
||||
/** Post-transform rows the chart draws — the output (equals `input` when the
|
||||
* spec has no transforms). */
|
||||
/** Post-transform rows the marks draw — the output (equals `input` when the view
|
||||
* has no transforms). */
|
||||
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 {
|
||||
/** Finalize the underlying Vega view and clear the node. */
|
||||
destroy(): void;
|
||||
@@ -78,20 +145,29 @@ export interface RenderHandle {
|
||||
*/
|
||||
resize(): void;
|
||||
/**
|
||||
* The chart's input and resolved (post-transform) rows — for the data inspector
|
||||
* (spec §04). Reads the live view's compiled dataflow once: lists its datasets,
|
||||
* picks the most-upstream
|
||||
* source and most-downstream result (`@core/result-data`), and returns both
|
||||
* tables' rows. This is the one place besides export that reaches into the view,
|
||||
* so the embedding boundary holds (arch 05 §1–§2) — callers get rows, never the
|
||||
* `view`.
|
||||
* The chart's inspectable tables — for the data inspector (spec §04). Enumerates
|
||||
* the tables the marks draw from the compiled Vega spec (`@core/inspect-views`),
|
||||
* and for each reads its input + resolved rows from the live view. A multi-view
|
||||
* spec yields several tables; a unit spec yields one. This is the one place
|
||||
* besides export that reaches into the view, so the embedding boundary holds
|
||||
* (arch 05 §1–§2) — callers get rows, never the `view`.
|
||||
*
|
||||
* Returns `null` when there is nothing to inspect (the view was finalized, or
|
||||
* the spec produced no inspectable table). Either side can be `[]` when its
|
||||
* table is empty — a real signal (e.g. a filter removed every row on the
|
||||
* resolved side), kept distinct from "no chart" so the inspector can say which.
|
||||
* Returns `null` when the view was finalized (no chart). The result's `tables`
|
||||
* is empty when a chart draws nothing inspectable, and any table's `input`/
|
||||
* `resolved` can be `[]` (e.g. a filter removed every row) — kept distinct from
|
||||
* "no chart" so the inspector can say which.
|
||||
*/
|
||||
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 {
|
||||
@@ -297,22 +373,22 @@ export async function renderSpec(
|
||||
},
|
||||
inspectData() {
|
||||
if (finalized) return null;
|
||||
// Enumerate the dataflow's datasets once, then pick the input + result
|
||||
// tables. getState with a truthy `data` filter is Vega's documented way to
|
||||
// list datasets (vega/editor's Data Viewer does the same) — we read only the
|
||||
// keys. Rows come from view.data(name), which hands back the live array (no copy).
|
||||
const state = result.view.getState({
|
||||
data: () => true,
|
||||
signals: () => false,
|
||||
recurse: true,
|
||||
}) as { data?: Record<string, unknown> };
|
||||
const names = Object.keys(state.data ?? {});
|
||||
const sourceName = pickSourceDataset(names);
|
||||
const resultName = pickResultDataset(names);
|
||||
if (sourceName === null && resultName === null) return null;
|
||||
const rows = (name: string | null): ReadonlyArray<Record<string, unknown>> =>
|
||||
name === null ? [] : ((result.view.data(name) ?? []) as Record<string, unknown>[]);
|
||||
return { input: rows(sourceName), resolved: rows(resultName) };
|
||||
// The tables the marks draw + their input lineage come from the compiled Vega
|
||||
// spec (a byproduct of the embed, not recompiled); the rows come from
|
||||
// view.data(name), which hands back the live array (no copy).
|
||||
const views = inspectableViews(result.vgSpec);
|
||||
const rows = (name: string): ReadonlyArray<Record<string, unknown>> =>
|
||||
(result.view.data(name) ?? []) as Record<string, unknown>[];
|
||||
const tables = views.map((v, i) => ({
|
||||
id: v.resolved,
|
||||
label: inspectViewLabel(v.input, i),
|
||||
input: rows(v.input),
|
||||
resolved: rows(v.resolved),
|
||||
}));
|
||||
return { tables };
|
||||
},
|
||||
onDataChange(listener) {
|
||||
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 selection→filter→data 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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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')}`,
|
||||
};
|
||||
}
|
||||
@@ -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 parent→child 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: 'Can’t 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');
|
||||
}
|
||||
@@ -40,11 +40,16 @@ import { useCustomThemeStore } from '../stores/CustomThemeStore';
|
||||
import { notify } from '../stores/NotificationStore';
|
||||
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;
|
||||
try {
|
||||
parsed = JSON.parse(model.getValue());
|
||||
parsed = JSON.parse(text);
|
||||
} catch {
|
||||
notify({
|
||||
kind: 'error',
|
||||
@@ -57,7 +62,7 @@ function parseSpecObject(model: monaco.editor.ITextModel): Record<string, unknow
|
||||
notify({
|
||||
kind: 'error',
|
||||
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;
|
||||
}
|
||||
@@ -85,7 +90,7 @@ function replaceDocument(
|
||||
export function runMergeChartTheme(editor: monaco.editor.IStandaloneCodeEditor): void {
|
||||
const model = editor.getModel();
|
||||
if (!model) return;
|
||||
const spec = parseSpecObject(model);
|
||||
const spec = parseSpecObject(model.getValue());
|
||||
if (!spec) return;
|
||||
|
||||
const { chartTheme, uiTheme } = useAppStore.getState();
|
||||
@@ -122,7 +127,7 @@ function extractableConfig(editor: monaco.editor.IStandaloneCodeEditor): {
|
||||
} | null {
|
||||
const model = editor.getModel();
|
||||
if (!model) return null;
|
||||
const spec = parseSpecObject(model);
|
||||
const spec = parseSpecObject(model.getValue());
|
||||
if (!spec) return null;
|
||||
|
||||
const { spec: rest, config } = extractConfigFromSpec(spec);
|
||||
|
||||
@@ -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 spec’s 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() {} };
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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 draft↔published 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, []);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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 isn’t 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() {} };
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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]),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -42,3 +42,19 @@ describe('data inspector height (divider math)', () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { create } from 'zustand';
|
||||
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 { ChartThemeSelection } from '@core/vega-themes';
|
||||
import type { ModalName } from '../modals/types';
|
||||
@@ -65,6 +67,40 @@ export interface AppState {
|
||||
dataInspectorHeight: number;
|
||||
/** The currently open modal, or 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;
|
||||
/** 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.
|
||||
*/
|
||||
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) => ({
|
||||
@@ -93,6 +149,8 @@ export const useAppStore = create<AppState>((set) => ({
|
||||
dataInspectorOpen: false,
|
||||
dataInspectorHeight: DATA_INSPECTOR_DEFAULT_HEIGHT,
|
||||
activeModal: null,
|
||||
revealTarget: null,
|
||||
composeRequest: null,
|
||||
|
||||
setTheme: (uiTheme) => set({ uiTheme }),
|
||||
toggleTheme: () => set((s) => ({ uiTheme: s.uiTheme === 'dark' ? 'light' : 'dark' })),
|
||||
@@ -101,4 +159,42 @@ export const useAppStore = create<AppState>((set) => ({
|
||||
setDataInspectorOpen: (dataInspectorOpen) => set({ dataInspectorOpen }),
|
||||
setDataInspectorHeight: (dataInspectorHeight) => set({ dataInspectorHeight }),
|
||||
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 },
|
||||
})),
|
||||
}));
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
* active snippet draft's inline data, takes a dataset name, and on confirm saves
|
||||
* the data as a new dataset and rewrites the draft so the inline data is replaced
|
||||
* by a by-name reference (`{ "data": { "name": … } }`).
|
||||
* embedded data of the **view the cursor sits in**, takes a dataset name, and on
|
||||
* confirm saves the data as a new library dataset and rewrites the spec so the
|
||||
* embedded data is replaced by a by-name reference.
|
||||
*
|
||||
* Scope (M3): the **top-level** `data` block of the draft spec — the common case
|
||||
* for a single-view chart. Inline data nested inside layers/concats is left for a
|
||||
* later pass; `hasInlineData` reflects exactly what `confirm` can lift, so the
|
||||
* editor only offers the action when this store can act on it.
|
||||
* Two shapes of embedded data, captured as a `target` (spec §03F; multi-view scope
|
||||
* doc M3):
|
||||
* - **inline** — a view's `data.values`. Confirm rewrites that view's `data`
|
||||
* 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 type { DataFormat } from '@core/format-detection';
|
||||
import { createDataset } from '@core/dataset';
|
||||
import { formatSpec } from '@core/json-format';
|
||||
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 { notify } from './NotificationStore';
|
||||
import { useSnippetStore } from './SnippetStore';
|
||||
|
||||
/** The inline `data` block of a parsed spec, if it carries `values`. */
|
||||
interface InlineData {
|
||||
values: unknown;
|
||||
format: DataFormat;
|
||||
}
|
||||
/** The path of the view whose `data` block Extract rewrites (`[]` = root). */
|
||||
type AnchorPath = ReadonlyArray<string | number>;
|
||||
|
||||
/**
|
||||
* Read the top-level inline data from a draft spec's text, or null when there is
|
||||
* none (no snippet, unparseable, or no `data.values`). The format comes from an
|
||||
* explicit `data.format.type` when present (raw CSV/TSV strings), else JSON.
|
||||
*/
|
||||
function readInlineData(draftText: string): InlineData | null {
|
||||
let parsed: unknown;
|
||||
/** What confirm rewrites — an inline `data` block, or a self-defined `datasets` entry. */
|
||||
export type ExtractTarget =
|
||||
| { kind: 'inline'; anchorPath: AnchorPath }
|
||||
| { kind: 'self-defined'; datasetName: string };
|
||||
|
||||
/** True when the active snippet's draft has data Extract can lift, in any view. */
|
||||
export function hasExtractableData(draftText: string): boolean {
|
||||
try {
|
||||
parsed = JSON.parse(draftText);
|
||||
return specHasExtractableData(JSON.parse(draftText));
|
||||
} 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 {
|
||||
/** Proposed dataset name (required, unique). */
|
||||
name: string;
|
||||
/** The inline data captured at open, for the read-only preview. */
|
||||
source: InlineData | null;
|
||||
/** The focused view's embedded data captured at open, for the read-only preview. */
|
||||
source: InlinePayload | null;
|
||||
/** Where `confirm` writes the by-name reference. */
|
||||
target: ExtractTarget | null;
|
||||
/** Inline validation message, or null. */
|
||||
error: string | null;
|
||||
|
||||
/** Capture the active snippet's inline data and reset the form. */
|
||||
init: () => void;
|
||||
/** Seed the form with the focused view's captured data (extract-action). */
|
||||
begin: (captured: { source: InlinePayload; target: ExtractTarget; name?: string }) => void;
|
||||
setName: (name: string) => void;
|
||||
/**
|
||||
* Validate, create the dataset, and rewrite the active draft to reference it by
|
||||
* name. Returns whether it committed; on failure `error` is set. `now`
|
||||
* injectable for tests.
|
||||
* Validate, create the dataset, and rewrite the spec to reference it by name.
|
||||
* Returns whether it committed; on failure `error` is set. `now` injectable for
|
||||
* tests.
|
||||
*/
|
||||
confirm: (now?: Date) => boolean;
|
||||
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) => ({
|
||||
...INITIAL,
|
||||
|
||||
init: () => {
|
||||
const draft = useSnippetStore.getState().draftText;
|
||||
set({ name: '', source: readInlineData(draft), error: null });
|
||||
},
|
||||
begin: ({ source, target, name = '' }) => set({ name, source, target, 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.` });
|
||||
return false;
|
||||
}
|
||||
const source = get().source;
|
||||
if (!source) {
|
||||
set({ error: 'No inline data to extract.' });
|
||||
const { source, target } = get();
|
||||
if (!source || !target) {
|
||||
set({ error: 'No data to extract.' });
|
||||
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.
|
||||
const dataset = createDataset({
|
||||
name,
|
||||
@@ -117,20 +140,17 @@ export const useExtractStore = create<ExtractState>((set, get) => ({
|
||||
});
|
||||
useDatasetStore.getState().add(dataset);
|
||||
|
||||
// Rewrite the top-level data block to a by-name reference, preserving the rest
|
||||
// of the spec and its pretty-printed text shape.
|
||||
const draftText = useSnippetStore.getState().draftText;
|
||||
const spec = JSON.parse(draftText) as Record<string, unknown>;
|
||||
spec.data = { name };
|
||||
useSnippetStore.getState().replaceActiveDraft(JSON.stringify(spec, null, 2), now);
|
||||
// Re-serialize in the app's house style (json-format), preserving the spec and
|
||||
// only swapping the captured data for the reference.
|
||||
useSnippetStore.getState().replaceActiveDraft(formatSpec(rewritten), now);
|
||||
|
||||
// Success confirmation (spec §03F). Title states the action; the message
|
||||
// adds the consequence — the spec was rewritten to reference the new dataset
|
||||
// by name (council toast-copy rule, docs/architecture/10 → Toast copy).
|
||||
// Success confirmation (spec §03F). Title states the action; the message adds
|
||||
// the consequence — the spec was rewritten to reference the new dataset by name
|
||||
// (council toast-copy rule, docs/architecture/10 → Toast copy).
|
||||
notify({
|
||||
kind: 'success',
|
||||
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);
|
||||
return true;
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
@@ -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 }),
|
||||
}));
|
||||
@@ -114,7 +114,7 @@ export interface SnippetState {
|
||||
/**
|
||||
* 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
|
||||
* 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.
|
||||
*/
|
||||
replaceActiveDraft: (text: string, now?: Date) => void;
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Shared base64 codecs — the one home for base64 in core (font bytes, spec-link
|
||||
* payloads). `btoa`/`atob` and `TextEncoder`/`TextDecoder` are platform globals
|
||||
* in every runtime we target (browsers, the Node test environment), so core
|
||||
* stays portable without hand-rolled bit twiddling.
|
||||
*
|
||||
* Revisit when `Uint8Array.prototype.toBase64`/`fromBase64` (with the
|
||||
* `base64url` alphabet option) is old enough to assume in arbitrary public
|
||||
* browsers — it deletes this module.
|
||||
*/
|
||||
|
||||
/** Base64-encode raw bytes (32 KB chunks to stay under the argument-spread limit). */
|
||||
export function bytesToBase64(buffer: ArrayBuffer | Uint8Array): string {
|
||||
const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer);
|
||||
let binary = '';
|
||||
const CHUNK = 0x8000;
|
||||
for (let i = 0; i < bytes.length; i += CHUNK) {
|
||||
binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
/** Decode standard base64 to raw bytes. Throws on malformed input (caller guards). */
|
||||
export function base64ToBytes(base64: string): ArrayBuffer {
|
||||
const binary = atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
||||
return bytes.buffer;
|
||||
}
|
||||
|
||||
/** Encode text as unpadded base64url (RFC 4648 §5) — a URL-hash-safe alphabet. */
|
||||
export function textToBase64Url(text: string): string {
|
||||
return bytesToBase64(new TextEncoder().encode(text))
|
||||
.replaceAll('+', '-')
|
||||
.replaceAll('/', '_')
|
||||
.replace(/=+$/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode unpadded base64url back to text. Total: malformed input — characters
|
||||
* outside the alphabet, an impossible length, bytes that aren't valid UTF-8 —
|
||||
* returns `null` rather than throwing.
|
||||
*/
|
||||
export function base64UrlToText(payload: string): string | null {
|
||||
// A base64 stream never leaves exactly 6 leftover bits (length ≡ 1 mod 4).
|
||||
if (payload.length === 0 || payload.length % 4 === 1) return null;
|
||||
const b64 = payload.replaceAll('-', '+').replaceAll('_', '/');
|
||||
try {
|
||||
const bytes = base64ToBytes(b64 + '='.repeat((4 - (b64.length % 4)) % 4));
|
||||
return new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -96,6 +96,47 @@ export const CHART_EXAMPLES: ReadonlyArray<ChartExample> = [
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'brush',
|
||||
name: 'Brushed scatter',
|
||||
description: 'Drag a rectangle — points outside the brush fade.',
|
||||
// Interactivity showpiece. Single-view on purpose: the gallery sizes every
|
||||
// thumbnail with a top-level `width`/`height`, which composed (concat/layer)
|
||||
// specs don't accept — a linked-views example needs a bespoke host.
|
||||
spec: {
|
||||
$schema: VEGA_LITE_SCHEMA_URL,
|
||||
description: 'A scatter plot with an interval brush selection.',
|
||||
data: {
|
||||
values: [
|
||||
{ x: 1.1, y: 2.6, group: 'A' },
|
||||
{ x: 1.6, y: 3.1, group: 'A' },
|
||||
{ x: 2.0, y: 2.2, group: 'A' },
|
||||
{ x: 2.4, y: 3.6, group: 'A' },
|
||||
{ x: 2.9, y: 2.9, group: 'A' },
|
||||
{ x: 3.3, y: 4.2, group: 'B' },
|
||||
{ x: 3.8, y: 3.4, group: 'B' },
|
||||
{ x: 4.2, y: 4.8, group: 'B' },
|
||||
{ x: 4.6, y: 3.9, group: 'B' },
|
||||
{ x: 5.1, y: 5.2, group: 'B' },
|
||||
{ x: 5.5, y: 4.4, group: 'C' },
|
||||
{ x: 6.0, y: 5.7, group: 'C' },
|
||||
{ x: 6.4, y: 4.9, group: 'C' },
|
||||
{ x: 6.9, y: 6.1, group: 'C' },
|
||||
{ x: 7.3, y: 5.3, group: 'C' },
|
||||
],
|
||||
},
|
||||
params: [{ name: 'brush', select: 'interval' }],
|
||||
mark: 'point',
|
||||
encoding: {
|
||||
x: { field: 'x', type: 'quantitative' },
|
||||
y: { field: 'y', type: 'quantitative' },
|
||||
color: {
|
||||
condition: { param: 'brush', field: 'group', type: 'nominal' },
|
||||
value: 'lightgray',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'area',
|
||||
name: 'Stacked area',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { validateExpression, referencedFields } from './expr-validate';
|
||||
import { validateExpression, referencedFields, activeCall } from './expr-validate';
|
||||
|
||||
describe('validateExpression', () => {
|
||||
it('accepts a well-formed Vega expression', () => {
|
||||
@@ -37,3 +37,35 @@ describe('referencedFields', () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -85,6 +85,63 @@ export function referencedFields(expr: string): string[] {
|
||||
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
|
||||
* `null` when the node isn't such an access (a different object, computed-dynamic
|
||||
|
||||
+2
-19
@@ -20,6 +20,8 @@
|
||||
* and the SVG embed treat every FontAsset the same regardless of source.
|
||||
*/
|
||||
|
||||
import { base64ToBytes, bytesToBase64 } from './base64';
|
||||
|
||||
/** A FontAsset record's schema version (read-time migration target). */
|
||||
export const CURRENT_FONT_VERSION = 1;
|
||||
|
||||
@@ -189,25 +191,6 @@ const FONT_MIME: Record<FontFormat, string> = {
|
||||
otf: 'font/otf',
|
||||
};
|
||||
|
||||
/** Base64-encode raw font bytes (32 KB chunks to stay under the spread limit). */
|
||||
function bytesToBase64(buffer: ArrayBuffer): string {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
let binary = '';
|
||||
const CHUNK = 0x8000;
|
||||
for (let i = 0; i < bytes.length; i += CHUNK) {
|
||||
binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
/** Decode base64 back to raw font bytes. Throws on malformed input (caller guards). */
|
||||
function base64ToBytes(base64: string): ArrayBuffer {
|
||||
const binary = atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
||||
return bytes.buffer;
|
||||
}
|
||||
|
||||
/** A `data:` URL embedding a face's bytes — the `src` for an `@font-face` rule. */
|
||||
export function fontDataUri(asset: FontAsset): string {
|
||||
return `data:${FONT_MIME[asset.format]};base64,${bytesToBase64(asset.data)}`;
|
||||
|
||||
@@ -0,0 +1,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
Reference in New Issue
Block a user