Compare commits

..

35 Commits

Author SHA1 Message Date
oleh 499e0b265d feat: add About page with usage instructions and privacy information 2025-01-28 18:40:53 +02:00
oleh 10896146cf feat: integrate Monaco editor for enhanced comment editing in modal 2025-01-25 21:22:37 +02:00
oleh dd56d4adc2 feat: add search functionality for snippets with input field in UI 2025-01-25 20:05:51 +02:00
oleh fa08f2f8b9 feat: improve comment modal UI and streamline event listener setup in UIManager 2025-01-25 19:59:37 +02:00
oleh dbb02ba4e1 feat: enhance comment button visibility and interaction in UIManager 2025-01-25 19:51:35 +02:00
oleh e59feebcf1 feat: add creation date to snippets and sort by date in UI 2025-01-25 19:43:30 +02:00
oleh d847de361c feat: add comment functionality for snippets with modal interface 2025-01-25 19:33:23 +02:00
oleh 52543943bc feat: add duplicate snippet functionality in SnippetManager and UI 2025-01-25 19:21:02 +02:00
oleh a04fd74b11 refactor: streamline snippet saving logic and button creation in UIManager 2025-01-25 19:18:31 +02:00
oleh c474f8cdc2 feat: implement snippet import/export functionality in UIManager 2025-01-19 17:47:51 +02:00
oleh 55b60749ee feat: simplify button labels in editor UI for clarity 2025-01-19 17:42:00 +02:00
oleh b8b0a704bd feat: update title in index.html to reflect application branding 2025-01-19 17:07:31 +02:00
oleh e476cb7c44 feat: add Plausible analytics script to index.html 2025-01-19 16:51:02 +02:00
oleh 0022a3e109 Merge branch 'main' of https://github.com/olehomelchenko/astrolabe 2025-01-19 16:50:03 +02:00
oleh c514e2ed8c feat: add JSON schema validation for Vega and Vega-Lite in the editor 2025-01-19 16:49:58 +02:00
Oleh Omelchenko 07e30cde51 Create static.yml 2025-01-19 16:06:38 +02:00
oleh 6905a473ce Merge branch 'main' of https://github.com/olehomelchenko/astrolabe 2025-01-19 16:02:33 +02:00
Oleh Omelchenko 8e40048a0b Initial commit 2025-01-19 15:59:02 +02:00
oleh 711fe89139 refactor: consolidate UI updates and snippet saving into dedicated methods 2025-01-19 15:19:30 +02:00
oleh a8b2fa4be8 feat: add export and import functionality for snippets with UI controls 2025-01-19 15:12:05 +02:00
oleh f14f76e2f0 feat: add application header with logo and favicon for improved branding 2025-01-19 15:05:20 +02:00
oleh 55d20b4d81 feat: add custom font styling for improved UI aesthetics 2025-01-19 14:38:11 +02:00
oleh 49705d7d30 feat: add snippet renaming functionality and enhance UI with edit button 2025-01-19 14:26:12 +02:00
oleh fe9e554cec feat: add snippet deletion functionality and enhance UI for snippet management 2025-01-19 14:23:55 +02:00
oleh 86058767a1 fix: update hasUnsavedChanges logic for draft versions and improve visualization spec handling 2025-01-19 14:12:54 +02:00
oleh 2664b12c12 feat: auto-generate snippet names based on existing snippets for better organization 2025-01-19 04:00:28 +02:00
oleh 811d981ab9 feat: add StorageManager, VisualizationManager, UIManager, EditorManager, and update PanelResizer for improved snippet handling and visualization 2025-01-19 03:46:25 +02:00
oleh 9bb476c135 refactor: remove draft management from SnippetManager and streamline snippet handling 2025-01-19 03:34:52 +02:00
oleh adb575ea00 feat: implement draft versioning and UI controls for snippet management 2025-01-19 02:16:31 +02:00
oleh ad1d5865ab refactor: pass snippetManager to PanelResizer and update visualization on resize 2025-01-19 01:43:21 +02:00
oleh ce3dc2fd3f panels: set default values and fix errors while resizing 2025-01-19 01:28:26 +02:00
oleh 6107c19e04 reorganize scripts 2025-01-19 01:17:25 +02:00
oleh 3015c6e39b move scripts 2025-01-19 01:07:25 +02:00
oleh 7acda7d10a move styles 2025-01-19 01:05:57 +02:00
oleh 54a25cdd1f init 2025-01-19 00:45:12 +02:00
402 changed files with 1453 additions and 81259 deletions
-12
View File
@@ -1,12 +0,0 @@
{
"permissions": {
"allow": [
"Bash(npm run typecheck)",
"Bash(npm run typecheck *)",
"Bash(npm test)",
"Bash(npm test *)",
"Bash(npm run lint)",
"Bash(npm run lint *)"
]
}
}
-311
View File
@@ -1,311 +0,0 @@
---
name: alignment
description: Review staged or uncommitted code to ensure quality, test coverage, and alignment with project specifications
disable-model-invocation: true
---
# Code Alignment
Review staged or uncommitted code to ensure quality, test coverage, and alignment with the
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 #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.
## Scope
Determine the review scope using `git diff` (unstaged) and `git diff --staged` (staged).
Review all changes in scope. If changes span multiple patterns below, apply all relevant sections.
## General Instructions
### Process
1. **Git**: **NEVER** stage (`git add`) or commit (`git commit`) — that is the USER's
responsibility. If the reviewed changes span multiple independent concerns (a feature + an
unrelated fix, a refactor + a new capability), suggest splitting them into separate commits
and mention the logical boundaries.
2. **Verification**: After changes, run `npm run typecheck` and `npm test`; run `npm run build`
if the change could affect the build. If tests fail, fix the issue if straightforward; ask
the user only if non-trivial or ambiguous.
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 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
4. **Code Cleanup**: Remove leftover code, unnecessary defensive programming, and
over-engineering from iterative development — dead code, try/catch around internal calls
that can't throw, abstraction layers wrapping a single implementation. Proceed with caution;
ask if unsure.
- **Export hygiene**: a symbol is exported only if another module imports it. Symbols used
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
fix whitespace/formatting (trailing newlines etc.) — Prettier owns that.
- **Control primitives & the two-height scale** (arch 09 §4): action buttons are the
`Button` component, icon-only buttons are `IconButton` — never a freshly styled
`<button>`. Interactive controls are `var(--control-height)` (32px) or
`var(--control-height-lg)` (40px); a hardcoded control height (28px, 36px, …) in a
diff is a finding. **The field look has exactly one home** — the element baseline
in `styles/base.css` (fill `var(--field)` + bottom border `--border-strong`, no
box; arch 09 §4): a `border:` or `background:` on an input/textarea in a component
module is a finding (module classes add only width/padding/font-size); the sole
sanctioned restatements are the select-like triggers (SelectControl, SortControl).
A surface that elevates to `--layer-01` sets `--field: var(--field-02)` /
`--field-hover: var(--field-hover-02)` on its container, mirroring
`--control-hover-fill`. Call-site classes composed onto a primitive may only do layout
(flex, margins, reveal) or a documented state accent (outlined-danger, pressed) —
restyling the primitive's box from a call site is a finding. Borders mark function:
full `--border-strong` boxes are reserved for segmented controls, secondary
buttons, drop targets (dashed), and the color-swatch input; fields and triggers
are underlined, not boxed;
passive chrome (tags, badges, glyphs) takes `--border`; plain actions are ghost or
filled; list rows are flat with dividers, not stacked boxes. A shared look travels
through one of exactly four mechanisms — design tokens, contextual custom
properties set by surfaces, `base.css` element baselines, React primitives
(Button/IconButton); introducing a fifth (CSS-module `composes`, utility classes,
a mixin layer) is a finding. When a recipe migrates to a shared baseline, grep
for every selector that restated any of its fragments (focus, placeholder,
border) — a partially deleted restatement is worse than an undeleted one,
because its higher specificity silently overrides the baseline.
6. **Code Comments**: Comments should not duplicate what the code already says. Remove
parroting comments. Ensure comments capture non-obvious _why_ — design decisions,
constraints, gotchas. Flag missing comments where a reader would reasonably ask "why is this
done this way?" Write them as **matter-of-fact prose** — state what the code _is_ and the
standing _why_, not the story of how this session arrived at it. Rewrite session-decision
narration ("this bit us", "we decided", "supersedes the earlier plan", "used to do X") and
directives-to-future-self ("keep the escape") into a standing property of the code; keep the
technical fact, drop the resolution framing. A deliberate simplification with a known
ceiling — a naive scan that's fine at current sizes, a coarse heuristic, a
correct-but-unscalable default — gets a comment naming the ceiling and the upgrade path
(`// linear scan; index if the library grows large`), so it reads as a chosen shortcut,
not a missed one; unmarked, it invites a later reviewer to either "fix" it back into
complexity or flag the absent rationale.
7. **Workarounds**: Flag code that works around a problem rather than solving it (`// HACK`,
silent catch-and-ignore, feature detection for internal bugs). A justified workaround
(upstream bug, browser quirk) needs a comment explaining why and a tracking reference; an
unjustified one should be replaced with a proper fix.
8. **Pre-existing & out-of-scope issues — leave a breadcrumb.** For anything you notice but
don't fix (pre-existing patterns the new code follows; observations the change exposes but
that are out of scope), mark it with a `// TODO:` at the relevant code site explaining
_what_ could be improved and _why_ (13 lines), as matter-of-fact prose (rule #6 — no
session narration). **If an observation is important enough to mention in the summary, it is
important enough to deserve a `// TODO:` at the code location** — otherwise the next reader
has no way to recover the context.
### Architecture & Project-Specific Checks
9. **Portable core boundary**: `src/core/` must stay pure — no browser APIs (`window`,
`document`, `indexedDB`, `localStorage`), no React, no Monaco, no `vega-embed`. Flag any such
import. Pure spec logic (detection, profiling, reference resolution, fit transforms,
validation, import normalization) belongs in `src/core/` and must be unit-tested. See
`docs/architecture/00-overview.md` for the layering.
10. **Infrastructure-adapter boundary**: Only `src/app/infrastructure/` touches `indexedDB`,
`localStorage`, or `window.location`. Flag direct access elsewhere — route it through an
adapter (`docs/architecture/02-persistence.md`, `04-routing-and-events.md`).
11. **Rendering safety** (`docs/architecture/05-rendering-theming-preview.md`): the
reference-resolution/fit-mode transform must run on a **copy** of the spec — never mutate the
stored spec; a previous `vega-embed` view must be `.finalize()`d before re-render (no leaks);
user-derived field names must be escaped before going into `field:`; an invalid/unrenderable
spec must fail safe (readable error, no crash), and a blank spec renders nothing.
12. **Persistence safety**: records that may need migration carry a `version` field; reads
apply migrations; destructive actions (delete, revert, reset) confirm; storage failures
warn rather than silently lose data. All IndexedDB writes go through `db.put` — a
hand-rolled `tx(…, 'readwrite')` or raw `objectStore.put`/`delete` outside `db.ts` bypasses
the quota normalization that mints `StorageQuotaError`, silently losing the fail-loud signal
(arch 02). A new per-record entity tier's write-through subscriber calls the shared
`wireEntityWriteThrough` helper (`orchestration/entity-persistence.ts`) instead of
re-implementing the prev/next diff loop.
13. **Documentation hygiene** (the docs are maintained artifacts; keep them at altitude): - **Self-containment**: documentation and comments must not add pointers that require an
external repository to follow. Knowledge gets captured locally (`docs/spec/`,
`docs/architecture/`), not linked out. - **Matter-of-fact, as rule #6 demands of comments**: docs state _what the design is_ +
the standing _why_, never the build narration (`this bit us`, `we chose X over Y`,
`resolves the former divergence`, `council resolution recorded`). A decision is recorded
by stating its resulting rule, not the story of reaching it. - **No stale-prone constructs** — three things rot the moment the code moves: - **TS code blocks that copy current implementation.** A snippet mirroring a real
module goes stale on its next rename. An illustrative _shape_ sketch is fine; a copy
of specific current code is a finding — replace it with a navigation map (`file →
role`) or the rule it demonstrates. - **Positional sub-section cross-refs.** Cite a doc by a stable identifier — the file
(`spec §07`, `arch 02`) or a named section / quoted rule — never a positional
sub-section number (`arch 07 §4`) that renumbers when a section is inserted above it.
Applies to code comments too. - **Volatile exact counts.** Test counts, file/LOC counts, and one-off timings churn
daily and read as stale within a week. State the qualitative fact ("seconds of layout
on a large dataset"), not the measured number. The only homes for exact figures are
`docs/exploration/` records (frozen by definition) and `docs/codebase-metrics.md` (the
deliberately-tracked trend).
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. **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
**role** via the shared predicates (`isMeasureMapping`, `isReorderableCategory`) — never
test `effectiveType(m) === 'quantitative'` directly for measure-ness. `bin` makes a field a
discretized _dimension_ (mirrors Vega-Lite's `isDiscrete`); `aggregate` makes it a _measure_.
Reasoning over raw type is what made a histogram trip the two-measures→scatter nudge
(eng-council 2026-06-13; arch 10 §5). A new taste-heuristic warning should also be
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
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.
---
## Pattern A: New Functionality
### Testing
- Unit tests for new `src/core/` logic (test the core hardest).
- Lighter component/interaction tests for new UI.
- Tests pass before proceeding.
### Documentation
Update relevant docs if the feature is significant:
- **`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.
### Dependencies
If `package.json` changed:
- Flag each new dependency; explain what it does and why it's needed.
- Every package imported directly in `src/` must be declared in `dependencies` — never rely
on a transitive install (it can vanish or drift on any lockfile churn). Declaring a package
the bundle already carries adds no weight.
- Could a small custom implementation avoid it? Note the trade-off.
- Prefer dependencies that solve genuinely hard problems (parsing, rendering) over those that
save boilerplate.
### Alignment Check
- **SOUL.md** — philosophy (must not violate without good reason).
- **`docs/spec/`** — behavioral record.
- **`docs/architecture/`** — the relevant pattern doc.
---
## Pattern B: Bug Fixes
### Testing
- Add a regression test that reproduces the bug and verifies the fix.
- Interaction test if the bug affected UI behavior.
### Documentation
Usually not required unless the bug revealed incorrect docs, or the fix changes documented
(spec) behavior.
### Alignment Check
- **SOUL.md** philosophy; **`docs/spec/`** behavioral record; **`docs/architecture/`** patterns.
---
## Pattern C: Refactoring
### Impact Analysis
1. **Search for usages** of modified functions/types across the codebase (Grep).
2. **Identify call sites** (components, stores, services, infrastructure, tests).
3. **Check exports** used by other modules.
4. **Review dependencies** — what the code depends on and what depends on it.
### Testing
- Update existing tests to the new structure; verify all call sites.
- Run `npm test` and `npm run typecheck`.
### Documentation
Update `docs/architecture/` if a pattern, module responsibility, or navigation map changed.
Update JSDoc/inline comments if signatures or behavior changed.
### Alignment Check
- **SOUL.md** (simplicity, no parallel systems); **`docs/architecture/`** (consistent with the
documented patterns); **`docs/spec/`** (behavior unchanged unless intended).
### Common Refactoring Checks
- Function signatures → all call sites updated.
- Type definitions → search type usages.
- Imports → correct after file moves.
- Stores → all consumers verified.
- Component props → all usages checked.
- Constants/enums → all references updated.
---
## 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 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 |
-92
View File
@@ -1,92 +0,0 @@
---
name: council
description: Consult the design council — the external interaction, content, and accessibility canon (IBM Carbon, GOV.UK Design System, WAI-ARIA APG, Nielsen Norman) — before committing a user-facing design decision. Auto-invoke when writing user-facing error / empty-state / notification copy, or when designing the keyboard / focus / ARIA behavior of an interactive widget (modal, menu, listbox, toast, splitter, disclosure). Also invokable on demand for any interaction, content, accessibility, or usability decision.
disable-model-invocation: false
---
# Design Council
A standing panel of external design authorities, consulted the way we already consult
Carbon: **local corpora, grepped — not WebFetched.** The council **advises**; Astrolabe's
own contract **decides**. When a source conflicts with `SOUL.md`,
`docs/architecture/09-visual-design.md` (visual contract), or
`docs/architecture/10-interaction-and-feedback.md` (interaction contract), **our contract
wins** — note the divergence and move on.
## Scope: a steady-state mechanism, with a one-off debt to clear
The council's standing job is **pointwise** — consult on the decision in front of you (the
routing table below), at authoring time. New user-facing work is checked as it's written,
so it never accrues the drift the council exists to prevent. This is the default, and what
the auto-triggers fire.
The **backfill** is _not_ a second mode — it's tech debt with an end. Code written _before_
the council existed was never checked against the roster; reconciling it is a one-off sweep
(every pre-council widget vs. APG, every user-facing string vs. GOV.UK/Carbon content
rules, every data surface vs. the loading/empty/error triad), emitting fixes + a gap list
into `docs/architecture/10`. Done once, **pointwise keeps it honest — there is no recurring
audit.** The only thing that reopens it is an **event, never a schedule**: seating a new
member (its principles have never touched existing code) or a source's breaking revision —
see _Adding a seat_. A light glance at a milestone boundary is a fair safety net for what
the heuristic auto-trigger missed, but that's the same one-off sweep, not a standing mode.
## How to convene (the routing rule)
Pull the **minimal** relevant member(s) for the decision at hand — do **not** sweep all
four; that wastes tokens and dilutes the answer. Map the decision to its seat(s):
| Decision in front of you | Primary seat → then |
| ---------------------------------------------------------------- | ------------------------------------------------------ |
| Error / failure / empty-state / notification **copy** | **GOV.UK** → Carbon notifications → NN/g heuristic #9 |
| Keyboard / focus / ARIA roles of an **interactive widget** | **WAI-ARIA APG** → Carbon component |
| **Latency / feedback / loading / progress** budgets | **NN/g** response-time limits → Carbon loading pattern |
| **Forms / validation / destructive-action** flow | **GOV.UK** → Carbon |
| General **usability** gut-check on a flow | **NN/g** 10 heuristics |
| **Visual** styling (type, spacing, colour, component look) | **Carbon** + our `docs/architecture/09` |
| **Which chart** for the data/intent (chart-type choice) | **FT Visual Vocabulary** + **Datawrapper** |
| **PWA / offline / install / SW-update / storage-persistence** UX | **web.dev** (+ vite-plugin-pwa / Workbox for the API) |
Then: read the cited file(s), extract the **specific** principle, and report it back with
a **citation (member + file path)** and a one-line "how it lands in Astrolabe." Don't
paraphrase the whole source — quote the rule that decides the question.
## The roster
All paths are under `/Users/oleh/code/reference/`. Treat clones as **inspiration, not
law** — they drift; the published guidance is the truth, the clone is the fast index.
| Member | Path | Authoritative for | How to query |
| ------------------------------ | ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **IBM Carbon** | `carbon-website/src/pages/` | Notification taxonomy, status levels, empty/loading states, content basics, data-viz styling | grep `.mdx` under `components/notification`, `patterns/{empty-states,loading,status-indicator}-pattern`, `guidelines/content` |
| **GOV.UK Design System** | `govuk-design-system/src/` | Error & validation messages, failure pages, forms, plain-language content, accessibility | `index.md` under `components/{error-message,error-summary,notification-banner}`, `patterns/{problem-with-the-service-pages,service-unavailable-pages,check-answers}`, `accessibility/` |
| **WAI-ARIA APG** | `aria-practices/content/patterns/` | Keyboard interaction, focus management, ARIA roles/states for widgets | `<pattern>/<pattern>-pattern.html` — e.g. `dialog-modal`, `alertdialog`, `alert`, `listbox`, `menu-button`, `disclosure`, `switch`, `tabs`, `tooltip`, `windowsplitter` |
| **Nielsen Norman (distilled)** | `principles/nielsen-norman.md` | 10 usability heuristics; response-time / feedback budgets (0.1s / 1s / 10s) | read directly — it is short and curated |
| **FT Visual Vocabulary** | `chart-doctor/visual-vocabulary/` | Chart choice: data-relationship taxonomy (Magnitude, Correlation, Change-over-Time, Ranking, Distribution, Deviation, Part-to-whole, Spatial, Flow) → chart type | read `README.md` — the taxonomy is prose; each category gives a "use when…" definition + recommended chart types |
| **Datawrapper (distilled)** | `principles/datawrapper.md` | Chart choice in plain language; practical rules of thumb (bar-is-safe-default, line-vs-column, circles hard to compare, size = quantity) | read directly — short and curated; pairs with the FT clone |
| **web.dev (distilled)** | `principles/web-dev.md` | PWA/offline: service-worker update flow (`registerType: 'prompt'`), persistent storage (`navigator.storage.persist()`), quota/`estimate()`, installability — with the exact vite-plugin-pwa/Workbox API we use | read directly — short and curated; cites local `web-dev/`, `vite-plugin-pwa/`, `workbox/` clones |
## Close the loop
The council exists so we **don't re-derive the same decision twice**. When a consultation
settles a _recurring_ question (not a one-off), capture the resolution into our own
contract — `docs/architecture/10-interaction-and-feedback.md` for interaction/feedback,
`09-visual-design.md` for visual — via the `/doc-update` skill. Upstream canon informs;
the downstream contract records. A code site that embeds such a rule should cite our
contract, not the external source.
## Adding a seat
1. If it's an open-source repo, shallow-clone it into `/Users/oleh/code/reference/`
(`git clone --depth 1 …`). If it's articles/blogs (not cloneable), **distill** the
stable parts into `reference/principles/<source>.md` with attribution + links, like
the NN/g note.
2. Add one row to **the roster** and one routing entry above.
3. Keep it lean — a seat earns its place only if it's authoritative for a decision the
others don't cover well.
4. **Backfill once.** Reconcile the existing codebase against the new seat — its principles
have never been applied before. This is the one event that reopens the one-off sweep
(see _Scope_); after it, pointwise maintains the new seat like the rest. If the seat's
domain is a **brand-new surface** with no pre-existing code to reconcile, the backfill is
discharged by building that surface against the canon — no separate sweep.
Candidate future seats (not yet seated): **Shopify Polaris** (UX-writing depth).
-123
View File
@@ -1,123 +0,0 @@
---
name: doc-update
description: Update project documentation based on knowledge gaps discovered during the current session
disable-model-invocation: false
---
# Documentation Update from Session Context
Review the current session to identify knowledge gaps that caused suboptimal codebase
navigation, then update the relevant documentation.
## The quality bar
Every addition must pass this test: **"Would this save a future session at least 5 minutes
of exploration?"**
Documentation serves two purposes — know **where to look** and know **what to do**. Both
are valuable, but at different levels of detail:
- **Navigation map** (good): "Preview flow: `LivePreview.tsx``prepareSpecForRender()` (core) → `vega-embed`" — lists the files and their roles so you don't read a dozen files to find the right four.
- **Decision rule** (good): "The fit-mode/reference-resolution transform runs on a _copy_ of the spec — never mutate the stored spec" — captures a non-obvious convention.
- **Code walkthrough** (bad): "SnippetStore.updateDraft sets draftSpec, which a startup subscriber watches, debounces, then calls snippetStore.put… " — restates the code, goes stale on any rename.
**Navigation maps** use file/module names (stable) to show flow direction. **Decision
rules** capture "when/why" constraints. **Code walkthroughs** restate implementation
details — that's what reading the code is for.
For documentation organization, see **[CLAUDE.md](../../../CLAUDE.md)** and the doc index in
**[AGENTS.md](../../../AGENTS.md)**.
## The three documentation layers (know which one a gap belongs to)
- **`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.
- **`docs/IMPLEMENTATION-PLAN.md`** — the _when_: milestone sequence and scope.
## Process
### 1. Analyze the session
Look back through the conversation and identify:
- **Missing navigation maps**: Where did you read many files to discover which 34 files a
flow actually involves? A one-line map of file roles would have saved that.
- **Missing rules**: What conventions or constraints were discovered that a new session
would violate or re-discover?
- **Non-obvious "when/why" knowledge**: What decisions require understanding intent, not
just implementation?
Produce a brief list of gaps before proceeding. For each, state what's needed in one
sentence — a navigation map ("X flow: file → file → file") or a decision rule ("X must/must
not do Y"). If you can't state it concisely, it may be too implementation-specific to document.
### 2. Categorize and target
Map each gap to the right document:
| Gap type | Target document |
| ------------------------------------------------- | --------------------------------------------------------------------------------- |
| Product behavior, capabilities, acceptance points | `docs/spec/` (the relevant 0010 section) — **record; keep it matching the code** |
| State / Zustand stores | `docs/architecture/01-state-and-stores.md` |
| Persistence, IndexedDB, localStorage, migrations | `docs/architecture/02-persistence.md` |
| Modals, dialog lifecycle | `docs/architecture/03-modal-system.md` |
| URL routing, keyboard/events | `docs/architecture/04-routing-and-events.md` |
| Rendering, theming, vega-embed, preview | `docs/architecture/05-rendering-theming-preview.md` |
| Type inference, dataset profiling | `docs/architecture/06-type-inference.md` |
| Names, snippet↔dataset links, rename propagation | `docs/architecture/07-naming-and-relationships.md` |
| Milestone scope, build order | `docs/IMPLEMENTATION-PLAN.md` |
| Project philosophy / identity | `SOUL.md` |
| Onboarding, conventions, stack | `AGENTS.md` / `CLAUDE.md` |
If a gap fits no existing document, consider a new section in the closest one; prefer
extending over creating. A brand-new architecture topic can become `docs/architecture/08-*.md`
(add it to `docs/architecture/00-overview.md`).
### 3. Read, locate, and check for bloat
For each target document:
- Confirm the gap isn't already covered (if partially covered, extend rather than duplicate).
- Find the right insertion point.
- **Check section length**: if a section is already long (>50 lines), tighten or consolidate
before adding. Documentation that only grows becomes noise.
### 4. Apply updates
- **Rules and constraints over descriptions**: "X must do Y because Z" beats "X works by A, B, C".
- **Matter-of-fact voice**: write what the design _is_ and the standing _why_, not the story
of how this session arrived at it. No decision-log narration ("this bit us", "we decided",
"supersedes the earlier plan", "used to do X"), no directives-to-future-self ("keep the
escape"). Phrase rationale as a standing property of the system, not a resolution reached.
- **Stability over specifics**: no line numbers, no file counts, no volatile details.
- **Proportional**: a missing sentence doesn't need a new section; a missing concept does.
- **Match existing style**: follow surrounding formatting, heading levels, tone.
- **Self-contained**: never add a pointer that requires an external repository to follow.
- **Consolidate while adding**: net size increase should be minimal.
### 5. Update index (if needed)
Only update `CLAUDE.md` (or `docs/architecture/00-overview.md`) if a new document was created
or a major new section was added that should be discoverable. Not for minor additions.
## What NOT to document
- **Code walkthroughs**: prose that restates the code; goes stale on any rename. (Navigation
maps that list file roles are fine.)
- **TS code blocks that copy current implementation**: a snippet mirroring a real module
rots on its next rename. An illustrative _shape_ sketch is fine; for real code, point to
the file (navigation map) or state the rule it demonstrates.
- **Stale-prone references**: positional sub-section cross-refs (`arch 07 §4` renumbers when
a section is inserted — cite the file `arch 07` or a named section) and exact counts
(test/file/LOC counts, one-off timings — state the qualitative fact; `codebase-metrics.md`
owns the tracked numbers).
- **Obvious-from-code patterns**: if reading the file makes it clear, don't add docs.
- **Session-specific context**: current task details, debugging steps taken.
- **Speculative patterns**: only document conventions confirmed across multiple instances.
- **Implementation details that change with refactoring**: if renaming a variable would
invalidate the doc, it's too specific.
-168
View File
@@ -1,168 +0,0 @@
---
name: eng-council
description: Convene the engineering council — an evidence-grounded review of codebase structure, consistency, layering altitude, subtraction (what should be deleted), and documentation altitude. Modes - whole-codebase sweep (milestone boundary or backfill), refactor review, new-functionality review, and a pre-build consult. Auto-invoke only the consult mode - before introducing a new module/store/modal/service/hook shape, ask the council whether it needs to exist and the laziest rung that meets the need (stdlib/native/already-shipped dep before new code), then the canonical shape and what already exists to reuse. All other modes run on demand.
disable-model-invocation: false
---
# Engineering Council
The structural counterpart to `/council`. The design council guards what the user sees;
the engineering council guards the shape of the codebase — structure, consistency,
altitude, documentation, and above all **net growth**. `/alignment` reviews a diff in
isolation and can't see cross-cutting drift: parallel patterns forming, near-duplicate
helpers, modules outgrowing their responsibility, docs accreting a move-by-move log. This
skill exists to see that whole picture.
**The lens is wider than the diff — on purpose.** `/alignment` stays inside the change and
parks anything else as a `// TODO:`. The engineering council is the opposite: it reviews
the change _from altitude_, and is expected to lift its eyes to what surrounds it and
record improvement opportunities it notices outside the reviewed lines. A review that finds
nothing beyond its own diff has stayed too close to the ground. Those observations are
**noted, not fixed** (see Beyond-scope notes in the report) — surfacing them is the value;
the user triages.
**Seats are evidence lenses, not personas.** The design council works because every seat
quotes a real corpus. There is no external canon for software structure, so a seat here is
defined by _what it measures and lines up_ — different seats run different queries over
the codebase. A seat that produces an opinion without evidence (file:line, tool output, or
a side-by-side of instances) has not spoken.
## The deletion rules (output format, non-negotiable)
LLMs accrete code; aspirational "prefer removal" instructions wash out. So the bias is
encoded in the report format instead:
1. **Every finding states its estimated net LOC delta** (e.g. `120`, `+15`). The report
ends with the total if all findings were accepted.
2. **"Remove" is a mandatory report section.** It may be empty, but emptiness must be
argued ("knip clean, no single-implementation abstractions found"), not skipped.
3. **Any proposal that adds an abstraction must name ≥ 2 existing call sites** that would
use it _today_. No speculative generality.
4. **A refactor that adds a new way to do something already done elsewhere is a finding,
not progress** — even if the new way is better, until the old instances are migrated
and the old way deleted.
## The seats
Run **sequentially, in this order, in this session** — no subagent fan-out. Later seats
consume earlier findings (Subtraction needs Consistency's verdict on which variant is
canonical before deciding which duplicate dies; Documentation runs last so it can check the
docs against what the other seats actually found — a doc describing a shape Consistency says
no longer exists is stale).
| Seat | Question | Evidence it gathers |
| ----------------- | ----------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Structure** | Is the dependency picture still the documented one? | `npx madge --circular --extensions ts,tsx src` (cycles); `npx madge --json src` for fan-in/fan-out outliers; grep for layer leaks (`docs/architecture/00-overview.md` defines the layers — core imports nothing from app; only infrastructure touches browser APIs). Oversized modules: files ≫ their peers' size doing > 1 job. |
| **Consistency** | How many shapes does each _kind_ of thing have? | Line up all instances of a kind (modals, stores, services, hooks, confirm flows, persistence subscribers) side by side. Count distinct shapes; name the canonical one; list divergers. Divergence is only visible in the line-up — never judge an instance alone. |
| **Altitude** | Is logic at the layer where it's cheapest to test? | Testable logic stuck in components that belongs in `src/core/` or stores (the testing philosophy in `AGENTS.md` is the canon: core hardest, components lightest). Spec operations outside core. Duplicated derivations that should be a store selector. |
| **Subtraction** | What can be deleted? | `npx knip` (dead exports, unused files/deps); `npx jscpd src --min-tokens 50` (duplication); grep for abstractions with a single implementation, re-implementations of an existing utility, props/options/branches no caller exercises, spec'd-then-abandoned remnants. Consumes all prior seats' findings. |
| **Documentation** | Do the docs hold a structured general picture, or accrete a move-by-move log? | Doc sizes & growth (`wc -l docs/architecture/*.md`; the `codebase-metrics.md` trend). Grep `docs/` for decision-log narration (`this bit us`, `we chose`/`we decided`, `supersed`, `resolves the former`, `previously`, `council resolution recorded`, one-off measurements) and for per-instance worked examples that re-illustrate a principle already stated generally or restate code. Embedded TS blocks that copy current code (vs. illustrative shape-sketches). Sections > 50 lines (doc-update's own bar). Stale-prone refs: line/file/test counts, positional sub-section cross-refs (`arch 07 §4`) that renumber, one-off timings. **Docs-vs-code drift:** a doc describing a shape the other seats found no longer exists. The bar (from `/doc-update`): docs state _what the design is_ + the standing _why_ at a stable altitude — not how the build arrived there. |
Tool notes: `madge`, `knip`, `jscpd` are **not** project dependencies — run via `npx`,
treat output as evidence, not verdict (knip false-positives on entry points and dynamic
imports; verify a symbol is truly dead before deleting). If a tool fails or is
unavailable, the seat still sits — grep is the fallback evidence.
## Modes
Pick the mode from what's in front of you; say which mode is running.
The two review modes are also executed by a **clean-context subagent** at session
wrap-up, after `/doc-update` and the alignment pass (see CLAUDE.md → Session wrap-up
protocol). When running as that subagent: you deliberately have no session context —
judge the diff against the codebase's own population of shapes, and return the report as
your final message so the session agent can relay and arbitrate it. A deliberate-looking
divergence whose rationale is recorded nowhere is itself a finding.
### Sweep — whole codebase
For: milestone boundaries, the one-off backfill, "how healthy are we?" on demand.
**Never on a schedule.** Scope: all of `src/` (+ `styles/` for the Structure seat, `docs/`
for the Documentation seat). All five seats. Output: the full report (format below) **plus a
metrics snapshot row**
appended to `docs/codebase-metrics.md` (create on first sweep):
```
| date | src files | src LOC | core LOC | app LOC | deps | knip dead exports | jscpd dup % |
```
The trend line is the point — accretion becomes visible instead of felt.
### Refactor review
For: a landed or in-progress refactor (`git diff` / `git diff --staged`). Scope: changed
files **plus their one-hop import neighborhood** (importers and imports) — drift is only
visible relative to neighbors. Seats: Consistency and Subtraction always; Structure if
imports moved; Documentation if the change touched a doc or makes one stale. Center
question: did this refactor _reduce_ the number of shapes, or add variant N+1? (Deletion
rule 4 applies with full force.)
### New-functionality review
For: a new feature in the diff. Scope: changed files + one-hop neighborhood. Seats:
Consistency (does it follow the canonical shape for its kind?), Altitude (is the logic in
core/stores, not the component?), Subtraction (does it re-implement an existing utility?
does every prop/option/branch have a caller?), Structure if a new module or dependency
appeared (a new dependency needs the same justification `/alignment` demands), Documentation
if the feature added or should have added docs (and whether what it added stays at altitude,
not a move-by-move log).
### Consult — before writing code
The cheapest and most preventive mode; the only one that auto-fires. Two questions, in
order, before a line is written.
**Does it need to exist — and at what rung?** Stop at the first rung that holds: (1) the
need is speculative → say so and skip it (YAGNI); (2) stdlib or a language built-in does
it; (3) a native platform feature covers it — `<input type="date">` over a picker lib, CSS
over JS, an IndexedDB/DB constraint over app code; (4) an already-shipped dependency solves
it — Monaco, vega/vega-lite, Zustand are already in the bundle, so never add a dependency,
or hand-roll, for what one of them or a few lines covers (a new dependency owes the
justification `/alignment` demands). Only past the rungs is fresh structure earned. Lazy is
less code, not a flimsier algorithm: between two equal-size options take the one correct on
edge cases, and never simplify away input validation at trust boundaries, error handling
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). 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.
## Report format
For sweep and review modes:
1. **Verdict** — two or three sentences: overall shape, the one thing to act on first.
2. **Findings** — per seat (Structure, Consistency, Altitude, Subtraction, Documentation),
each finding: `file:line`, the evidence, the proposed fix, net LOC delta. Skip seats
with nothing to say in one line, not zero.
3. **Beyond-scope notes** — improvement opportunities the review noticed _outside_ the
reviewed change: a shape worth revisiting elsewhere, a doc drifting, an abstraction
forming across files the diff didn't touch. Noted with `file:line` and a one-line why,
**not fixed here** — the user triages. Expected to be non-empty in a healthy review; an
empty one means either a pristine codebase or a council that stayed too close to the diff.
4. **Remove** — the mandatory section (deletion rule 2).
5. **Net delta** — total LOC delta if all findings were accepted.
6. **Rules discovered** — recurring patterns worth making law (see Close the loop).
**Fix vs. propose:** mechanical, behavior-preserving removals (dead exports, unused files,
unreferenced props) — fix directly, keep `npm run typecheck` + `npm test` green. Trimming
decision-log narration from a doc back to the matter-of-fact bar is the documentation
equivalent of a behavior-preserving removal — fix directly. Structural findings (merge two
shapes, move logic across layers, kill an abstraction, restructure or consolidate a doc) —
propose with the evidence; they're the user's call. An in-scope out-of-place observation
gets a `// TODO:` breadcrumb at the code site (same rule as `/alignment`); a beyond-scope
one goes in the Beyond-scope notes section so it surfaces rather than scattering as TODOs.
## Close the loop
A council that re-finds the same drift every sweep has failed. When a finding reveals a
_recurring_ rule (not a one-off): capture it into the relevant `docs/architecture/` doc
via `/doc-update`, and if it's checkable in a diff, **add it as a numbered check to
`/alignment`** so pointwise review enforces it from then on. The council discovers
systemic rules; `/alignment` keeps them honest. Sweeps should get quieter over time —
that, plus the metrics trend, is how you know it's working.
-94
View File
@@ -1,94 +0,0 @@
---
name: release
description: Bump the app version, update the changelog, and prepare a git tag for release
disable-model-invocation: false
---
# Release
Bump the app version, update the changelog, and prepare a git tag.
## Process
### 1. Determine what changed since the last version
Run `git log` from the last version tag (or all history if no tags exist) and review the
changes. Categorize:
- **Features**: new user-facing capabilities
- **Fixes**: bug fixes
- **Improvements**: performance, UX polish, refactoring that affects behavior
- **Internal**: refactoring, docs, tests, build (don't list individually — summarize if substantial)
### 2. Determine bump type
Read the current version from `package.json`. The project uses **simplified semver during
pre-1.0**:
| Bump | When | Example |
| ------------------- | ---------------------------------------------------- | ----------------- |
| **Minor** (`0.x.0`) | New features, UI changes, behavior changes | `0.1.0``0.2.0` |
| **Patch** (`0.x.y`) | Bug fixes, polish, performance, internal | `0.1.0``0.1.1` |
| **Major** (`1.0.0`) | Only when declaring public stability (user decision) | — |
Present the categorized changes and your recommended bump type to the user **for confirmation
before proceeding**.
### 3. Update version
Update the `version` field in `package.json`. This is the **single source of truth** — Vite
injects it as `__APP_VERSION__` at build time (shown in the header badge, and in Settings /
the export envelope once those exist).
### 4. Update the changelog
Maintain `docs/CHANGELOG.md`. If it doesn't exist yet, create it with a top-level `# Changelog`
heading. Add a new entry under a month heading:
```markdown
## June 2026
### v0.2.0
- **New feature** — description…
### v0.1.1
- **Bug fix** — description…
```
- Group by feature/change, not by commit.
- Lead with the name in bold, then a dash and description.
- Most important changes first; summarize related commits into coherent items.
### 5. Cross-check user-facing docs
Scan hand-maintained user-facing surfaces against the changes landing in this release and fix
drift **before** the version-bump commit (the CHANGELOG entry is not the place to quietly slip
in doc fixes):
- `README.md` — the status line and any feature claims still accurate?
- Any in-app help / about / onboarding content that exists at release time.
(When narrative content pages are added later, list them here so this net catches accumulated
drift across many changes.)
### 6. Suggest the git tag
After the user stages and commits the version bump, suggest:
```bash
git tag v{version}
git push --tags
```
If/when a deploy pipeline is wired, note here what publishing the tag triggers.
## Rules
- **Never bump the version without user confirmation** on the bump type.
- **Never stage or commit** — the user handles git operations.
- The export-format `version` (the import/export envelope) is **independent** of the app
version — only bump it when the export schema actually changes.
- Keep commit subjects single-line; no `Co-Authored-By` trailers (the user handles the commit
regardless).
-8
View File
@@ -1,8 +0,0 @@
# Revisions to skip in `git blame` — bulk, mechanical reformatting only.
# Each entry must be a pure formatting/no-behavior-change commit.
#
# Enable locally: git config blame.ignoreRevsFile .git-blame-ignore-revs
# GitHub honors this file automatically.
# Format entire codebase with Prettier (mechanical, no behavior change)
0c7297624e5a5ce6360ad52ccaaeb6ce2685454f
+43
View File
@@ -0,0 +1,43 @@
# Simple workflow for deploying static content to GitHub Pages
name: Deploy static content to Pages
on:
# Runs on pushes targeting the default branch
push:
branches: ["main"]
# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:
# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
permissions:
contents: read
pages: write
id-token: write
# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued.
# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete.
concurrency:
group: "pages"
cancel-in-progress: false
jobs:
# Single deploy job since we're just deploying
deploy:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Pages
uses: actions/configure-pages@v5
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
# Upload entire repository
path: '.'
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
-20
View File
@@ -1,20 +0,0 @@
node_modules
dist
dist-ssr
dev-dist
*.local
.DS_Store
*.log
coverage
.vite
# Claude Code per-project session/memory data — machine-local, not part of the
# codebase. Shared project config (.claude/skills/, .claude/settings.json) stays tracked.
.claude/projects/
# 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/*/
-4
View File
@@ -1,4 +0,0 @@
# Format + lint the staged files, then run the full verify gate.
# At this project's size all three finish in ~2s; if the suite grows slow,
# move typecheck/test to a pre-push hook and leave lint-staged here.
npx lint-staged && npm run typecheck && npm test
-1
View File
@@ -1 +0,0 @@
22
-6
View File
@@ -1,6 +0,0 @@
{
"singleQuote": true,
"trailingComma": "all",
"printWidth": 100,
"semi": true
}
-190
View File
@@ -1,190 +0,0 @@
# Astrolabe Project
> **Purpose**: Onboarding document for AI agents (and humans) working on Astrolabe.
---
## Project Overview
**Astrolabe** is a browser-based snippet manager for Vega-Lite visualizations. A user keeps
a local library of **snippets** (saved Vega-Lite specs), edits each as JSON with live
validation and a live chart preview, and reuses **datasets** across many snippets. Fully
local, offline-capable, no account.
It is a rebuild on an architecture adapted from its sibling project Syto. **`docs/spec/`**
(sections 0010) 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
| Layer | Technology |
| ------- | ------------------------------------------------------------------------------------ |
| Build | Vite, TypeScript, Vitest (happy-dom) |
| UI | React, Zustand, CSS Modules |
| Editor | Monaco (JSON + Vega-Lite schema service) |
| Charts | Vega-Lite + vega-embed |
| Storage | IndexedDB (snippets, datasets), localStorage (settings/prefs), URL hash (view state) |
| Offline | `vite-plugin-pwa` (Workbox), `registerType: 'prompt'` |
---
## Architecture (non-negotiable)
- **`src/core/` is portable** — no browser APIs, no React, no Monaco. All spec operations
live here and are tested hardest.
- **`src/app/`** — React + Zustand UI. State in Zustand **stores**; browser specifics in
**`src/app/infrastructure/`** adapters (IndexedDB / localStorage / URL hash). The rest of
the app never touches `window`/`indexedDB` directly.
- **Modals** go through a registry + coordinator + shell, not ad-hoc rendering.
- **CSS Modules + design tokens** (`styles/tokens.css`); themes flip `[data-theme]`.
- **No shared library with Syto** — patterns are adapted, never imported.
- **The app is served at `/app/`; `/` is a standalone marketing landing** (`src/landing/`).
Multi-page Vite build — `index.html` → the landing, `app/index.html` → the app (hash
view-state routing is unchanged by the base path). The landing reuses `src/core` and the
`chart-renderer` service only — never stores, orchestration, modals, or components — and
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). 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
`docs/IMPLEMENTATION-PLAN.md` for the milestone sequence. Both are **self-contained** — no
external repo is needed to work from them.
---
## Directory Structure
```
index.html # Landing entry (served at /)
app/index.html # App entry (served at /app/)
learn/index.html # Learning-section entry (served at /learn/)
src/
├── main.tsx # App bootstrap (font wiring, startup, render)
├── landing/ # Marketing landing at / — standalone page; reuses core + chart-renderer
├── learn/ # /learn/ deep-dive — markdown lessons + the SpecProgression engine (arch 11)
├── core/ # Portable spec engine (no browser/React/Monaco)
├── app/
│ ├── components/ # React UI (CSS Modules co-located)
│ ├── hooks/ # Reusable React hooks (e.g. useFocusTrap — shared by overlays)
│ ├── stores/ # Zustand stores (incl. ConfirmStore — in-app confirm dialogs)
│ ├── services/ # Business logic
│ ├── orchestration/ # Startup wiring: store↔adapter subscribers (persistence)
│ └── infrastructure/ # IndexedDB, localStorage, Monaco, settings adapters
styles/ # Global CSS (tokens, base)
docs/
├── spec/ # Behavioral specification (0010) — the WHAT (record; code leads)
├── architecture/ # Architecture playbook (0011) — the HOW (self-contained)
│ └── visual-specimen.html # Standalone token sandbox + reusable-primitive catalog
├── exploration/ # Point-in-time records (research, reviews, scope memos) — not maintained
├── IMPLEMENTATION-PLAN.md # Milestone sequence (M0M6)
└── WHY-A-SEPARATE-REBUILD.md
```
---
## Development
```bash
npm run dev # Dev server
npm run build # Typecheck + production build (+ PWA)
npm run typecheck # tsc --noEmit
npm test # Vitest (run once)
npm run test:watch # Vitest watch
npm run format # Prettier
```
### AI Developer Protocol
- **No git on your own initiative** — don't `add`/`commit`/`push` unless explicitly invited.
- **Verify** — run `npm run typecheck` and `npm test` after changes. A green build and a
smaller bundle prove nothing about behavior: when a change touches what the user sees or
does, confirm it by exercising the feature, not by the compiler alone.
- **Trim content, not capability** — when narrowing a third-party import or build to cut
size, remove optional _content_, never the library's _features_. The smallest/lowest-level
entry point is seldom the right one — it often drops capabilities you meant to keep. Prefer
the entry that excludes the unwanted content while retaining behavior, and validate the
behavior survived. (This bit us once: importing Monaco's `editor.api` to drop unused
languages also stripped every editor feature — see `docs/architecture/08`.)
- The line isn't "content vs. capability" by category — it's **"does any real user path
depend on this?"** Safe to drop: data no user path exercises (a date formatter's unused
locale tables, an icon set you never render, themes you don't ship). _Not_ safe, even
though it looks like "content": **human-language coverage** — font script subsets,
translatable strings — which is capability the moment the app is meant to be usable in
that language. Treat dropping it like dropping a feature. (This bit us a second time:
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 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),
then `/alignment` — and `/eng-council` review when the diff is structural — each as a
**clean-context subagent** that reports back. Full protocol in CLAUDE.md.
### Project skills
Invoke with `/<name>` (defined in `.claude/skills/`):
- **`/alignment`** — review uncommitted/staged changes for quality, test coverage, and
alignment with `docs/spec/` + `docs/architecture/`. Fixes issues directly and leaves
`// TODO:` breadcrumbs at code sites for out-of-scope observations.
- **`/doc-update`** — capture session-discovered knowledge gaps into the right doc layer
(`docs/spec/` for behavior, `docs/architecture/` for patterns).
- **`/council`** — consult the design council (Carbon, GOV.UK, WAI-ARIA APG, Nielsen
Norman, cloned under `reference/`) before a user-facing interaction/content/a11y
decision. Auto-fires on error/empty-state copy and new interactive-widget keyboard/focus
work; on demand otherwise. It advises; `docs/architecture/09`+`10` decide.
- **`/eng-council`** — convene the engineering council: an evidence-grounded review of
codebase structure, consistency, layering altitude, and subtraction (what to delete).
Modes: whole-codebase sweep, refactor review, new-functionality review, and a pre-build
consult (only the consult auto-fires — before building a new instance of a kind).
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
source of truth is `version` in `package.json`, injected as `__APP_VERSION__`.
### Testing Philosophy
High coverage on `src/core/` (parsing, detection, profiling, reference resolution, fit
transforms, import normalization). Lighter on components. Extract testable logic out of
components into core/stores where practical.
Don't test static presentational components — copy, markup, and links with no logic behind
them. Content assertions ("renders the heading X", "this link exists") are change-detectors:
they break on intentional copy edits and catch no real bug. Test the logic a component
carries — platform branches, state transitions, config-path writes, render serialization —
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
integration-heavy; a resolved no-op handle suffices) — see any `components/*.test.tsx`.
Infrastructure tests that touch IndexedDB run against `fake-indexeddb`.
-82
View File
@@ -1,82 +0,0 @@
# Claude Context — Astrolabe
See @AGENTS.md for project overview, architecture rules, and the AI developer protocol.
## Documentation Index
- **[SOUL.md](SOUL.md)** — project philosophy and identity. _Read first._
- **[docs/spec/](docs/spec/)** — behavioral specification (sections 0010): 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
(0011): the **how** (state, persistence, modals, routing, rendering, inference,
relationships, vega-editor techniques, visual design, interaction & feedback, learning
section).
Self-contained — no external repo needed. Companion:
**[visual-specimen.html](docs/architecture/visual-specimen.html)** — token sandbox +
reusable-primitive catalog (open in a browser).
- **[`/council`](.claude/skills/council/SKILL.md)** — the design council: consult external
interaction/content/a11y canon (Carbon, GOV.UK, WAI-ARIA APG, Nielsen Norman, cloned
under `reference/`) before a user-facing decision. It advises; our contract (architecture
09/10) decides. Resolutions are recorded back into the contract.
- **[`/eng-council`](.claude/skills/eng-council/SKILL.md)** — the engineering council:
evidence-grounded review of codebase structure, consistency, altitude, and subtraction
(what to delete). Sweep / refactor-review / new-work-review / pre-build-consult modes;
recurring findings become `docs/architecture/` rules and `/alignment` checks.
- **[docs/IMPLEMENTATION-PLAN.md](docs/IMPLEMENTATION-PLAN.md)** — incremental milestone
plan (M0M6), MVP boundary, per-milestone tests + manual checks, and an architecture
reference index.
- **[docs/manual-verification.md](docs/manual-verification.md)** — standing QA checklist for
what tests can't cover (offline/install, keyboard/a11y, theming, reduced-motion).
- **[AGENTS.md](AGENTS.md)** — onboarding, stack, directory map, scripts, conventions.
## Quick Orientation
- 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**.
- Work milestone by milestone (see the plan): core-first, then UI, then tests, then a
manual smoke check against the spec's acceptance points.
## Conventions
- No git actions unless explicitly invited.
- Run `npm run typecheck` and `npm test` after changes.
- Single-line commit subjects; no Co-Authored-By trailers.
## Session wrap-up protocol
When the user signals the session is wrapping (asks to commit, says it's done/wrapped),
run the review pass **before** anything is committed:
1. **Flush knowledge first — `/doc-update`, in-session.** Capture what this session
decided or discovered: rationale for non-obvious choices (to `docs/` or a code
comment at the site, whichever is the right home), spec/architecture gaps, decisions
made in conversation that never landed in writing. This step cannot be delegated —
only the session knows what was decided — and it runs first so the clean-context
reviewers below judge against recorded rationale instead of flagging deliberate
choices as oversights.
2. **Alignment — clean-context subagent.** Spawn an agent with no session context
beyond this prompt: "Read `.claude/skills/alignment/SKILL.md` and execute it against
the current uncommitted/staged diff. Fix directly per the skill, run typecheck and
tests, and return the skill's summary as your final message." The clean slate is the
point — the reviewer simulates the future maintainer and must not inherit the
session's rationalizations.
3. **Eng-council review — clean-context subagent, conditional.** Only when the session's
diff is structural (a new module or kind-instance, a refactor, a new dependency):
spawn an agent the same way to execute `.claude/skills/eng-council/SKILL.md` in the
matching review mode (refactor review / new-functionality review — never the sweep;
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, 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.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Oleh Omelchenko
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-54
View File
@@ -1,54 +0,0 @@
# Astrolabe
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 — local, offline-capable, no account.
> 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.
## Stack
Vite · TypeScript · React + Zustand · Monaco · Vega-Lite + vega-embed · IndexedDB · PWA (offline + installable).
## Develop
```bash
npm install
npm run dev # dev server
npm run build # typecheck + production build (+ service worker)
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
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
TBD.
-118
View File
@@ -1,118 +0,0 @@
# Astrolabe — What This Project Is About
## The Problem
People who work with [Vega-Lite](https://vega.github.io/vega-lite/) directly — analysts,
educators, chart authors — don't have a fast, private place to _keep_ their charts. The
official Vega-Lite editor is great for a single spec in a tab, but it forgets everything
when you close it. Notebooks bury charts in code. BI tools hide the spec behind a GUI and
lock you into an account.
Astrolabe fills this gap: **a local-first workspace where you author Vega-Lite specs as
JSON, see them render live, and keep a personal, searchable library of them — with no
account, no server, and full offline use.**
## The Core Idea
The central artifact is the **snippet**: a saved Vega-Lite specification plus metadata.
Everything else — the editor, the live preview, the dataset library, the chart builder —
exists to author, organize, and reuse snippets. Astrolabe does not abstract Vega-Lite
away; a snippet _is_ a Vega-Lite spec. The chart builder offers a no-JSON on-ramp, but the
JSON is always the source of truth and always editable.
Reusable **datasets** are stored once and referenced by name from many snippets, so the
data lives in one place and the specs stay lean.
## Core Values
### 1. Local-Only by Default
Everything runs in the browser. Snippets, datasets, and settings never leave the machine.
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
The product domain _is_ Vega-Lite. We validate, render, and reason about specs as
Vega-Lite, and we surface its real vocabulary (marks, encodings, field types). We don't
invent a parallel chart abstraction. The chart builder is an on-ramp, not a replacement
for the spec.
### 3. Experiment Safely
A snippet carries a stable **published** spec and an editable **draft**. You can tinker
freely without losing a known-good version. Auto-save protects in-progress work; publish
promotes it deliberately.
### 4. Beginner On-Ramp, Power-User Ceiling
The chart builder lets someone produce a chart without writing JSON. The editor — with
schema-aware autocomplete and live validation — lets a power user do anything Vega-Lite
can. Neither caps the other.
### 5. Own Your Data
Fully local and offline-capable, with import/export for backup and transfer. Your library
is a file you control, not a row in someone's database.
### 6. Predictable, Not Clever
When a behavior could go several ways, pick the one closest to the user's existing mental
model (the Vega-Lite editor, JSON tooling, file-based apps). Least surprise beats most
clever.
## What We're Not
- **Not a BI/dashboarding tool.** A snippet is _one_ visualization, not a composed report
with cross-filters and layout. Dashboards are a different product.
- **Not a data-wrangling tool.** Datasets are stored and referenced, not cleaned or
transformed. (That's [Syto](https://github.com/) territory — Astrolabe's sibling in
architecture and quality bar, but a separate product with separate goals.)
- **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-Recorded, Clean Implementation
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
`src/core/` is pure and portable — no browser APIs, no UI framework. Spec operations
(detection, profiling, reference resolution, fit transforms, validation, import
normalization) live there and are tested hardest. The UI is a thin, replaceable shell over
that core.
### Leverage Existing Libraries
Vega-Lite renders. Monaco edits. vega-embed mounts charts. React + Zustand drive the UI.
We wrap these with thin integration layers rather than reinventing them. Custom code
focuses on what's unique to Astrolabe: the snippet/dataset model, the rendering contract,
and the workspace that ties it together.
### No Parallel Systems
Each fact lives in one place. A snippet↔dataset link, a setting, a schema — one source of
truth, others derived. If you're writing the same logic twice, one should import or be
generated from the other.
### Test the Core, Trust the UI
High coverage on the portable engine (where a bug corrupts data or breaks rendering);
lighter coverage on components (where a bug is a cosmetic annoyance).
## The Name
An **astrolabe** is an ancient instrument for locating and predicting the positions of
stars — a tool for finding your way by the sky. The app helps you find your way through a
library of visualizations: keep them, locate them, and see where each one points.
+80
View File
@@ -0,0 +1,80 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Astrolabe - your Vega-Lite IDE/Snippet manager</title>
<script defer data-domain="olehomelchenko.github.com"
src="https://plausible.io/js/script.outbound-links.tagged-events.js"></script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
<link rel="icon" href="src/astrolabe.svg" type="image/svg+xml">
<link rel="stylesheet" href="src/styles.css">
</head>
<body>
<div class="about-container">
<header class="app-header">
<a href="/">
<img src="src/astrolabe.svg" alt="Astrolabe logo">
<h1>Astrolabe</h1>
</a>
</header>
<div class="panel">
<div class="about content">
<h2>About Astrolabe</h2>
<p>Astrolabe is a simple web application that allows you to create, edit, and save Vega & Vega-Lite snippets.
You can
create a new snippet, edit it, and save it to your browser's local storage. You can also export your
snippets to a JSON file and import them back into the application.</p>
<h2>How to use Astrolabe</h2>
<p>When you first open Astrolabe, you will see two panels: the Snippets panel and the Editor panel. The
Snippets panel displays a list of all your snippets, and the Editor panel allows you to edit the
selected
snippet. You can create a new snippet by clicking the "New Snippet" button in the Snippets panel.
You can
also search for snippets using the search bar at the top of the Snippets panel.</p>
<h3>Managing Snippets</h3>
<p>
<ul>
<li>You can create a new snippet by clicking the "New Snippet" button in the Snippets panel.</li>
<li>You can search for snippets using the search bar at the top of the Snippets panel.</li>
<li>You can rename a snippet's name by clicking the "Rename ✏️" button in the Snippets panel.</li>
<li>You can edit a snippet by selecting it in the Snippets panel.</li>
<li>You can delete a snippet by clicking the "Delete" button in the Snippets panel.</li>
<li>You can add or edit the comment for a snippet by clicking the "Comment 💬" icon.</li>
</ul>
</p>
<p>When you select a snippet in the Snippets panel, the Editor panel will display the snippet's code in
an editor. You can edit the code and save it by clicking the "Save" button. You can also switch
between the editor and the saved version of the snippet by clicking the "View Saved" button.</p>
<h2>Exporting and importing snippets</h2>
<p>You can export your snippets to a JSON file by clicking the "Export" button in the header. You can
import
snippets from a JSON file by clicking the "Import" button in the header and selecting the file you
want to
import.</p>
<h2>Privacy</h2>
<p>
Astrolabe does not collect any personal data. All your snippets are stored locally in your browser's
local storage. If you clear your browser's local storage, all your snippets will be deleted.
The only data that is collected is anonymous usage data, which is used to better understand
how users interact with the application. It is publicly available on the <a
href="https://plausible.io/olehomelchenko.github.com">Plausible Analytics</a> website.
</p>
<h2>Feedback</h2>
<p>If you have any feedback or suggestions for Astrolabe, please feel free to reach out to me on Twitter
<a href="https://twitter.com/olehomelchenko">@olehomelchenko</a>.
</p>
</div>
</div>
</div>
<footer class="footer">
<p>2025 Oleh Omelchenko <a href="https://olehomelchenko.com">olehomelchenko.com</a></p>
</footer>
</body>
</html>
-15
View File
@@ -1,15 +0,0 @@
<!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" />
<title>Astrolabe</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
-140
View File
@@ -1,140 +0,0 @@
# Astrolabe — Incremental Implementation Plan
> A rebuild of Astrolabe on Syto's architecture. The behavioral record is
> `docs/spec/` (sections 0010) — the code leads, and the spec is kept
> rewritten to match.
>
> **The M0M6 build is complete** — the milestone map below is the record. Remaining
> work is post-M6 enhancement, tracked in the **live backlog** and owned in detail by
> the two scope docs it points to. Per-milestone build notes aren't kept here; the git
> history and the spec/architecture docs hold them.
>
> **Method per milestone:** build core-first (portable, pure, tested) → wire UI →
> cover with tests → manual smoke check against the spec's acceptance points.
> "Test the Core, Trust the UI": high coverage on `src/core/`, lighter on components.
---
## Architectural ground rules
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.
- **`src/core/` is portable** — no browser APIs, no React, no Monaco. Pure spec
operations (detection, profiling, reference resolution, fit transforms,
validation, import normalization). This is what we test hardest and what could
power a future headless renderer/CLI.
- **`src/app/`** holds React + Zustand UI. State lives in Zustand **stores**
(`useAppStore`, plus per-feature stores); browser specifics live in
**`src/app/infrastructure/`** adapters (IndexedDB, localStorage, URL hash) so
the rest of the app never touches `window`/`indexedDB` directly.
- **Modals via a registry + coordinator + shell** (see [Architecture 03](architecture/03-modal-system.md)),
not ad-hoc conditional rendering.
- **CSS Modules + design tokens** (`styles/tokens.css`); themes flip
`[data-theme]`. Vega theme follows the UI theme. The design language behind the
tokens — type, spacing, color roles, components, themes — is defined in
[Architecture 09](architecture/09-visual-design.md) and established in M1.5.
- **Editor: Monaco**, **self-hosted from npm + raw `monaco-editor` API** (not the
CDN loader / `@monaco-editor/react` wrapper — decided; rationale in
[Architecture 08](architecture/08-vega-editor-techniques.md#decision--monaco-integration-self-hosted-raw-api)).
Workers are wired explicitly via Vite `?worker`. The Vega-Lite JSON-schema
service is what gives autocomplete + validation; mine vega-editor for how it
wires the schema.
- **No shared library with Syto.** Patterns are copied/adapted, never imported.
---
## 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, §03AC, §04, §09A |
| **M1.5** | Visual design foundation ✅ | Apply the design language: tokens, IBM Plex, restyled M1 surfaces, chart theme | [arch 09](architecture/09-visual-design.md) |
| **M2** | Editor robustness ✅ | Draft/Published, validation, schema autocomplete, fit modes | §03DE, §04, §07(editor) |
| **M3** | Datasets ✅ | Named reusable data + reference resolution in preview | §05, §03F, §09B |
| **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 made it _look right_; M2 made it _robust_; M3M6 made it _complete_.
---
## Live backlog
The M0M6 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** (M1M5): 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.13.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 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. 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; 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.
---
## Architecture reference
The **how** behind each milestone is documented self-containedly in
[`docs/architecture/`](architecture/00-overview.md) — no external repo needed:
| Need | Doc |
| ------------------------------------------------------------------------------ | --------------------------------------------------------------------------------- |
| Zustand stores, selector derivations, debounced auto-save | [01 · State & Stores](architecture/01-state-and-stores.md) |
| IndexedDB wrapper, lazy loading, migrations, localStorage prefs, storage tiers | [02 · Persistence](architecture/02-persistence.md) |
| Modal registry + coordinator + shell, unsaved-change detection, focus trap | [03 · Modal System](architecture/03-modal-system.md) |
| URL hash view-state, keyboard routing, interactive-context detection | [04 · Routing & Events](architecture/04-routing-and-events.md) |
| vega-embed integration, theming, debounced preview, error display | [05 · Rendering, Theming & Preview](architecture/05-rendering-theming-preview.md) |
| Column type inference + dataset profiling | [06 · Type Inference & Profiling](architecture/06-type-inference.md) |
| Unique names + import auto-suffix, snippet↔dataset links, rename propagation | [07 · Naming & Relationships](architecture/07-naming-and-relationships.md) |
| Monaco setup, Vega-Lite schema service, editor patterns mined from vega/editor | [08 · Vega Editor Techniques](architecture/08-vega-editor-techniques.md) |
| 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) |
-210
View File
@@ -1,210 +0,0 @@
# Astrolabe → Syto Integration Analysis
> **Question:** Can Astrolabe (a browser-based Vega-Lite snippet manager) be integrated into
> Syto's functionality? This document compares the Astrolabe specification (`docs/spec/`)
> against Syto in its current state, and recommends an integration path.
>
> **Short answer:** Not as a wholesale port, and not as the "snippet manager" it is today —
> that framing collides with Syto's stated non-goals. But the _valuable parts_ of Astrolabe
> (the Chart Builder, the generic spec→render pipeline, the schema-assisted JSON editor) map
> cleanly onto a Syto-native **"chart a model"** feature, and most of the supporting tech already
> exists in the codebase. The recommendation is **harvest, don't port** — and the framing decision
> needs a `SOUL.md` ruling first.
---
## 1. Executive Summary
| | |
| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Conceptual fit** | Partial. Astrolabe and Syto are both local-first, browser-only, Vega-Lite-using, three-pane-ish workspaces. But Astrolabe's _primary entity_ (a saved chart spec) is a thing Syto deliberately does not have. |
| **Strategic fit** | **Conflicted.** `SOUL.md` explicitly lists "Not a BI/visualization platform — charts are for exploration during wrangling, not final output" as a non-goal, and "Do One Thing Well." A _snippet library_ is chart-authoring-as-product. This is the central tension and must be resolved before any code. |
| **Technical fit** | **Good for the rendering/editing layer, poor for the data-model and shell layers.** Syto already ships Vega-Lite, vega-embed, CodeMirror 6, IndexedDB persistence, a settings system, URL-hash routing, and a far stronger type/schema engine than Astrolabe's profiler. The friction is in the _parallel systems_ a verbatim port would introduce. |
| **Recommended path** | **Option B (harvest into a native "Visualize" feature).** Reuse Astrolabe's Chart Builder and rendering contract; bind them to Syto **Models** instead of a new "dataset" entity; drop the snippet-as-primary-entity, the draft/published workflow, the separate dataset library, and the separate import/export envelope. |
---
## 2. The Two Products Side by Side
| Dimension | **Astrolabe** | **Syto** |
| ------------- | ----------------------------------------------------------------------------- | -------------------------------------------------------------------- |
| Core artifact | A **snippet** = a saved Vega-Lite spec + metadata | A **workflow** = a declarative transform pipeline over a Source |
| Primary verb | _Author & organize charts_ | _Clean & reshape tabular data_ |
| Data unit | **Dataset** (named blob: JSON/CSV/TSV/TopoJSON, inline or URL) | **Source** (immutable imported table) → **Model** (derived table) |
| Persistence | Snippets (~5 MB tier) + Datasets (high-capacity tier), both local | Sources + Models in IndexedDB (lazy row data), prefs in localStorage |
| Editor | JSON editor w/ Vega-Lite schema autocomplete + live validation | CodeMirror 6 — used for transform JSON + the expression language |
| Rendering | Renders _arbitrary user specs_ via reference-resolution + fit-mode transforms | Renders _programmatically generated_ EDA specs (`charts.ts`) |
| Shell | 3 panes: library · editor · preview, + modals | Ribbon + sidebar + data table + slide-panel/modal dialogs |
| Routing | URL hash: `#snippet-<id>`, `#datasets/...` | URL hash: active source/model/dialog |
| Export | One JSON envelope of all snippets + datasets | Workflow v2 JSON (transforms, topo-sorted) |
| Stack stance | Implementation-agnostic spec | Preact + Signals + Arquero + CSS Modules, fixed |
**The key observation:** Astrolabe's "dataset" is conceptually Syto's "Source," and the thing you
most want to chart in Syto — a cleaned, transformed **Model** — has _no equivalent in Astrolabe at
all_. Astrolabe charts static blobs; Syto produces living, recomputed tables. A naive port would
bolt a second, weaker data-library (Astrolabe datasets) next to Syto's existing one (Sources/Models),
which directly violates SOUL's **"No Parallel Systems"** value.
---
## 3. The Strategic Tension (resolve this first)
This is not a technical blocker; it is a product-identity decision, and per project convention
(`SOUL.md` is the arbiter for contract/design decisions) it must be settled before implementation.
**What `SOUL.md` currently says:**
- _"Do One Thing Well… It's not trying to become a spreadsheet, a statistical package, a visualization tool, or a database. The EDA features… exist to help users understand their data before transforming it — not to replace dedicated analysis tools."_
- _"Not a BI/visualization platform: Charts are for exploration during wrangling, not final output. Dashboards and reporting are a separate concern."_
A **snippet manager** — a personal, searchable, import/exportable _library of saved charts_ — is
squarely "charts as final output" and "a visualization tool." Porting Astrolabe as-is would
contradict two written non-goals.
**However**, there is a reading that is fully _aligned_ with the rest of SOUL:
- _"Beginner-Friendly, Not Beginner-Limited"_ and _"Progressive Complexity"_ — today a user can clean data but has **no way to turn the result into a shareable picture.** A chart is the natural last step of a wrangling session.
- _"Leverage Existing Libraries — Vega-Lite handles charts."_ The infrastructure is already paid for.
- Astrolabe's **Chart Builder** (pick a mark, map columns → spec) is the _exact_ beginner-friendly, no-JSON affordance Syto favors, and the live JSON editor is the power-user escape hatch.
**The decision to make:** Is "produce a chart as the output of a workflow" _part of_ doing the one
thing well (wrangling ends in a usable artifact), or is it the BI/viz scope SOUL rejects?
Two coherent resolutions:
- **(A) Amend SOUL** to permit _single-chart output of a model_ (not dashboards, not a chart library-as-product) — and integrate as a native feature (§6, Option B).
- **(B) Keep it separate** — Astrolabe stays its own thing, or lives as a sibling `/tools/` mini-app that merely _shares code_ with Syto (§6, Option C). The main app's non-goals stay intact.
I recommend (A) with a tightly-scoped amendment, because the value lands precisely where Syto is
currently weakest (no output artifact), and because doing it natively avoids the parallel-systems
trap. But this is the user's call to make against SOUL.
---
## 4. Feature-by-Feature Reuse Map
Legend: 🟢 already exists / strong reuse · 🟡 partial, needs adaptation · 🔴 net-new build
| Astrolabe feature | Syto today | Verdict | Notes |
| ----------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **Vega-Lite rendering** | `charts.ts` + `vega-embed@7` render programmatic specs into DOM refs | 🟡 | Engine present; needs a _generic_ "render this arbitrary spec" path + error surface. The hardcoded EDA specs don't help directly, but the rendering primitive does. |
| **Dataset-reference resolution** (`{data:{name}}` → contents, recursing into layers) | none | 🔴 | New, but small and pure — and in Syto it resolves to a **Model's data**, not a separate dataset store. |
| **Fit-mode transforms** (Original/Width/Height/Full via `"container"`) | none | 🔴 | Small, pure, copy-on-render spec rewrite. Directly portable. |
| **JSON spec editor** | CodeMirror 6 (`CodeMirrorEditor.tsx`, `JsonEditorModal.tsx`) + lint infra (`linters/`) | 🟡 | Editor & lint plumbing exist. Missing: a **Vega-Lite schema service** for autocomplete + validation. (Note: Astrolabe's "minimap" and "VS Light/Dark/High-Contrast" editor themes are Monaco-isms; Syto is on CodeMirror — those exact settings don't carry over.) |
| **Chart Builder** (mark + X/Y/Color/Size + field types → spec) | none | 🟡→🔴 | The single most valuable, most SOUL-aligned piece. Build it against a **Model's columns** using Syto's existing schema types. High reuse of the _dialog_ pattern (registry + slide-panel/modal + debounced preview). |
| **Column profiling / type inference** | `schema-engine.ts` (integer/float/date/datetime/boolean/json) | 🟢 | Syto's engine **supersedes** Astrolabe's (number/string/date/boolean). Astrolabe→Vega field-type mapping (numeric→Quantitative, date→Temporal, else Nominal) layers on top trivially. |
| **Datasets library + manager modal** | Sources/Models already _are_ the data library | 🔴 _(avoid)_ | Do **not** build. Reuse Sources/Models. Building it = parallel systems. |
| **Snippet library** (search/sort/CRUD, draft vs published, status, tags, storage monitor) | none | 🔴 | The genuinely new persistent entity. Only needed if going full snippet-manager (not recommended). Draft/Published has no analog in Syto's undo/redo model. |
| **Settings** (editor/performance/formatting) | `ux-settings.ts` + settings dialog | 🟡 | System exists; add render-debounce + a couple of fields. Editor-theme/minimap fields are Monaco-shaped and mostly drop. |
| **Import/Export envelope** (snippets+datasets JSON) | Workflow v2 export/import | 🔴 _(avoid)_ | A second export format competing with workflow v2. If charts are part of a workflow, they belong _in_ the workflow spec or alongside it — not in a rival envelope. |
| **App shell / 3-pane layout** | Ribbon + sidebar + table + slide-panel | 🔴 _(avoid)_ | Don't graft Astrolabe's shell. A chart view is a _mode/panel within_ Syto's shell. |
| **URL-hash routing** | Hash routing for source/model/dialog | 🟡 | Reusable, but Astrolabe's `#snippet-…`/`#datasets/…` scheme would **collide**; must namespace under Syto's existing scheme. |
| **Keyboard shortcuts** | `EventRouter` owns Ctrl+S (save), Escape priority chain, etc. | 🟡 | **Collisions:** Astrolabe binds Ctrl+S (Publish) and Ctrl+K (Datasets). Syto already owns Ctrl+S. Must reconcile, not adopt verbatim. |
| **Offline / PWA / installable** | `vite-plugin-pwa` already configured | 🟢 | Free. |
| **i18n** | i18next, en/uk, namespaced | 🟢 | New strings go in a namespace; framework is there. |
| **Toasts** | Notification system exists | 🟢 | Reuse. |
**Reuse tally:** the _rendering, editing, persistence, settings, schema, i18n, PWA, and toast_
substrate is largely present. The _data-model, shell, routing-scheme, and lifecycle_ layers of
Astrolabe are either redundant with Syto or actively conflicting and should be dropped.
---
## 5. Technical Friction Points (if ported verbatim)
1. **Parallel data library.** Astrolabe datasets vs Syto Sources/Models — two stores, two
profilers, two "named data" concepts. Violates _No Parallel Systems_. (The fix: charts reference
Models.)
2. **Parallel persistence + export.** A second IndexedDB store layout and a second JSON envelope
alongside workflow v2. Two backup formats for users to confuse.
3. **Draft/Published has no home.** Syto's non-destructive model is _pipeline steps + undo/redo_,
not a per-document draft/published toggle. Astrolabe's central editing model would be a third,
unrelated state concept.
4. **Shell mismatch.** Astrolabe's library·editor·preview triptych is a _whole app_. Syto's shell is
ribbon-driven with slide-panel dialogs. They don't compose; one must yield.
5. **Routing & shortcut collisions.** Hash schemes overlap; Ctrl+S/Ctrl+K already bound.
6. **Editor-feature gap.** Syto is on CodeMirror (no minimap, different theme model); Astrolabe's
settings assume Monaco. And neither today has a **Vega-Lite schema service** — that autocomplete/
validation is net-new work on either stack.
7. **TopoJSON / arbitrary-JSON data.** Syto Sources are _tabular_. Astrolabe datasets include
TopoJSON and arbitrary JSON. Charting a Model covers the tabular case; map/topology charts would
be out of scope unless Sources grow a non-tabular kind.
None of these are unsolvable — but every one of them is _work created by the port itself_, not by
the user value. That's the signature of "harvest, don't port."
---
## 6. Integration Options
### Option A — Full port (snippet manager inside Syto)
Bring Astrolabe over more-or-less intact: snippet library, dataset manager, draft/published, its
shell, its export.
- **Pros:** Fastest way to "have Astrolabe." Feature-complete chart authoring.
- **Cons:** Maximal parallel-systems debt (§5). Directly contradicts SOUL non-goals. Two data
libraries, two export formats, shell/routing/shortcut conflicts. **Not recommended.**
### Option B — Harvest into a native "Visualize" feature ✅ _recommended_
Add charting as the natural _output_ step of a workflow, reusing Syto's own primitives:
- A **"Chart" / "Visualize"** action on a **Model** opens a **Chart Builder** (Astrolabe's mark +
X/Y/Color/Size + field-type controls), populated from the Model's columns and `schema-engine`
types.
- It produces a Vega-Lite spec rendered live via the existing `vega-embed`, using a ported
**reference-resolution + fit-mode** rendering contract where the named data resolves to the
**Model's rows**.
- Power users get the **JSON spec editor** (CodeMirror, with a Vega-Lite schema service added) as the
escape hatch — consistent with _Beginner-Friendly, Not Beginner-Limited_.
- The chart (its spec) is persisted **attached to the Model** (or to the workflow), not as a separate
snippet entity. Export rides along with workflow v2 (or a sibling field), not a rival envelope.
- **Dropped from Astrolabe:** separate dataset library, draft/published, snippet search/sort/tags,
storage monitor, its shell, its import/export, its routing scheme.
- **Pros:** No parallel systems. Lands value exactly where Syto is weak (no output artifact). Maximal
reuse of existing infra. Defensible against SOUL with a _narrow_ amendment ("single-chart output of
a model," not dashboards/library).
- **Cons:** Requires the SOUL decision (§3). Loses Astrolabe features that depend on the
snippet/dataset model (TopoJSON/URL datasets, multi-snippet library). Net-new: schema service,
builder dialog, render contract.
### Option C — Sibling `/tools/` mini-app
Port Astrolabe as a self-contained app under `/tools/astrolabe/`, sharing only _code_ (vega render
helpers, CodeMirror wrapper, i18n) with the main app — no AppStore/DialogStore coupling.
- **Pros:** Keeps the main app's non-goals pristine (it's a separate utility, like other tools).
Lower conceptual conflict. Astrolabe keeps its own model.
- **Cons:** Syto's `/tools/` layer is designed for _small, single-purpose_ utilities; Astrolabe is a
full application — a stretch for that slot. Still carries Astrolabe's whole parallel data model,
just quarantined. "Integration" here means "co-located," not "unified" — limited synergy.
---
## 7. Recommendation
1. **Make the SOUL call first (§3).** Decide whether single-chart _output of a model_ is in scope.
If **no**, stop here or pursue Option C as a quarantined sibling. If **yes**, amend SOUL with a
tight scope statement and proceed to Option B.
2. **Pursue Option B.** Harvest the three high-value, well-aligned pieces:
- the **Chart Builder** (bound to a Model, driven by `schema-engine` types),
- the **rendering contract** (reference-resolution + fit modes, resolving to Model data),
- the **schema-assisted JSON editor** (CodeMirror + a new Vega-Lite schema service).
3. **Drop the parallel-systems pieces:** separate dataset library, draft/published, snippet
library + storage monitor, separate import/export envelope, Astrolabe's shell and routing scheme.
4. **Reconcile, don't adopt,** the cross-cutting surfaces: fold settings into `ux-settings`,
namespace any new hash state under Syto's scheme, resolve the Ctrl+S/Ctrl+K shortcut collisions.
This delivers the genuinely useful core of Astrolabe — turning cleaned data into a chart, with a
beginner path and a power-user path — while staying true to _Do One Thing Well_ and _No Parallel
Systems_, and reusing the infrastructure Syto has already built.
---
## 8. Open Questions for the User
- **SOUL scope:** Is "a chart as the output of a workflow" inside Syto's mission, or out? (Blocks everything.)
- **Persistence model:** Should a chart spec live _on a Model_, _in the workflow v2 export_, or as a new top-level entity?
- **Non-tabular data:** Do we ever need TopoJSON / arbitrary-JSON charts (maps), which Syto Sources can't currently hold? If not, that simplifies scope considerably.
- **Editor depth:** Is full Vega-Lite schema autocomplete/validation in scope, or is a plain JSON editor + live error surface enough for v1?
-71
View File
@@ -1,71 +0,0 @@
# Astrolabe — Architecture Playbook
> These documents capture the **architectural patterns** Astrolabe is built on. They are
> self-contained: everything needed to implement a pattern lives here, in Astrolabe's own
> domain terms (snippets, datasets, settings, Vega-Lite specs). You do not need any other
> repository to work from them.
>
> They are the architectural counterpart to [`docs/spec/`](../spec/): the **spec** says
> _what the app does_ (behavior, acceptance points); this **playbook** says _how we build
> it_ (state, persistence, modals, routing, rendering, inference, relationships, and the
> visual + interaction language).
## Spec vs playbook: cite, don't restate
The two documents overlap most in §09 (visual) and §10 (interaction), which necessarily talk
about user-facing widgets. At that overlap, one rule keeps them from drifting:
- The **spec owns product behavior** — what features exist, what surfaces appear when, what
the data does. The **playbook owns the _how_** — state shape, persistence, ARIA roles,
keyboard models, focus, tokens, motion.
- **For product behavior, the spec is the source: cite it (`spec §NN`), don't restate it,
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 — 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
- Building a feature? Read the relevant spec section first (the _what_), then the matching
playbook doc (the _how_), then implement core-first per [`../IMPLEMENTATION-PLAN.md`](../IMPLEMENTATION-PLAN.md).
- Each doc states the pattern, the **rationale** (what problem it solves, what it prevents),
TypeScript sketches in Astrolabe terms, and Do/Don't rules.
- The sketches are _illustrative_, not finished code. Adapt them; keep the principles.
## The documents
| # | Doc | Covers |
| --- | --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 01 | [State & Stores](01-state-and-stores.md) | Zustand stores; one source of truth; selector derivations; central `useAppStore` vs per-feature stores; testable action functions; debounced auto-save. |
| 02 | [Persistence](02-persistence.md) | The infrastructure-adapter boundary; promise-wrapped IndexedDB wrapper; lazy data loading; per-record schema versioning + migration; localStorage prefs with fallback; storage tiers + quota monitoring. |
| 03 | [Modal System](03-modal-system.md) | Registry + coordinator + shell; one modal at a time; unsaved-change detection via snapshot; focus trap; backdrop/Escape/close dismissal. |
| 04 | [Routing & Events](04-routing-and-events.md) | URL hash as view-state (restore/sync, Back/Forward); global keyboard routing; Escape priority chain; the single-source `isInInteractiveContext()` helper (Monaco-aware). |
| 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, 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`). |
## The non-negotiable layering (every doc assumes this)
- **`src/core/`** — portable, pure logic. No browser APIs, no React, no Monaco. Spec
operations live here and are unit-tested hardest. (Docs 06, 07, parts of 05 land here.)
- **`src/app/stores/`** — Zustand stores. (Doc 01.)
- **`src/app/infrastructure/`** — the _only_ place that touches `indexedDB`, `localStorage`,
or `window.location`. Everything else goes through these typed adapters. (Docs 02, 04.)
- **`src/app/services/` & `orchestration/`** — coordination that composes stores +
infrastructure + core (lifecycle, routing sync, dependency upkeep). (Docs 03, 04, 07.)
- **`src/app/components/`** — React + CSS Modules. Thin; pushes logic down into stores/core
so it stays testable. (Docs 03, 05.)
## Why a playbook at all
Patterns written down once, in one place, stop two classes of problem: drift (the same
decision re-litigated inconsistently across features) and rediscovery (re-deriving why
something is the way it is). When a pattern here proves wrong, change the doc — don't fork
the convention silently. This is the same discipline `docs/spec/` applies to behavior.
-535
View File
@@ -1,535 +0,0 @@
# State Management & Stores
How Astrolabe holds and shares application state. The whole app is built on
**Zustand**: small, standalone stores created with `create()`, each exposing
state fields and the actions that mutate them. Components subscribe to the exact
slices they read; non-component code (services, infrastructure, orchestration)
reads and writes the same stores directly. This document defines how we use
Zustand, where state lives, and the rules that keep state predictable as the app
grows.
Why Zustand: it is idiomatic React (just a hook), it has a first-class **outside-React**
API (`getState`/`setState`/`subscribe`) that fits our "logic lives in core/services,
not components" architecture, and it carries no build-time magic. The principles below
(one source of truth, derive-don't-duplicate, actions outside components, thin components)
are the durable part — they would survive a change of library.
**Lineage (why React + Zustand).** The UI began on Preact + `@preact/signals` and migrated to
**React + Zustand** at M0, before feature work. The driver was _React-ecosystem friction_
real-React-only libraries not cooperating with `preact/compat`**not** the signals model.
Switching framework while nothing was implemented yet was also the one cheap moment to pick
the lowest-migration-risk state library, so signals gave way to Zustand. A bonus: borrowing
from the React-based vega/editor reference (see [08](08-vega-editor-techniques.md)) then ports
directly rather than through `preact/compat`.
---
## 1. The Primitives
A store is a module that calls `create<State>()` once and exports the resulting
hook. The state object holds both **data fields** and **action functions**.
```ts
import { create } from 'zustand';
// ModalName is the union of the app's modal identifiers; UiTheme is defined in core.
export interface AppState {
uiTheme: UiTheme;
activeModal: ModalName | null;
setTheme: (theme: UiTheme) => void;
// Low-level primitive. High-level open/close (snapshot, URL sync, discard
// prompt) is the modal coordinator's job — see docs/architecture/03.
setActiveModal: (modal: ModalName | null) => void;
}
export const useAppStore = create<AppState>((set) => ({
uiTheme: 'light',
activeModal: null,
setTheme: (uiTheme) => set({ uiTheme }),
setActiveModal: (activeModal) => set({ activeModal }),
}));
```
Three ways to touch a store:
- **`set(partial)`** — update state (shallow-merges). Inside actions, the only place
that mutates state.
- **`get()`** — read current state inside actions without subscribing.
- **the hook `useAppStore(selector)`** — read state _in a React component_, subscribing
to exactly what the selector returns.
### Reading in components — always select narrowly
Call the hook with a **selector** that returns the smallest thing you need. The
component re-renders only when that selected value changes (default `Object.is`
comparison).
```tsx
import { useAppStore } from '../stores/AppStore';
export function ThemeBadge() {
const theme = useAppStore((s) => s.uiTheme); // re-renders only when uiTheme changes
return <span>{theme}</span>;
}
```
When you select **multiple fields or a fresh object/array**, wrap the selector in
`useShallow` so a new-but-equal result doesn't cause an extra render:
```tsx
import { useShallow } from 'zustand/react/shallow';
const { activeModal, uiTheme } = useAppStore(
useShallow((s) => ({ activeModal: s.activeModal, uiTheme: s.uiTheme })),
);
```
**`useShallow` only helps when the elements are stable.** It shallow-compares the
result — array elements (or object values) by `Object.is`. A selector that
**computes** a fresh collection of fresh objects each call (e.g.
`useShallow((s) => buildWarnings(s.config))`) defeats it: every element is a new
reference, so the result never compares equal, `useSyncExternalStore` re-renders
forever, and React throws _"Maximum update depth exceeded"_ (a white screen). A
selector must return a **primitive** or a **stored reference** — never a freshly
built array/object. Derive computed collections in the component with `useMemo`
over a stable slice instead:
```tsx
const config = useChartBuilderStore((s) => s.config); // stored ref, stable between updates
const warnings = useMemo(() => builderWarnings(config), [config]); // recompute only on change
```
This is a render-time loop, so core/store unit tests stay green and miss it. A bare
`react-dom/client` + `react`'s `act` mount test catches it with **no test-library
dependency** — mount the component in the looping config and assert it doesn't throw
(prove the guard by reverting the fix first). See `ChartBuilderModal.test.tsx`.
### Reading/writing outside components
Services, orchestration, infrastructure, and tests use the store object directly —
no React involved. This is the property that lets our logic live outside components:
```ts
openModal('datasets'); // via the modal coordinator (doc 03)
const theme = useAppStore.getState().uiTheme; // snapshot read
const unsub = useAppStore.subscribe((s, prev) => {
/* react to changes */
});
```
> Rule: in components, **select narrowly** (and `useShallow` for object/array
> selections). Outside components, use `getState()` for a snapshot, `subscribe()`
> to react.
---
## 2. One Source of Truth per Fact — Derive, Don't Duplicate
Every fact lives in exactly one state field. Anything that can be _calculated_
from other state is computed **in a selector at read time**, never stored as a
second field you keep in sync by hand.
The failure mode this avoids: two fields that must agree (`snippets` and
`snippetCount`, or `activeSnippetId` and `activeSnippet`) drift apart because one
update path forgets the other. If the derived value is computed from the source on
read, drift is structurally impossible.
```ts
// State holds only the sources:
// snippets: Snippet[]
// activeSnippetId: string | null
// Derive in the component's selector — not a stored field:
const activeSnippet = useSnippetStore(
(s) => s.snippets.find((x) => x.id === s.activeSnippetId) ?? null,
);
const snippetCount = useSnippetStore((s) => s.snippets.length);
```
For a derivation that is **expensive** or reused in many places, expose it as a
selector function (memoize if profiling shows it matters) rather than caching it
into state:
```ts
// src/app/stores/snippet-selectors.ts
export const selectActiveSnippet = (s: SnippetState) =>
s.snippets.find((x) => x.id === s.activeSnippetId) ?? null;
// in a component:
const active = useSnippetStore(selectActiveSnippet);
```
> Rule: if you can compute it, do not store it. Add a new state field only for a
> value that is _input_ the app receives, not output it derives.
### Editing buffers — the sanctioned duplication, and its sync rule
A text field with debounced auto-save (the metadata panel's Name/Comment, the editor
buffer) legitimately mirrors a store fact into local component state: the local copy is
the user's in-progress text, the store holds the saved value. This duplication carries an
obligation the moment the store fact has **another writer** (publish's content-derived
renaming, import, any store-side mutation): the component must **adopt** a store change it
didn't make, or its debounced save will write the stale local copy back — silently undoing
the other writer. The pattern (see `SnippetLibrary`'s `SnippetMeta`): track the last store
value seen in a ref; when the store value changes, adopt it into local state **unless the
user has diverged** (local ≠ previous store value) — in-progress typing always wins.
Keying the component by entity id handles switching entities; this rule handles the same
entity changing underneath.
---
## 3. Where State Lives: Central vs. Per-Feature Stores
Each store is its own `create()` module. We split by _concern_, not by component
tree.
### Per-feature stores
Each cohesive feature owns a store holding its durable domain state.
- **`useSnippetStore`** — the snippet library: `snippets`, `activeSnippetId`, the
working `draftSpec`, and its actions.
- **`useDatasetStore`** — loaded datasets, the active dataset, inferred fields.
- **`useUserSettingsStore`** — the **managed** user preferences applied _live_ as
`saved` (editor options, render debounce, date format). Its per-cluster setters
are called by the per-pane settings popovers; the editor/preview/library read
`saved.*`. There is **no draft/Apply** — settings are distributed and commit on
change (spec §07). **UI theme and preview fit mode are NOT here** — they're
cross-cutting and live in `useAppStore` (header toggle, Fit control); all three
persist to the one `astrolabe:settings` record via slice writers (arch 02 §5).
### Global overlay stores (imperative trigger)
Some surfaces are summoned from anywhere — including non-React code — and own only
ephemeral request state, not durable data:
- **`useConfirmStore`** — the blocking confirm dialog (the `window.confirm` replacement).
- **`useNotificationStore`** — non-blocking toasts (failed saves, etc.).
- **`useSettingsPopoverStore`** — the single-open registry for **all** pane-header
disclosures (the per-pane settings clusters and the per-chart Export control), keyed
by id so at most one is open at once; its imperative `openSettingsPopover(id)` lets the
Cmd/Ctrl+, shortcut open the editor cluster. Header disclosures share this registry rather
than each carrying their own open-state. The disclosure widget contract (trigger +
non-modal popover, not an ARIA menu; Esc/focus rules) is
[10 · Interaction & Feedback](10-interaction-and-feedback.md) §5.
Each pairs its store with a thin **imperative trigger** exported alongside the hook —
`confirm(opts): Promise<boolean>` and `notify(opts): string` — so orchestration/services can
raise one without a hook: `export const notify = (o) => useNotificationStore.getState().notify(o)`.
Components subscribe to the store to _render_ it; everyone else calls the function. (Why a
toast at all, and which channel for which message: [10 · Interaction & Feedback](10-interaction-and-feedback.md) §1.)
### The central `useAppStore`
`useAppStore` holds only _cross-cutting, ephemeral UI state_ that no single
feature owns — which modal is open, the runtime theme, transient render flags.
### How to decide
| Put it in a **feature store** when… | Put it in **`useAppStore`** when… |
| -------------------------------------------- | -------------------------------------------- |
| It's domain data (snippets, datasets, specs) | It's transient UI chrome (open modal, theme) |
| It outlives a single interaction | It belongs to no single feature |
| It gets persisted | Multiple unrelated features read/write it |
> Rule: keep `useAppStore` small. When a chunk of it only ever serves one feature,
> 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
Components **render** and **dispatch**; they do not contain mutation logic. Every
state change goes through a named action defined on the store (via `set`/`get`).
Multi-step logic that coordinates several stores or touches infrastructure can
live in a `src/app/services/*` module that calls store actions.
```ts
// src/app/stores/SnippetStore.ts
import { create } from 'zustand';
import type { Snippet } from '@core/snippet';
interface SnippetState {
snippets: Snippet[];
activeSnippetId: string | null;
draftSpec: string; // Monaco editor buffer (Vega-Lite JSON)
create: (name: string) => string;
select: (id: string) => void;
remove: (id: string) => void;
updateDraft: (spec: string) => void;
reset: () => void;
}
export const useSnippetStore = create<SnippetState>((set, get) => ({
snippets: [],
activeSnippetId: null,
draftSpec: '',
create: (name) => {
const snippet: Snippet = { id: crypto.randomUUID(), name, spec: '{}' };
set((s) => ({ snippets: [...s.snippets, snippet] }));
get().select(snippet.id);
return snippet.id;
},
select: (id) =>
set((s) => ({
activeSnippetId: id,
draftSpec: s.snippets.find((x) => x.id === id)?.spec ?? '{}',
})),
remove: (id) =>
set((s) => {
const snippets = s.snippets.filter((x) => x.id !== id);
const activeSnippetId =
s.activeSnippetId === id ? (snippets[0]?.id ?? null) : s.activeSnippetId;
return { snippets, activeSnippetId };
}),
updateDraft: (draftSpec) => set({ draftSpec }),
reset: () => set({ snippets: [], activeSnippetId: null, draftSpec: '' }),
}));
```
The component is thin — it selects state narrowly and wires events to actions, with no
mutation logic of its own:
```tsx
export function SnippetList() {
const { snippets, activeSnippetId } = useSnippetStore(
useShallow((s) => ({ snippets: s.snippets, activeSnippetId: s.activeSnippetId })),
);
const select = useSnippetStore((s) => s.select); // stable identity — select actions individually
const remove = useSnippetStore((s) => s.remove);
// render: one row per snippet, each calling select(id) / remove(id) on the events.
}
```
> Note: action identities are stable, so selecting them (`s.select`) never causes
> re-renders — select actions individually rather than bundling them into a
> `useShallow` object.
### Why mutations live in the store
- **Testable without a DOM.** Actions are plain functions over state. A Vitest test
calls `useStore.getState().create('x')` and asserts on `getState()` — no
rendering, no React.
- **One place to change behavior.** "Deleting the active snippet falls back to the
first remaining one" is a rule that lives in `remove`, not scattered across every
delete button.
- **Readable components.** A component that only wires events to named actions reads
like a description of the UI, not a tangle of state juggling.
```ts
// SnippetStore.test.ts — no browser needed
import { useSnippetStore } from './SnippetStore';
beforeEach(() => useSnippetStore.getState().reset());
test('deleting the active snippet selects the next one', () => {
const store = useSnippetStore.getState();
const a = store.create('A');
const b = store.create('B');
store.select(a);
store.remove(a);
expect(useSnippetStore.getState().activeSnippetId).toBe(b);
});
```
> Rule: no `setState` calls inside component bodies for shared state — call an
> action. Local, throwaway UI state (a dropdown's open flag) may stay in component
> `useState`; anything another component reads belongs in a store behind an action.
### Change detection: reference identity, not serialization
Because every action replaces a state object via spread (never mutates it),
"has this changed since X" is **reference identity** against the object captured
at X. The Chart Builder's dataset switch keeps the exact config `init` produced
(`initialConfig`) and asks `config === initialConfig` to tell an untouched
opening default from built-on work. Field-level comparison against the source
record (the Theme Builder's draft-dirty selector) is the equivalent for forms
seeded from a saved record.
> Rule: never detect change by serializing and comparing
> (`JSON.stringify(a) === JSON.stringify(b)`) — it silently depends on key
> order, costs proportionally to state size, and a store that replaces objects
> immutably already has a cheaper, exact signal. The one sanctioned
> serialization is the modal coordinator's unsaved-change **snapshot**
> (architecture 03), where a cross-store, store-agnostic baseline is the point.
---
## 5. Effects: Persistence and External Sync
Cross-cutting reactions — persisting state, mirroring the theme onto the document,
pushing the draft into Vega for rendering — are wired once at app startup with
`store.subscribe(...)`, in the orchestration/startup layer, not in components.
Subscribers read state and write to `src/app/infrastructure/` adapters (IndexedDB,
`localStorage`, URL hash).
### Theme → document (the minimal example, already wired)
```ts
// src/app/orchestration/theme.ts (wired from main.tsx at startup)
const applyTheme = (t: string) => {
document.documentElement.dataset.theme = t;
};
applyTheme(useAppStore.getState().uiTheme);
useAppStore.subscribe((s, prev) => {
if (s.uiTheme !== prev.uiTheme) applyTheme(s.uiTheme);
});
```
The store stays DOM-free; the adapter (the `applyTheme` subscriber) lives at the edge.
### Adding a persisted UI preference (the established chain)
A small global preference (preview fit mode, chart theme) follows one chain — five touch
points, in order:
1. `core/settings.ts` — field on `UserSettings` + `defaultSettings()` + `loadSettings`
validation (an unknown stored value falls back to the default, never breaks the app).
2. `infrastructure/settings-store.ts` — slice loader/saver pair using the per-slice
write-through merge (doc 02 §5), so other writers of the shared record survive.
3. `stores/AppStore.ts` — field + setter (the store stays browser-free).
4. `orchestration/preferences.ts``initX()` hydrates the store from the adapter;
`wireX()` subscribes store → adapter.
5. `main.tsx` — call both before `createRoot().render` (the store is the single source
of truth from first paint).
The UI control only calls the AppStore setter; persistence follows from the subscriber.
The Monaco editor writes every keystroke into `draftSpec`. We do **not** persist on
every keystroke. A startup subscriber observes the draft and debounces the expensive
work:
```ts
// src/app/orchestration/snippet-persistence.ts
import { useSnippetStore } from '../stores/SnippetStore';
import { saveSnippet } from '../infrastructure/snippet-store'; // IndexedDB adapter
export function wireDraftAutoSave(): void {
let timer: ReturnType<typeof setTimeout> | undefined;
useSnippetStore.subscribe((s, prev) => {
if (s.draftSpec === prev.draftSpec) return; // only react to draft edits
const id = s.activeSnippetId;
if (!id) return;
clearTimeout(timer);
const spec = s.draftSpec;
timer = setTimeout(() => {
useSnippetStore.setState((cur) => ({
snippets: cur.snippets.map((x) => (x.id === id ? { ...x, spec } : x)),
}));
void saveSnippet(id, spec);
}, 400);
});
}
```
> For selector-based subscriptions (`subscribe(selector, listener)` with an equality
> function) add the `subscribeWithSelector` middleware to the store. Plain
> `subscribe((state, prev) => …)` as above is enough for most wiring.
> Rule: components never touch infrastructure adapters directly. Reads/writes to
> IndexedDB, `localStorage`, and the URL hash happen in startup subscribers or
> actions, so the persistence story is in one place and the UI stays pure.
---
## 6. Reading State: Import the Store, Don't Thread It
Because stores are singletons importable anywhere, a deep leaf component reads the
state it needs directly instead of receiving it through five layers of props.
```tsx
// Good: a deeply nested toggle reads + flips the theme itself.
import { useAppStore } from '../stores/AppStore';
export function ThemeToggle() {
const theme = useAppStore((s) => s.uiTheme);
const setTheme = useAppStore((s) => s.setTheme);
return (
<button onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}>
{theme === 'dark' ? '🌙' : '☀️'}
</button>
);
}
```
This is the right default for **global/shared** state. Threading `theme` and
`onThemeChange` through `Layout → Header → Toolbar → ThemeToggle` adds noise and
couples every intermediate component to data it doesn't use.
### When to thread props instead
- The value is **presentational input**, not shared app state. `<Button variant="primary">`
takes `variant` as a prop; it should not know about any store.
- The component is meant to be **reusable / store-agnostic** (design-system
components, list-item renderers given their item via prop).
- A parent supplies **per-instance** data, e.g. `<SnippetRow snippet={s} />` inside a
`.map()` — the row gets its snippet by prop but still calls
`useSnippetStore.getState().remove(...)` (or a selected action) for mutations.
> Rule of thumb: shared app state → select it from the store at the point of use.
> Per-instance or presentational data → pass it as a prop. Passing global state down
> as props is the anti-pattern to avoid.
---
## 7. Resetting State
Each store exposes a `reset()` action that returns its fields to initial values
(used on "new workspace", sign-out, or test teardown). Because every fact is a
single source field with no hand-maintained duplicates, reset is a flat `set(...)`
of the initial values (as in `SnippetStore` above); selector-derived values
recompute on their own.
---
## Rules Summary
**Do**
- Keep one state field per fact; derive everything else in selectors, not stored fields.
- In components, **select narrowly**; use `useShallow` for object/array selections.
Outside components, use `getState()` / `subscribe()`.
- Split durable domain state into feature stores (`useSnippetStore`, `useDatasetStore`,
`useUserSettingsStore`); keep `useAppStore` for thin cross-cutting UI state.
- Put every shared-state mutation behind a named action on the store so it's testable
without a DOM (`getState().action()`).
- Do persistence and external sync (IndexedDB, `localStorage`, URL hash, theme) in
startup `subscribe` listeners via `infrastructure/` adapters.
- Debounce expensive reactions (auto-save, re-render) inside the subscriber.
- Advance a snippet's `modified` on **every** save the library sorts by — draft
auto-save, inline name/comment edits, publish, revert, rename-propagation — so
Modified-descending keeps the just-touched snippet on top (spec §02 → Sort).
- Bump `SnippetStore.bufferEpoch` only on a _programmatic_ buffer load (select /
create / duplicate / revert / hydrate) — it is the "reload the editor, this isn't
a keystroke" signal consumed by both the Monaco buffer and the preview's
immediate-render path (arch 05 §5). Metadata edits (name/comment) advance
`modified` but must **not** bump it — they aren't in the spec buffer.
- Import singleton store hooks directly in the leaves that need shared state.
**Don't**
- Don't store derived values as their own fields and sync them by hand.
- Don't call `setState` for shared state inside component render bodies — call an action.
- Don't select broad objects without `useShallow` (causes needless re-renders).
- Don't let `useAppStore` accumulate feature-specific state; extract a store.
- Don't touch IndexedDB/`localStorage`/URL adapters from components.
- Don't thread global state down through props; don't pass per-instance or
presentational data via store imports.
- Don't detect change by serialize-and-compare; immutable replacement makes
reference identity the exact, cheap signal (§4 → Change detection).
-501
View File
@@ -1,501 +0,0 @@
# 02 · Persistence Architecture
How Astrolabe stores data in the browser, and the rules that keep that storage testable, portable, and safe to evolve. This document is the implementation contract for the persistence layer. For the _behavioral_ data model (what fields a Snippet or Dataset has, what the tiers hold), see [09 · Data Model & Persistence](../spec/09-data-model.md); this document covers _how the code is structured to implement it_.
---
## 1. The Infrastructure-Adapter Principle
**Rule: nothing outside `src/app/infrastructure/` ever touches `indexedDB`, `localStorage`, `window`, `location`, or `fetch` directly.** Every browser interaction (storage _and_ network) goes through a typed adapter module that exposes plain async functions returning domain objects.
```
src/
├── core/ # portable engine — NO browser APIs, NO React
├── app/
│ ├── stores/ # Zustand stores; calls infrastructure, never IDB/fetch
│ ├── services/ # business logic; calls infrastructure, never IDB/fetch
│ └── infrastructure/ # the ONLY place that imports indexedDB/localStorage/fetch
│ ├── snippet-store.ts # IndexedDB: snippets (metadata + drafts)
│ ├── dataset-store.ts # IndexedDB: datasets (heavy payloads)
│ ├── settings-store.ts # localStorage: UserSettings
│ ├── ux-prefs.ts # localStorage: app/UI prefs (sort, panel layout)
│ └── remote-data.ts # network: fetch a URL dataset's body (the ONLY fetch)
```
### Why this boundary exists
- **Testability.** Stores and services depend on a small typed surface (`getSnippet(id): Promise<Snippet | null>`), not on the IndexedDB request API. Tests mock the adapter, not a browser global. The adapters themselves are tested directly against `fake-indexeddb` / a localStorage stub in Vitest.
- **Portability.** `src/core/` stays free of browser APIs so the spec/parse/transform logic can run in Node (tests, future CLI, SSR). The adapters are the seam where the portable core meets the browser.
- **Single place for migrations.** Schema upgrades and record migrations live in exactly one module per store. A reader looking for "how does v1 data become v2 data" has one file to open, not a scattered set of `if (record.someOldField)` checks across the UI.
- **Failure containment.** Quota errors, corrupt JSON, and missing keys are handled at the boundary and converted into typed results (or sane fallbacks), so the rest of the app never sees a raw `DOMException`.
> **Do:** `import { saveSnippet } from '@/app/infrastructure/snippet-store'`
> **Don't:** `indexedDB.open(...)`, `localStorage.getItem(...)`, or `fetch(...)` anywhere in a component, store, or service.
### Background vs. interactive adapters
Most adapters are driven by **background subscribers** (arch 01 §5, _Effects_): a store changes, a startup subscriber writes it through to IndexedDB — the store never calls the adapter itself. The **network** adapter is the exception. Fetching a URL dataset is a user-initiated action with its own pending/error UI, so the **component** calls `remote-data.ts` directly (components may call adapters — cf. `navigator.clipboard`) and hands the fetched body to **pure** store actions (`DatasetStore.commitUrlSnapshot` / `refreshDataset`). The store never fetches, so it stays browser-free and unit-testable on already-fetched text.
> **Rule:** keep `fetch` behind `remote-data.ts`; orchestrate the URL-dataset fetch in the _component_ (busy state + the "paste data inline instead" recovery), not the store. Store commit actions only ever receive already-fetched text.
**URL-dataset snapshot lifecycle** (the files a change to it touches): add / Refresh → component fetches (`infrastructure/remote-data.ts`) → `core/dataset.snapshotFromText` shapes the body by sniffed format → `DatasetStore.commitUrlSnapshot` / `refreshDataset` snapshots + profiles it _exactly like inline data_ → render resolves it cached-first in `core/rendering.resolvedData` (a live-URL fallback applies only while a URL dataset is still unfetched). See spec §05 for the behavior.
A fetched body is held whole in memory and stored whole in IndexedDB, so `remote-data.ts` caps it at a fixed ceiling (`MAX_REMOTE_BYTES`): it rejects on the declared `Content-Length` _before_ reading, so an oversized download never lands in memory, with a body-length backstop for chunked responses that declare no length. The classified `too-large` reason maps to its own copy in `services/remote-data-errors.ts` — neither the inline-paste nor the retry recovery helps an oversized file, so that message points at using a smaller or pre-aggregated source instead.
---
## 2. IndexedDB Wrapper
IndexedDB's native API is event-based (`onsuccess`/`onerror`) and verbose. The adapter wraps it into promises and exposes a tiny CRUD surface per object store. Define one shared helper and build typed stores on top of it.
### 2.1 Opening the database
Open with an explicit **version number** and an `onupgradeneeded` handler that creates/upgrades object stores. The version is a monotonically increasing integer; bump it whenever the _store layout_ changes (a new object store, a new index). It is independent of per-record schema versions (§4).
```ts
// src/app/infrastructure/db.ts
const DB_NAME = 'astrolabe';
const DB_VERSION = 1;
let dbPromise: Promise<IDBDatabase> | null = null;
export function openDB(): Promise<IDBDatabase> {
// Memoize: opening is idempotent and cheap to share across calls.
if (dbPromise) return dbPromise;
dbPromise = new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, DB_VERSION);
req.onupgradeneeded = (event) => {
const db = req.result;
const oldVersion = event.oldVersion;
// Create stores idempotently — guard every create.
if (!db.objectStoreNames.contains('snippets')) {
db.createObjectStore('snippets', { keyPath: 'id' });
}
if (!db.objectStoreNames.contains('datasets')) {
db.createObjectStore('datasets', { keyPath: 'id' });
}
// Per-version store-layout migrations go here, gated on oldVersion.
// if (oldVersion < 2) { /* add index, split a store, ... */ }
void oldVersion;
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error ?? new Error('Failed to open IndexedDB'));
});
return dbPromise;
}
```
**Verify the layout, don't trust the version.** An interrupted upgrade can stamp
the new version without creating the new stores (observed in dev: a hot reload
opened a bumped `DB_VERSION` before the store-creation code for it existed) —
after which `onupgradeneeded` never fires again for that version and every
transaction on the missing store throws `NotFoundError`, permanently. The real
`openDB` therefore checks `db.objectStoreNames` against the expected store list
after every successful open and, if anything is missing, closes and reopens at
`db.version + 1` to force another (idempotent) upgrade pass. Two consequences:
the database **self-heals** instead of being stuck until manually deleted, and
the on-disk version may run **ahead of** `DB_VERSION` — so the open also
catches `VersionError` and retries without an explicit version. Covered by
`db.test.ts` (fake-indexeddb).
### 2.2 Promise-wrapped CRUD helpers
Wrap a single IDB request and a whole transaction so callers write linear `async/await` code.
```ts
// src/app/infrastructure/db.ts (continued)
function wrap<T>(req: IDBRequest<T>): Promise<T> {
return new Promise((resolve, reject) => {
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
async function tx<T>(
store: string,
mode: IDBTransactionMode,
run: (s: IDBObjectStore) => IDBRequest<T>,
): Promise<T> {
const db = await openDB();
return new Promise<T>((resolve, reject) => {
const transaction = db.transaction(store, mode);
const request = run(transaction.objectStore(store));
transaction.oncomplete = () => resolve(request.result);
transaction.onerror = () => reject(transaction.error);
transaction.onabort = () => reject(transaction.error);
});
}
export const get = <T>(store: string, key: IDBValidKey) =>
tx<T | undefined>(store, 'readonly', (s) => s.get(key) as IDBRequest<T | undefined>);
export const getAll = <T>(store: string) =>
tx<T[]>(store, 'readonly', (s) => s.getAll() as IDBRequest<T[]>);
export const put = <T>(store: string, value: T) =>
tx<IDBValidKey>(store, 'readwrite', (s) => s.put(value as any));
export const del = (store: string, key: IDBValidKey) =>
tx<undefined>(store, 'readwrite', (s) => s.delete(key) as IDBRequest<undefined>);
```
> **Do:** resolve on `transaction.oncomplete`, not on the request's `onsuccess` — the write is only durable once the transaction commits.
> **Don't:** hold an IndexedDB transaction open across an `await` to non-IDB work; transactions auto-close when the microtask queue drains and you'll get `TransactionInactiveError`.
---
## 3. Lazy Loading: Metadata vs Heavy Payloads
A snippet library can grow large, and **datasets can be megabytes each** (CSV text, parsed TopoJSON). Loading every dataset payload at startup just to render a list of names is wasteful and slow. The rule:
> **Store record metadata separately from large payloads. Load heavy data on demand. Treat `null` as "exists but not loaded yet" — distinct from absent.**
For Astrolabe this maps cleanly onto the two stores:
- **`snippets`** — snippet records are small (a spec is JSON text). They load eagerly as a set when the library opens.
- **`datasets`** — the `data` payload is the heavy part. The list view needs only the derived summary fields (`name`, `format`, `source`, `rowCount`, `columnCount`, `columns`, `size`, timestamps). Load `data` only when a snippet that references the dataset is actually previewed.
There are two ways to implement the split; pick per store:
1. **Two object stores** (`datasets` for metadata, `dataset-payloads` keyed by the same id for `data`) — strongest separation; a `getAll` on metadata never touches payload bytes.
2. **One store, lazy field** — keep `data` in the record but set it to `null` on the bulk list load and fetch it per-id on demand.
**Target, not yet built.** Datasets currently load in full via `loadDatasets()` (`infrastructure/dataset-store.ts`); the lazy-field approach below is the **intended direction** for when dataset size demands it, not a description of shipped code. The plan: keep `data` in the one store but set it to `null` on the bulk list load (`data === null` = "summary loaded, payload not yet") and fetch per-id on demand.
```ts
// src/app/infrastructure/dataset-store.ts
import { get, getAll, put } from './db';
import { migrateDataset, type Dataset } from './dataset-migrations';
/** List view: returns every dataset's summary, payload nulled out. */
export async function loadDatasetSummaries(): Promise<Dataset[]> {
const records = await getAll<Dataset>('datasets');
return records.map((r) => ({ ...migrateDataset(r), data: null }));
}
/** Detail/preview: load (or return cached) full payload for one dataset. */
export async function ensureDatasetData(dataset: Dataset): Promise<Dataset['data']> {
if (dataset.data !== null && dataset.data !== undefined) return dataset.data; // already loaded
const record = await get<Dataset>('datasets', dataset.id);
dataset.data = record?.data ?? null;
return dataset.data;
}
```
> **Do:** use `null` for "not loaded" and a real value (including `''` or `[]`) for "loaded but empty." The distinction prevents a re-fetch loop.
> **Don't:** overwrite a stored payload with `null` on save. When persisting a record whose `data` is `null` (never loaded into memory), skip writing the payload field and leave the stored bytes intact — otherwise a list-load-then-save round-trip silently destroys data.
---
## 4. Per-Record Schema Versioning & Read-Time Migration
The IndexedDB **database version** (§2.1) governs _store layout_. A separate **per-record `version` field** governs the _shape of an individual record_. Both Snippet and Dataset records carry `version` (and `created` / `modified` timestamps). This lets record shapes evolve without forcing an `onupgradeneeded` database bump for every field rename.
Migrations are applied **on read** — when a record comes out of the store, run it through a migration function that upgrades it to the current shape before the app sees it. New writes always store the current version.
```ts
// src/app/infrastructure/snippet-migrations.ts
export const CURRENT_SNIPPET_VERSION = 2;
export function migrateSnippet(raw: any): Snippet {
let r = { ...raw };
const v = r.version ?? 1; // records written before versioning existed are v1
if (v < 2) {
// Example: a v1 snippet had a single `spec`; v2 splits draft from published.
r.draftSpec = r.draftSpec ?? r.spec;
r.tags = r.tags ?? [];
r.datasetRefs = r.datasetRefs ?? [];
}
// if (v < 3) { ... }
r.version = CURRENT_SNIPPET_VERSION;
return r as Snippet;
}
```
```ts
// src/app/infrastructure/snippet-store.ts
export async function loadSnippets(): Promise<Snippet[]> {
const records = await getAll<any>('snippets');
return records.map(migrateSnippet); // upgrade every record at the boundary
}
export async function saveSnippet(s: Snippet): Promise<void> {
await put('snippets', {
...s,
version: CURRENT_SNIPPET_VERSION,
modified: new Date().toISOString(),
});
}
```
### Rationale
- **Read-time migration is forgiving.** Old records sitting untouched in the store keep working; they upgrade lazily the next time they're loaded and re-saved. There is no big-bang migration step that can fail halfway.
- **Tolerate unknown fields.** A migration normalizes _missing/old_ fields but must not strip fields it doesn't recognize — a record written by a _newer_ build that downgraded must round-trip without data loss. Spread the original (`{ ...raw }`) and only fill in what's missing.
- **One function, well tested.** Each migration step is a pure function over a plain object — trivial to unit-test with fixture records from each historical version.
> **Do:** default `version` to the earliest shape (`1`) when the field is absent.
> **Don't:** branch on the presence of individual fields scattered through the app to detect "old data." Centralize that knowledge in the migration function.
> **Mirror shape changes in the import normalizer.** Imported records are built from a file, not read from IndexedDB, so they **never pass through `migrate<Entity>`** — `core/import-normalize.ts` upgrades them independently. A migration that changes a field's _shape_ must be applied in both places or import produces a malformed record. (E.g. the dataset v1→v2 URL-snapshot reshaping — address moves from `data` into `url`, `data` cleared — lives in **both** `migrateDataset` and `normalizeDataset`.)
---
## 5. localStorage Preferences (Settings & App/UI Prefs)
Small, frequently-read structured records live in `localStorage`, not IndexedDB: **UserSettings** (one record) and **app/UI preferences** (snippet sort, panel layout). Why split them from `UserSettings`? UI prefs change often (drag a panel divider, toggle a sort) and shouldn't force a rewrite of the whole settings blob on every interaction.
The pattern is **load-with-fallback, per-slice write-through merge.**
- **Load-with-fallback:** merge the parsed stored object over a complete `DEFAULTS` constant. Missing keys (a setting added in a later build) and malformed JSON silently fall back to defaults — the app always gets a fully-populated object and never `undefined`-crashes on a new field. For `UserSettings`, the normalization is **pure and lives in `@core/settings` (`loadSettings(raw)` / `defaultSettings()`)** — it clamps ranges and validates enums; the infra adapter is just the thin localStorage reader (`loadUserSettings()` = `loadSettings(readRaw())`).
- **Per-slice write-through merge:** every update reads current, merges in **just its slice**, and writes back. **The one `astrolabe:settings` record has multiple independent writers**, because settings are distributed and live-applied (spec §07 — no central save, no Apply step): the header theme toggle writes `ui.theme`, the preview Fit control writes `ui.previewFitMode`, the preview Chart-theme picker writes `ui.chartTheme`, and the per-pane settings clusters write `editor`/`performance`/`formatting`. **A writer that replaced the whole record would clobber the slices it doesn't own** — so each must field-merge. (There is deliberately no whole-record `saveSettings`.)
- **Environment-guarded:** `localStorage` is absent or throws in some test/SSR contexts; guard access and degrade to defaults rather than throwing.
```ts
// src/app/infrastructure/settings-store.ts
const KEY = 'astrolabe:settings';
export const CURRENT_SETTINGS_VERSION = 1;
export interface UserSettings {
version: number;
editor: {
fontSize: number;
theme: string;
minimap: boolean;
wordWrap: 'on' | 'off';
lineNumbers: 'on' | 'off';
tabSize: number;
};
performance: { renderDebounce: number };
ui: {
theme: 'light' | 'dark';
previewFitMode: 'default' | 'width' | 'height' | 'full';
chartTheme: ChartThemeId; // 'astrolabe' | 'stock' | vega-themes preset id
};
formatting: { dateFormat: 'smart' | 'iso' | 'custom'; customDateFormat: string };
}
// 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: {
fontSize: 12,
theme: 'auto',
minimap: false,
wordWrap: 'on',
lineNumbers: 'on',
tabSize: 2,
},
performance: { renderDebounce: 1500 },
ui: { theme: 'light', previewFitMode: 'default', chartTheme: 'astrolabe' },
formatting: { dateFormat: 'smart', customDateFormat: '' },
};
// NOTE — editor.theme default is 'auto': the editor theme follows the app UI
// theme (light -> light editor theme, dark -> dark) via custom Monaco
// themes that match the app chrome, unless the user picks an explicit override.
// The explicit-override option set (custom themes; whether to include High
// Contrast or the stock Monaco themes) is still TBD — see spec §07's provisional
// editor-theme note. Resolve the `'auto'` sentinel to a concrete Monaco theme at
// editor-config time, keyed off the current UI theme.
function available(): boolean {
try {
return typeof localStorage !== 'undefined' && typeof localStorage.getItem === 'function';
} catch {
return false; // access itself can throw (e.g. blocked storage)
}
}
export function loadSettings(): UserSettings {
if (!available()) return structuredClone(DEFAULTS);
try {
const raw = localStorage.getItem(KEY);
if (!raw) return structuredClone(DEFAULTS);
const p = JSON.parse(raw);
// Deep-merge each group over defaults so new keys fall back silently.
return {
version: CURRENT_SETTINGS_VERSION,
editor: { ...DEFAULTS.editor, ...p.editor },
performance: { ...DEFAULTS.performance, ...p.performance },
ui: { ...DEFAULTS.ui, ...p.ui },
formatting: { ...DEFAULTS.formatting, ...p.formatting },
};
} catch (err) {
console.warn('[settings] failed to load, using defaults', err);
return structuredClone(DEFAULTS);
}
}
// No whole-record save — each live control merges only its slice into the shared
// record, so the others survive (see the per-slice rule above):
// saveUiTheme(theme) -> { ...current, ui: { ...current.ui, theme } }
// savePreviewFitMode(mode) -> { ...current, ui: { ...current.ui, previewFitMode } }
// saveChartTheme(chartTheme) -> { ...current, ui: { ...current.ui, chartTheme } }
// saveManagedSettings(managed) -> { ...current, editor, performance, formatting }
```
> The above sketch keeps the `DEFAULTS`/merge shape inline for illustration, but
> the **authoritative** defaults + normalization now live in `@core/settings`
> (pure); the infra adapter calls them and owns only the localStorage IO + the
> slice writers. Keep the two in sync via that one core source, not a second copy here.
App/UI prefs follow the identical guard+fallback pattern, but live in **one
record under their own key**, `astrolabe:ux-prefs` (the snippet sort and the
panel layout together — the plan's "ux-prefs for sort + panel layout", §09D):
```ts
// src/app/infrastructure/ux-prefs.ts
const KEY = 'astrolabe:ux-prefs'; // { panelLayout: { libraryWidth, previewWidth }, sort: { … } }
// loadPanelLayout()/savePanelLayout() (and sort later) mirror §5's guard+fallback shape,
// merging the changed section so a frequent write (a drag) never clobbers the others.
```
> One nuance vs. the settings record: the panel-layout widths are **validated**
> (positive finite numbers) and surfaced as `undefined` when absent/invalid; the
> **defaults live in the `PanesStore`** (`PANE_DEFAULT`), applied at `hydrate`,
> rather than merged in the adapter. Persistence of the live drag is **debounced**
> in `orchestration/panes.ts` (a drag emits an update per pointer move).
> **Do:** keep a single complete `DEFAULTS` object as the source of truth and merge over it.
> **Don't:** read individual keys with bespoke `?? fallback` at each call site; one stale default and the shapes drift.
> **Testing:** exercise localStorage adapters against an **injected stub** (`vi.stubGlobal('localStorage', …)`), not the ambient global. Under Node + happy-dom a non-functional Node `localStorage` global shadows happy-dom's, so relying on the ambient one fails with `localStorage.clear is not a function`. Applies to every prefs/settings adapter test (settings-store today; dataset-payload/prefs stores later).
---
## 6. Storage Tiers & the Composition Monitor
Astrolabe has three tiers with different capacities and risk profiles:
| Tier | Backing | Holds | Behavior |
| -------------------- | -------------------- | ---------------------------------- | --------------------------------------------------------------------------------------------------------- |
| **Snippet store** | IndexedDB `snippets` | All snippet records | Snippets are user-authored and irreplaceable, so writes fail **loudly** on quota (below). |
| **Dataset store** | IndexedDB `datasets` | All dataset payloads | Separate, **high-capacity**; suited to large payloads. Loaded in full today; lazy loading is a §3 target. |
| **Settings & prefs** | localStorage | `UserSettings` + app/UI prefs (§5) | Small; effectively unbounded for this use. |
Splitting snippets and datasets into separate stores means a few large datasets can't crowd out snippets, and lets the storage monitor break usage down **by tier**.
### Composing the storage breakdown
The monitor shows what storage is **made of** — Snippets · Datasets · App — not a "used of quota" gauge. The browser's `quota` is a padded, deliberately fuzzed approximation, not a real free-space figure (web.dev → _storage-for-the-web_), so a budget fraction is false precision. We use only the reliable `usage` (bytes actually stored for the origin) and measure our own tiers, deriving the rest:
- **Snippets / Datasets** — measured directly: snippets serialized (`jsonByteSize`), datasets summed from each record's `size`. Always available, no API needed.
- **App** = `usage snippets datasets` — the precached app shell + IndexedDB overhead. Shown only when the Storage Manager API yields `usage`; otherwise the breakdown is just snippets + datasets.
- **Hidden below a floor.** The whole monitor stays hidden until _user_ data (snippets + datasets) reaches `STORAGE_MONITOR_MIN_USER_BYTES` (10 MB). Keyed off user bytes, not total: the ~precache baseline is roughly constant, so gating on total would make it always-visible and the bar App-dominated.
The pure summarizer lives in core (unit-tested without a browser); the adapter only does I/O.
```ts
// src/core/storage-estimate.ts — pure, unit-tested (no I/O)
export interface StorageComposition {
segments: { key: 'snippets' | 'datasets' | 'app'; label: string; bytes: number }[];
totalBytes: number; // origin usage when measured, else snippets + datasets
originMeasured: boolean; // false when the estimate API is absent
}
export function summarizeStorage(input: {
usageBytes?: number;
snippetBytes: number;
datasetBytes: number;
}): StorageComposition {
/* app = usage known, included only when usage ≥ known (else estimate lag) */
}
export function jsonByteSize(value: unknown): number; // UTF-8 bytes of JSON.stringify(value)
// src/app/infrastructure/storage-estimate.ts — the only browser-touching part
export async function readOriginUsage(): Promise<number | undefined> {
const storage = typeof navigator !== 'undefined' ? navigator.storage : undefined;
if (!storage || typeof storage.estimate !== 'function') return undefined;
const { usage } = await storage.estimate(); // quota deliberately ignored
return typeof usage === 'number' ? usage : undefined;
}
```
**The bar is decorative; the legend is the data.** There is no `role="meter"` — a meter needs a meaningful maximum, which a composition with no fixed ceiling lacks (APG → meter). The legend's text labels + sizes are the accessible source of truth, so meaning never rests on hue (WCAG 1.4.1). There are no "almost full" percentage thresholds — a fraction would key off the untrustworthy quota; the genuine out-of-room event surfaces at save time (below), where it is accurate.
### Fail loudly, never silently lose data
When a write would exceed quota, IndexedDB rejects with a `QuotaExceededError`. Quota is
whole-origin, so it's normalized **once** at the single write path — `db.put` — into a typed
`StorageQuotaError`. Every typed adapter inherits fail-loud behavior without repeating the
check, and consumers branch on the type instead of sniffing a `DOMException`.
```ts
// src/app/infrastructure/db.ts — the one write path
export const put = <T>(store: string, value: T): Promise<IDBValidKey> =>
tx<IDBValidKey>(store, 'readwrite', (s) => s.put(value)).catch((err: unknown) => {
if (err instanceof DOMException && err.name === 'QuotaExceededError') {
throw new StorageQuotaError(); // never silently drop the write
}
throw err;
});
```
> **Do:** surface quota warnings _before_ the budget is hit (the 80% threshold) and hard errors loudly when a write fails.
> **Don't:** wrap a save in a bare `try/catch {}` that logs and returns — that turns "your work wasn't saved" into a silent data-loss bug. The only thing that may safely swallow an error is a _read_ failure, where falling back to defaults/empty is the correct behavior.
> **Rule:** every write goes through `db.put`. An adapter that opens its own `tx(store, 'readwrite', …)` instead bypasses quota normalization and silently loses the typed `StorageQuotaError` — a regression the type system won't catch.
**The adapter propagating is only half — a consumer must catch and surface it.** A
fire-and-forget `void saveSnippet(n)` re-buries the very error the adapter took care to
throw. Persistence write-backs are wired as store subscribers, so the surfacing path is:
`orchestration/snippet-persistence.ts` (write-through `.catch`) / `orchestration/startup.ts` (load
`.catch`, then run in memory) → `services/storage-errors.ts` (pure error→message mapper) →
`notify()` (`stores/NotificationStore`) → `Toaster`.
Rules this encodes (spec §10 "told when a save fails"): never `void`-fire a persist without
a `.catch` that maps the error to a toast — `storageErrorNotification(op, err)` for snippets
(bespoke "your library" / "your changes" copy), `entityStorageErrorNotification(noun, op, err)`
for the other tiers (the same shape with the entity's own noun). The mapper splits user-fixable
(storage full → next step, no diagnostic) from not (blocked storage → plain explanation **+** a
reportable `detail`); and a blocked store at startup **warns and runs in memory** rather than
rejecting into the void.
### Multi-record writes (import): atomicity at the service boundary
`db.ts` exposes only per-store request/transaction helpers — there is **no wrapper for a
single transaction spanning many records across stores**. So a bulk operation like **import**
(`services/transfer.ts`) cannot be truly atomic at the IDB layer; it achieves atomicity at
the **service boundary** instead: write the new records to IndexedDB first, tracking what
succeeded, and only on full success commit to the Zustand stores. On any write failure
(typically `QuotaExceededError`) it **rolls back best-effort** — deletes the records written
so far (`Promise.allSettled`) and removes any datasets and custom themes already added to
their stores (their persistence subscribers propagate the removals to IDB) — so the
spec §08 "no partial import is committed" contract holds and the user gets an actionable
"storage full, delete and retry" message.
> **Rule:** for any operation that persists multiple records, write-then-commit and roll
> back on failure — never mutate the in-memory stores before the writes are known to have
> landed (a half-merged workspace is worse than a failed import).
> **Limit:** rollback is best-effort, not transactional; if the rollback deletes themselves
> fail, orphan records can remain (invisible to the app — never added to a store — and
> cleaned up on the next successful write). True cross-record atomicity would require
> exposing a raw multi-store transaction from `db.ts`; defer that until a second multi-record
> writer needs it.
---
## 7. Checklist for Adding a New Persisted Entity
1. Define the record type with `id`, `created`, `modified`, and a `version` field.
2. Decide the tier: small + critical → IndexedDB store with a monitored budget; large payload → separate high-capacity store with lazy loading (§3); tiny + frequently changing → localStorage pref (§5).
3. Add the object store in `openDB`'s `onupgradeneeded`, guarded by `contains(...)`; bump `DB_VERSION` only if you changed store _layout_.
4. Add a `migrate<Entity>()` function and call it on every read.
5. Expose typed `load*/save*/ensure*` functions from one infrastructure module — and from _only_ there.
6. Add the app layer: a Zustand store whose low-level `add`/`update`/`remove` are the single mutation point for the collection, and a **write-through subscriber** in `orchestration/` — a thin wrapper over the shared `wireEntityWriteThrough(store, select, { save, remove, onError })` helper (`entity-persistence.ts`), which diffs the array against the previous snapshot, upserts changed records, deletes missing ones, and toasts on failure.
7. Hydrate in `orchestration/startup.ts` and wire the subscriber **after** hydrate — wiring first would re-save every loaded record on each startup.
8. Quota propagation is automatic (`db.put` throws `StorageQuotaError`) — just pass an `onError` that maps it via `entityStorageErrorNotification(noun, …)`. If the tier has a budget, also hook it into the storage monitor.
9. Test the adapter against `fake-indexeddb` / a localStorage stub; test the migration with fixtures from each historical version.
The stack for one entity is four files with fixed roles: `infrastructure/<entity>-store.ts` (typed IDB adapter) + `infrastructure/<entity>-migrations.ts` (read-time upgrade) + `stores/<Entity>Store.ts` (in-memory collection + feature state) + `orchestration/<entity>-persistence.ts` (write-through, a thin call to the shared `wireEntityWriteThrough`), joined in `startup.ts`. Snippets, datasets, custom themes, and user fonts each follow it.
-639
View File
@@ -1,639 +0,0 @@
# 03 · Modal System
How Astrolabe manages its modals: a single metadata-driven registry, a thin
lifecycle coordinator, and one rendering shell. This document is the
authoritative architecture for adding, opening, closing, and rendering modals.
## Goals
- **One source of truth** for modal metadata — no `switch` statements scattered
across the codebase keyed on the active modal.
- **At most one modal open at a time** (mandated by the product spec). Opening a
modal closes any other; the two never overlap.
- **Uniform dismissal**: close button, `Escape`, or backdrop click — never a
click inside the body. A modal holding in-progress work can opt out of the
**backdrop** click (`dismissOnBackdrop: false`) so a stray click can't discard it
(the Chart Builder does); close button and `Escape` still dismiss.
- **Accessible by default**: focus moves into the modal on open and returns to
the trigger on close.
- **Unsaved-change safety** for editing modals, with an explicit opt-out for
modals that apply changes immediately.
The system is three layers, each with a single responsibility:
| Layer | Responsibility | Lives in |
| --------------- | ----------------------------------------------------------- | ----------------------------------------- |
| **Registry** | Static metadata per modal (title, validity, snapshot, init) | `src/app/modals/modal-registry.ts` |
| **Coordinator** | Lifecycle: open, close, URL sync, change detection | `src/app/modals/ModalCoordinator.ts` |
| **Shell** | Render exactly one modal; backdrop / Escape / focus trap | `src/app/App.tsx` + a `useFocusTrap` hook |
---
## The Modal Set
Astrolabe has a small, fixed set of modals. Model it as a closed union so the
registry, coordinator, and shell are exhaustively type-checked.
```ts
// src/app/modals/types.ts
export type ModalName =
| 'datasets' // Datasets manager (list / detail / new-dataset form)
| 'about' // About & Help (shortcuts, privacy)
| 'donate' // Donate
| 'chartBuilder' // Visual no-JSON chart composition for a dataset
| 'extract'; // Extract inline spec data into a new dataset
// Settings is NOT a modal — preferences are distributed to per-pane disclosure
// popovers (spec §01C/§07; see components/SettingsPopover).
export type ActiveModal = ModalName | null;
```
Two of these — `chartBuilder` and `extract` — are **opened from within
workflows** (the Datasets manager and the snippet editor), not from the header
toolbar. That is a UI wiring detail, not a structural one: every modal opens
through the same coordinator regardless of where the trigger lives.
---
## Layer 1 — Registry
Each modal is registered once with its metadata. The registry is a plain lookup
object keyed by `ModalName`; order is irrelevant. Utility queries
(title, validity, whether a modal participates in URL state) read from the
registry so there is exactly one place to change when behavior shifts.
### Config shape
```ts
// src/app/modals/modal-registry.ts
import type { ComponentType } from 'react';
import type { ModalName } from './types';
export interface ModalConfig {
name: ModalName;
title: string; // i18n key or literal
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/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
* changes on close. OMIT for modals with no in-progress edits to guard
* (about, donate) — omission opts out of the discard-confirmation. */
getState?: () => Record<string, unknown> | null;
/** Whether the modal's primary action (Save / Apply) should be blocked
* because the current input is invalid. Drives the disabled button. */
hasError?: () => boolean;
/** Human-readable reason for the disabled action, shown as a tooltip. */
getError?: () => string | null;
/** Whether this modal is reflected in the URL hash (back/forward, reload
* restore). Datasets and Chart Builder are navigable; About/Donate/Extract
* are not (spec §01E). */
isUrlNavigable?: boolean;
}
```
> **Shipped divergence.** The implemented registry (`modals/modal-registry.ts`) **omits
> `hasError`/`getError`**: each modal renders its **own action row** inside its body (the
> multi-view Datasets manager doesn't fit a single shell-level Save/Cancel), so validity is
> each modal's own concern. The shipped `ModalConfig` keeps only `getState` (close-time
> unsaved-change detection) plus `init`/`isUrlNavigable`. It also **omits `component`**:
> the name → component map lives in the shell (`components/ModalShell` →
> `MODAL_COMPONENTS`), its only consumer — a registry that imported components would close
> an import cycle (coordinator → registry → component → coordinator, since modal bodies
> call `closeModal`). The generic-footer sketch through the rest of this section is
> retained as the simpler pattern for a single-action modal — treat it as illustrative,
> not a description of current code.
### Example entries
```ts
import { DatasetsModal } from '../components/DatasetsModal';
import { ChartBuilderModal } from '../components/ChartBuilderModal';
import { ExtractModal } from '../components/ExtractModal';
import { DonateModal } from '../components/DonateModal';
import { AboutModal } from '../components/AboutModal';
import { useDatasetStore } from '../stores/DatasetStore';
import { useChartBuilderStore } from '../stores/ChartBuilderStore';
import { useExtractStore } from '../stores/ExtractStore';
// Per-modal transient state lives in the relevant feature store; the registry
// reads it via `getState()` (Zustand), never through component hooks.
export const MODAL_REGISTRY: Record<ModalName, ModalConfig> = {
// Navigable, editing modal — snapshot guards unsaved work.
datasets: {
name: 'datasets',
title: 'modals.datasets.title',
component: DatasetsModal,
isUrlNavigable: true,
init: (datasetId) => useDatasetStore.getState().select(datasetId ?? null),
getState: () => {
const s = useDatasetStore.getState();
return {
view: s.view, // 'list' | 'detail' | 'new'
draft: s.draftForm, // in-progress new/edit form
};
},
hasError: () => useDatasetStore.getState().formError !== null,
getError: () => useDatasetStore.getState().formError,
},
// Opened from a workflow (a specific dataset), navigable, editing.
chartBuilder: {
name: 'chartBuilder',
title: 'modals.chartBuilder.title',
component: ChartBuilderModal,
isUrlNavigable: true,
init: (datasetId) => useChartBuilderStore.getState().initFor(datasetId),
getState: () => ({ encoding: useChartBuilderStore.getState().encoding }),
hasError: () => !useChartBuilderStore.getState().markType,
getError: () =>
useChartBuilderStore.getState().markType ? null : 'modals.chartBuilder.pickMark',
},
// Opened from the snippet editor with the inline data to lift out.
extract: {
name: 'extract',
title: 'modals.extract.title',
component: ExtractModal,
init: (sourceKey) => useExtractStore.getState().initFrom(sourceKey),
getState: () => ({ name: useExtractStore.getState().name }),
hasError: () => useExtractStore.getState().name.trim() === '',
getError: () => (useExtractStore.getState().name.trim() ? null : 'modals.extract.nameRequired'),
},
// Pure info modals — no state, no validity, not navigable.
about: { name: 'about', title: 'modals.about.title', component: AboutModal },
donate: { name: 'donate', title: 'modals.donate.title', component: DonateModal },
};
```
### Registry queries
All callers go through these helpers instead of inspecting the active modal
directly:
```ts
export const getModalConfig = (name: ActiveModal): ModalConfig | undefined =>
name ? MODAL_REGISTRY[name] : undefined;
export const getModalTitle = (name: ActiveModal): string => getModalConfig(name)?.title ?? '';
export const isUrlNavigable = (name: ActiveModal): boolean =>
getModalConfig(name)?.isUrlNavigable ?? false;
```
> **Why metadata-driven?** The alternative — branching on the active modal in
> the shell, the URL sync, the keyboard handler, and the close logic — spreads
> one decision across four files. Each new modal then means four edits and a
> chance to forget one. With the registry, a new modal is one entry plus its
> component.
**Do**
- Add a modal by appending one `MODAL_REGISTRY` entry and writing its component.
- Express validity through `hasError` / `getError` so the shell's action button
and tooltip stay generic.
- Omit `getState` for any modal that commits changes immediately.
**Don't**
- Don't `switch (activeModal)` outside the shell's body render. Lookups belong
in registry helpers.
- Don't put rendering or DOM concerns in the registry — it is pure metadata.
- Don't read rapidly-changing input state inside `hasError`/`getState` from
component render paths; compute them with a selector at the shell boundary (see
Shell layer) so a keystroke doesn't re-render the whole app.
---
## Layer 2 — Coordinator
The coordinator owns the modal lifecycle. It mutates a single piece of state —
the active modal name — plus a snapshot used for change detection, and keeps the
URL in sync. It is framework-light: pure functions over a Zustand store, unit
testable without a DOM.
### State
The active modal name is **cross-cutting UI chrome**, so it lives on the central
`useAppStore` (`activeModal` + the `setActiveModal` primitive — see
docs/architecture/01). The coordinator never gets its own store; the only extra
piece of state it needs is the change-detection **snapshot**, which is
coordinator-internal (no component reads it), so it stays as a module-local
variable rather than store state.
```ts
// useAppStore already exposes:
// activeModal: ModalName | null
// setActiveModal: (modal: ModalName | null) => void
```
### Open / close
```ts
// src/app/modals/ModalCoordinator.ts
import { useAppStore } from '../stores/AppStore';
import { MODAL_REGISTRY, getModalConfig } from './modal-registry';
import { syncModalToUrl, clearModalFromUrl } from './UrlStateSync';
let confirmDiscard: (msg: string) => Promise<boolean> = async () => true;
export const setConfirm = (fn: typeof confirmDiscard) => {
confirmDiscard = fn;
};
// Coordinator-internal: the getState() JSON captured at open, compared on close.
let stateSnapshot: string | null = null;
const snapshot = (name: ActiveModal) =>
getModalConfig(name)?.getState ? JSON.stringify(getModalConfig(name)!.getState!()) : null;
/** Open `name`, optionally with a sub-target (dataset id, source key). */
export function openModal(name: ModalName, arg?: string): void {
// Opening any modal replaces the previous one — at most one open at a time.
useAppStore.getState().setActiveModal(name);
getModalConfig(name)?.init?.(arg);
stateSnapshot = snapshot(name);
syncModalToUrl(name, arg); // no-op when !isUrlNavigable
}
/** Close the active modal. Prompts on unsaved changes unless `force`. */
export async function closeModal(force = false): Promise<void> {
const name = useAppStore.getState().activeModal;
if (!name) return;
if (!force && hasUnsavedChanges()) {
const ok = await confirmDiscard('modals.discardChanges');
if (!ok) return;
}
clearModalFromUrl(name);
useAppStore.getState().setActiveModal(null);
stateSnapshot = null;
getModalConfig(name)?.init?.(undefined); // optional: reset transient state
}
/** Cmd/Ctrl+K toggle for the Datasets manager. */
export function toggleDatasets(): void {
if (useAppStore.getState().activeModal === 'datasets') void closeModal();
else openModal('datasets');
}
```
> **Pre-hydrate variant — open without `init`.** When a modal must open onto state the
> caller already loaded (the Chart Builder's _Open in builder_ edit flow — `openChartBuilderForEdit`
> hydrates the builder from a snippet first), the opener sets the active modal, snapshots, and
> syncs the URL **itself** and **skips the registry `init`** — running `init` would re-derive the
> default state and clobber the hydration. Such an opener is the exception, not the rule: ordinary
> opens go through `openModal` so `init` is the single place transient state is seeded.
### Change detection
```ts
export function hasUnsavedChanges(): boolean {
const name = useAppStore.getState().activeModal;
if (!name || stateSnapshot === null) return false; // no snapshot ⇒ opted out
const current = getModalConfig(name)?.getState?.();
if (current == null) return false;
return JSON.stringify(current) !== stateSnapshot;
}
```
The snapshot is taken once on open and compared on close. Modals without
`getState` (about, donate) snapshot to `null`, so `hasUnsavedChanges`
short-circuits and they close instantly — correct, because they hold nothing to
lose.
### Validity passthrough
```ts
export const activeModalHasError = (): boolean =>
getModalConfig(useAppStore.getState().activeModal)?.hasError?.() ?? false;
export const activeModalError = (): string | null =>
getModalConfig(useAppStore.getState().activeModal)?.getError?.() ?? null;
```
> **Why a coordinator instead of letting components open/close themselves?**
> Centralizing means the "close the previous one", snapshot, URL-sync, and
> discard-prompt rules are enforced once. A component that opened a peer modal
> directly could bypass the discard check or leave the URL stale.
**Do**
- Route every open/close through `openModal` / `closeModal`.
- Take the snapshot in `openModal` (after `init`) and compare in `closeModal`.
- Keep the coordinator DOM-free so it can be tested with plain Vitest.
**Don't**
- Don't mutate `activeModal` directly from components or handlers.
- Don't skip `closeModal`'s unsaved-change check by toggling state manually;
pass `force` only when the user has explicitly saved or confirmed.
---
## Layer 3 — Shell
`App` renders **exactly one** modal — whichever `activeModal` names — inside a
single reusable shell. The shell provides the backdrop, header, focus trap, and
the generic close/action affordances; the modal's registered `component` fills
the body.
```tsx
// src/app/App.tsx (modal portion)
import { useAppStore } from '../stores/AppStore';
import { getModalConfig, getModalTitle } from '../modals/modal-registry';
import { closeModal, activeModalHasError, activeModalError } from '../modals/ModalCoordinator';
import { useFocusTrap } from '../hooks/useFocusTrap';
export function App() {
const name = useAppStore((s) => s.activeModal);
const config = getModalConfig(name);
// Move focus into the modal on open, return it to the trigger on close.
const modalRef = useFocusTrap<HTMLDivElement>(name !== null);
// Derived at the shell boundary so per-keystroke store reads don't
// re-render the whole app tree.
const hasError = activeModalHasError();
const errorMsg = activeModalError();
return (
<div className={styles.app}>
{/* ...library · editor · preview panes, header... */}
{config && (
<div
className={styles.backdrop}
onClick={() => void closeModal()} // backdrop dismisses
onKeyDown={(e) => {
if (e.key === 'Escape') void closeModal();
}}
>
<div
ref={modalRef}
className={styles.modal}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
onClick={(e) => e.stopPropagation()} // inside body never dismisses
>
<header className={styles.modalHeader}>
<h2 id="modal-title">{t(getModalTitle(name))}</h2>
<button aria-label={t('buttons.close')} onClick={() => void closeModal()}>
×
</button>
</header>
<div className={styles.modalBody}>
{/* The ONE place the active modal is mapped to a component. */}
<config.component />
</div>
{/* Optional generic action row for editing modals. A modal with no
primary action (about, donate) can render its own footer/none. */}
{config.getState && (
<footer className={styles.modalFooter}>
<button className="btn-secondary" onClick={() => void closeModal()}>
{t('buttons.cancel')}
</button>
<button
className="btn-primary"
aria-disabled={hasError || undefined}
title={errorMsg ? t(errorMsg) : undefined}
onClick={() => {
if (!hasError) config.component /* invoke save handler */;
}}
>
{t('buttons.save')}
</button>
</footer>
)}
</div>
</div>
)}
</div>
);
}
```
Rendering `<config.component />` from the registry is the only modal-name→view
mapping in the app. There is no `name === 'datasets' && <DatasetsModal/>` chain.
### Focus trap
A small hook saves the previously focused element, focuses a focusable child on
open, wraps `Tab`/`Shift+Tab` within the modal, and restores focus on close. The
optional `initialSelector` picks _which_ child takes focus (e.g. Cancel for a
destructive confirm); it falls back to the first focusable child.
The trap wraps Tab **only within the shell element**: content portaled to
`<body>` (a `SelectControl` panel or other disclosure popover opened from inside
a modal) is outside both the trap's DOM subtree and its keydown listener. Such
popovers must therefore handle Tab themselves — close and refocus their trigger
(the native-select convention) — so focus can't strand outside the dialog while
it is open.
```ts
// src/app/hooks/useFocusTrap.ts
import { useRef, useEffect } from 'react';
const FOCUSABLE =
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), ' +
'textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
export function useFocusTrap<T extends HTMLElement = HTMLDivElement>(
active: boolean,
initialSelector?: string,
) {
const ref = useRef<T>(null);
const returnTo = useRef<Element | null>(null);
useEffect(() => {
const el = ref.current;
if (!active || !el) return;
returnTo.current = document.activeElement;
const initial =
(initialSelector ? el.querySelector<HTMLElement>(initialSelector) : null) ??
el.querySelector<HTMLElement>(FOCUSABLE);
initial?.focus();
const onKey = (e: KeyboardEvent) => {
if (e.key !== 'Tab') return;
const f = el.querySelectorAll<HTMLElement>(FOCUSABLE);
if (!f.length) return;
const first = f[0],
last = f[f.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
};
el.addEventListener('keydown', onKey);
return () => {
el.removeEventListener('keydown', onKey);
(returnTo.current as HTMLElement | null)?.focus(); // restore focus on close
};
}, [active, initialSelector]);
return ref;
}
```
> **Why one shell instead of each modal rendering its own chrome?** Backdrop
> behavior, the focus trap, `aria-modal`, Escape handling, and the close button
> are identical for every modal and easy to get subtly wrong (e.g. a backdrop
> that dismisses on inner clicks). Centralizing guarantees consistency and means
> accessibility is fixed once.
**Do**
- Render the active modal via `<config.component />` — the single mapping point.
- Put `onClick={closeModal}` on the backdrop and `stopPropagation` on the body.
- Compute `hasError`/`getError`/preview reads with a selector at the shell level.
- Gate the generic Save button on `hasError` and surface `getError` as its
tooltip.
**Don't**
- Don't render two modals simultaneously, and don't stack a second backdrop.
- Don't attach the focus trap to the backdrop — attach it to the modal body so
the backdrop click stays outside the trap.
- Don't dismiss on clicks inside the body, and don't let Escape fire when no
modal is open (the handler only exists while a modal renders).
**Sizing & backdrop opt-out.** The shell picks a **size tier** by modal: a small form
(Extract), a large two-pane manager (Datasets), or a near-fullscreen **work surface**
(Chart Builder — a config pane plus a chart that wants room). The two larger tiers have a
definite height so their inner panes scroll **internally** rather than the modal growing
past the viewport. A modal opts a backdrop click out of dismissal with the registry's
`dismissOnBackdrop: false` (above).
---
## Confirmation & alert dialogs
The registry/coordinator/shell above governs the **named feature modals** — a
fixed, registered, URL-navigable set with "at most one open at a time". A
destructive **confirmation** ("Delete _Name_? This cannot be undone.") is a
different animal and gets a **separate, lighter layer** rather than a `ModalName`
entry. Three properties force the split:
- **Ephemeral & content-on-call.** A confirm isn't a fixed surface with a stored
component; its title/message/labels are supplied at the call site. There's
nothing to register.
- **Stacks _above_ a feature modal.** The discard-changes prompt must appear over
an already-open feature modal (e.g. Datasets or Chart Builder) — which directly
violates the feature layer's "at most one open" rule. So confirmations live on a higher z-layer
(`z-index: 1000`, above the future modal shell).
- **Not navigable.** A confirmation is never a URL destination or a reload-restore
target; it only exists for the duration of one decision.
### The primitive
A promise-based store + one globally-mounted renderer. `confirm(opts)` returns
`Promise<boolean>` and is callable from anywhere — React components and non-React
code alike:
```ts
// src/app/stores/ConfirmStore.ts
import { confirm } from '../stores/ConfirmStore';
const ok = await confirm({
title: 'Delete snippet',
message: `Delete "${name}"? This cannot be undone.`,
confirmLabel: 'Delete',
danger: true, // Carbon "danger" styling + Cancel-defaulted focus
});
if (ok) removeSnippet(id);
```
| Piece | Responsibility | Lives in |
| ------------------------------- | --------------------------------------------------------------- | -------------------------------------- |
| `useConfirmStore` / `confirm()` | Hold the open request; resolve the awaiting promise | `src/app/stores/ConfirmStore.ts` |
| `ConfirmDialog` | Render the active request; backdrop, focus trap, Escape, danger | `src/app/components/ConfirmDialog.tsx` |
| `useFocusTrap` | Shared overlay focus trap (this dialog now, the shell later) | `src/app/hooks/useFocusTrap.ts` |
`ConfirmDialog` is mounted once at the app root. Only one confirmation shows at a
time; opening a second resolves the first `false` so no awaiter hangs.
### Dismissal — Carbon's transactional rule
Confirmations follow Carbon's **transactional / danger modal** behavior, not the
passive-modal behavior the feature shell uses:
- **Escape** and **Cancel** resolve `false`.
- A **backdrop click does _not_ dismiss** — the user must pick an action, so a
destructive choice is never made by an accidental outside click. (Contrast the
feature shell, where backdrop-click dismiss is correct for passive modals.)
- For `danger` requests, initial focus goes to **Cancel**, so a stray Enter can't
destroy anything; non-danger confirms focus the primary action.
- `role="alertdialog"` (not `dialog`) with `aria-describedby` on the message.
### The coordinator seam
The feature-modal coordinator exposes `setConfirm(fn)` for its unsaved-change
prompt. Once the feature-modal system lands, wire it to this same primitive:
```ts
setConfirm((message) => confirm({ title: 'Discard changes?', message, danger: true }));
```
That keeps every destructive/lossy decision — deletes, revert, reset, and
discard-on-close — flowing through one consistent dialog. Per spec §10, all
destructive actions confirm; per §01, _non_-blocking outcomes (success, info) are
**toasts**, not dialogs — don't reach for a confirm where a toast is the right
tool.
## URL & Keyboard Integration
The coordinator is the join point for navigation:
- `openModal` calls `syncModalToUrl`; navigable modals write a hash
(`#datasets`, `#datasets/dataset-<id>`, `#datasets/dataset-<id>/build`).
Non-navigable modals (about, donate, extract) write nothing.
- `closeModal` calls `clearModalFromUrl`, returning to the underlying workspace
hash.
- On load, the URL restorer reads the hash and calls `openModal(name, arg)` to
rehydrate the right modal and sub-target.
- The global key handler maps `Cmd/Ctrl+K``toggleDatasets()` and `Escape`
`closeModal()` (a no-op when `activeModal` is `null`). `Cmd/Ctrl+,` opens the
editor **settings popover**, not a modal (`openSettingsPopover('editor-settings')`;
settings are distributed — spec §01C/§07).
Because all of these call the same coordinator functions, browser
Back/Forward, keyboard shortcuts, and in-app triggers stay consistent — they
share the open/close/snapshot/URL logic rather than reimplementing it.
---
## Adding a Modal: Checklist
1. Add the name to the `ModalName` union.
2. Add one `MODAL_REGISTRY` entry (title, component; `getState`/`hasError`/
`getError` if it edits; `isUrlNavigable` + `init(arg)` if navigable;
`dismissOnBackdrop: false` if it holds in-progress work).
3. Write the body component; it reads/writes its feature store (e.g.
`useDatasetStore`, `useChartBuilderStore`) via a narrow selector.
4. If it isn't a small form, add its name to the **size-tier mapping in
`ModalShell`** (`isLarge`/`isXLarge`) — the one shell edit a new modal can
need; without it the modal renders at the small-form size.
5. If navigable, add its hash form to the URL sync and restore logic.
6. If it has a keyboard shortcut or workflow trigger, wire that to
`openModal(name, arg)` — never to `activeModal` directly.
The shell render, close logic, and change detection need no edits: those are
generic and driven entirely by the registry.
-528
View File
@@ -1,528 +0,0 @@
# 04 · Routing & Global Events
Two small, related subsystems govern how the app talks to the browser shell:
1. **URL hash as view-state** — the current view (selected snippet, open dataset
modal, etc.) lives in `location.hash`. It is read on load to restore state,
written on navigation, and Back/Forward step between prior states. Result:
every meaningful view is shareable, bookmarkable, and reload-safe.
2. **Global event / keyboard routing** — a single router owns the
document-level `keydown` / `paste` / `click` listeners. It runs an Escape
priority chain, dispatches shortcuts, and consults a single
`isInInteractiveContext()` helper so global shortcuts and paste handlers
never fire while the user is typing in an input or the Monaco editor.
Both are layered the same way:
```
src/app/infrastructure/url-hash.ts adapter: owns window.location & history
src/app/orchestration/UrlStateSync.ts mediator: hash <-> Zustand stores
src/app/orchestration/EventRouter.ts mediator: DOM events -> store actions
src/app/orchestration/focus-utils.ts single-source isInInteractiveContext()
```
Infrastructure modules touch browser globals; orchestration modules touch the
Zustand stores. Components never read `location.hash` or attach
`window.addEventListener` themselves — they go through these mediators.
---
## 1. URL Hash as View-State
### 1.1 The hash grammar
The hash is the serialized view. Astrolabe's forms:
| State | Hash |
| ------------------------------ | ------------------------------ |
| Default snippets view | _(empty / absent)_ |
| A selected snippet | `#snippet-<id>` |
| Datasets manager (list) | `#datasets` |
| A specific dataset | `#datasets/dataset-<id>` |
| New-dataset form | `#datasets/new` |
| Chart Builder for a dataset | `#datasets/dataset-<id>/build` |
| Chart Builder, no dataset open | `#build` |
Snippet `id` is an opaque string; dataset `id` is the numeric dataset id
rendered as a decimal string. The hash is the **only** persisted view-routing
state — there is no in-memory "current route" that can drift from it. `#build`
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
exposes a parse function (hash string → typed `ViewState`), a serialize
function (`ViewState` → hash string), and write helpers. Keep it pure-ish:
parsing is a total function with no side effects; writing is the only place
`history.replaceState` is called.
```ts
// src/app/infrastructure/url-hash.ts
export type ViewState =
| { kind: 'snippets' } // empty hash
| { kind: 'snippet'; snippetId: string } // #snippet-<id>
| { kind: 'datasets' } // #datasets
| { kind: 'dataset'; datasetId: number } // #datasets/dataset-<id>
| { kind: 'dataset-new' } // #datasets/new
| { kind: 'dataset-build'; datasetId: number } // .../build
| { kind: 'build' }; // #build — builder, no dataset loaded
export function parseHash(rawHash: string): ViewState {
const hash = rawHash.replace(/^#/, '');
if (hash === '') return { kind: 'snippets' };
const snippet = /^snippet-(.+)$/.exec(hash);
if (snippet) return { kind: 'snippet', snippetId: snippet[1] };
if (hash === 'build') return { kind: 'build' };
const parts = hash.split('/').filter(Boolean);
if (parts[0] === 'datasets') {
if (parts.length === 1) return { kind: 'datasets' };
if (parts[1] === 'new') return { kind: 'dataset-new' };
const m = /^dataset-(\d+)$/.exec(parts[1]);
if (m) {
const id = Number(m[1]);
if (parts[2] === 'build') return { kind: 'dataset-build', datasetId: id };
return { kind: 'dataset', datasetId: id };
}
}
// Unknown hash -> fall back to default rather than throwing.
return { kind: 'snippets' };
}
export function serializeHash(view: ViewState): string {
switch (view.kind) {
case 'snippets':
return '';
case 'snippet':
return `#snippet-${view.snippetId}`;
case 'datasets':
return '#datasets';
case 'dataset':
return `#datasets/dataset-${view.datasetId}`;
case 'dataset-new':
return '#datasets/new';
case 'dataset-build':
return `#datasets/dataset-${view.datasetId}/build`;
case 'build':
return '#build';
}
}
export function readView(): ViewState {
return parseHash(window.location.hash);
}
/** Write without adding a history entry (in-place correction, restore). */
export function replaceView(view: ViewState): void {
const url = new URL(window.location.href);
url.hash = serializeHash(view);
url.search = '';
window.history.replaceState({}, '', url.toString());
}
/** Write and add a history entry (user navigation -> Back works). */
export function pushView(view: ViewState): void {
const url = new URL(window.location.href);
url.hash = serializeHash(view);
url.search = '';
window.history.pushState({}, '', url.toString());
}
```
**`pushState` vs `replaceState` is the lever that makes Back/Forward feel
right.** Use `pushView` for deliberate user navigation (selecting a snippet,
opening a dataset) so each becomes a Back-able step. Use `replaceView` for
restoring on load and for correcting a stale/invalid hash, where you do not want
to litter history.
### 1.3 The mediator: `orchestration/UrlStateSync.ts`
`UrlStateSync` is the bridge between the hash and the Zustand stores. It does
three jobs:
- **On load — restore:** read the view, validate referenced ids against the
stores, and drive the stores to match. If an id no longer exists, fall back
to the default view and `replaceView` to clean the URL.
- **Hash → state (Back/Forward):** listen for `hashchange` and reconcile the
stores to the new view. This is what makes the browser buttons work.
- **State → hash:** expose typed `navigate*` helpers the rest of the app calls
when the user moves around. These `pushView` (or `replaceView`).
Guard against feedback loops: writing the hash fires no `hashchange` when you
use the History API the way above, but a defensive `applying` flag keeps the
`hashchange` reconciler from re-triggering navigation it just caused.
```ts
// src/app/orchestration/UrlStateSync.ts
import { useSnippetStore } from '../stores/SnippetStore';
import { useDatasetStore } from '../stores/DatasetStore';
import { useAppStore } from '../stores/AppStore'; // activeModal, etc.
import { readView, replaceView, pushView, type ViewState } from '../infrastructure/url-hash';
let applying = false; // suppress re-entrancy while we drive the stores
let started = false;
// Restore/reconcile is the one path that writes `activeModal` with the bare
// `setActiveModal` primitive instead of the coordinator's openModal/closeModal:
// we are reflecting the URL *into* the stores, so we must NOT re-sync the URL or
// run the unsaved-change discard prompt (the `applying` guard blocks re-entrancy).
/** Make the stores reflect `view`. Falls back + cleans URL on dead ids. */
function applyView(view: ViewState): void {
applying = true;
try {
switch (view.kind) {
case 'snippets':
useAppStore.getState().setActiveModal(null);
return;
case 'snippet': {
const snippet = useSnippetStore.getState().byId(view.snippetId);
if (!snippet) {
replaceView({ kind: 'snippets' });
return;
}
useAppStore.getState().setActiveModal(null);
useSnippetStore.getState().select(view.snippetId);
return;
}
case 'datasets':
useAppStore.getState().setActiveModal('datasets');
return;
case 'dataset':
case 'dataset-build': {
const ds = useDatasetStore.getState().byId(view.datasetId);
if (!ds) {
replaceView({ kind: 'datasets' });
return;
}
useAppStore.getState().setActiveModal('datasets');
useDatasetStore.getState().select(view.datasetId);
if (view.kind === 'dataset-build') useAppStore.getState().setActiveModal('chartBuilder');
return;
}
case 'dataset-new':
useAppStore.getState().setActiveModal('datasets');
useDatasetStore.getState().beginNew();
return;
}
} finally {
applying = false;
}
}
export function startUrlStateSync(): void {
if (started) return;
started = true;
// 1. Restore from the URL on load.
applyView(readView());
// 2. Back/Forward -> reconcile stores.
window.addEventListener('hashchange', () => {
if (applying) return;
applyView(readView());
});
// 3. State -> hash. A store subscription keeps the URL honest if any code path
// changes the active view without calling a navigate* helper. Optional;
// explicit navigate* calls are the primary writer.
useAppStore.subscribe((state, prev) => {
if (applying) return;
// derive ViewState from state and replaceView(...) here if desired
});
}
// --- State -> hash: the API the app calls on user navigation -------------
export const navigate = {
toSnippet: (id: string) => pushView({ kind: 'snippet', snippetId: id }),
toSnippets: () => pushView({ kind: 'snippets' }),
toDatasets: () => pushView({ kind: 'datasets' }),
toDataset: (id: number) => pushView({ kind: 'dataset', datasetId: id }),
toNewDataset: () => pushView({ kind: 'dataset-new' }),
toChartBuilder: (id: number) => pushView({ kind: 'dataset-build', datasetId: id }),
};
```
**Do**
- Restore on load with `replaceView`; navigate at runtime with `pushView`.
- Validate every id from the hash against the stores; fall back + clean URL on
a miss (deleted/shared-stale ids are normal, not exceptional).
- Keep `parseHash` / `serializeHash` pure and round-trippable — unit-test that
`parseHash(serializeHash(v)) === v` for every `ViewState`.
**Don't**
- Don't read or write `location.hash` from components.
- Don't `pushState` on load-restore (pollutes Back history).
- Don't throw on an unrecognized hash; degrade to the default view.
### Shipped (M6) — how the implementation refines this sketch
The routing mediator lives in **`modals/UrlStateSync.ts`**, not a new
`orchestration/UrlStateSync.ts`: that file was already the coordinator's modal↔URL
seam (`syncModalToUrl` / `clearModalFromUrl`), so M6 grew it into the whole router
rather than splitting routing across two modules. It must **not** import the
ModalCoordinator (cycle); restore drives `useAppStore.setActiveModal` directly, as
this sketch's `applyView` already does.
**state → hash is derived from the stores, not pushed by `navigate.*` calls.** A
`deriveViewState()` reads `activeModal` + `DatasetStore.view`/`selectedId` +
`ChartBuilderStore.datasetId` + `activeSnippetId`; a subscription on each of those
stores calls `pushView` when the derived view differs from the URL. Components never
call a navigate helper — they just mutate stores (select a snippet, open a dataset),
and the subscriber reflects it. This is **required**, not stylistic: the in-modal
dataset sub-views (`#datasets/new`, `#datasets/dataset-<id>`) are `DatasetStore` view
changes, not modal-open events, so only a derive-from-state writer captures them. Only
modals flagged `isUrlNavigable` in the registry own the hash; a non-navigable modal
(extract / about / donate) leaves the underlying snippet view in the URL. On load,
after restore, the active view is reflected with **`replaceView`** (not `pushView`) so
there's no dead Back step. `startRouting()` runs in `startup.ts` after store hydrate.
---
## 2. Global Event / Keyboard Routing
### 2.1 The router: `orchestration/EventRouter.ts`
One module binds the document-level listeners (`keydown`, `paste`, `click`) and
routes them. Centralizing this keeps ordering explicit and gives one place to
reason about priority. The router owns two things in particular:
- the **Escape priority chain**, and
- **shortcut dispatch**, gated by `isInInteractiveContext()`.
```ts
// src/app/orchestration/EventRouter.ts
import { useAppStore } from '../stores/AppStore';
import { useSnippetStore } from '../stores/SnippetStore';
import { navigate } from './UrlStateSync';
import { openModal, closeModal, toggleDatasets } from '../modals/ModalCoordinator';
import { isInInteractiveContext } from './focus-utils';
let started = false;
export function startEventRouter(): void {
if (started) return;
started = true;
window.addEventListener('keydown', onKeyDown);
window.addEventListener('paste', onPaste);
}
export function stopEventRouter(): void {
window.removeEventListener('keydown', onKeyDown);
window.removeEventListener('paste', onPaste);
started = false;
}
const isMac = /Mac|iPhone|iPad|iPod/.test(navigator.platform);
function onKeyDown(e: KeyboardEvent): void {
// --- Escape: highest priority, runs even inside editors/inputs ----------
if (e.key === 'Escape') {
if (handleEscapeChain()) e.preventDefault();
return;
}
const mod = isMac ? e.metaKey : e.ctrlKey;
// Cmd/Ctrl + S -> publish current draft. Checked BEFORE the interactive-context
// gate: it is the canonical "save" shortcut (spec §01D mandates it override the
// browser default), and publishing happens *while editing the draft in Monaco* —
// gating it behind "not typing" would defeat its purpose.
if (mod && !e.shiftKey && e.key.toLowerCase() === 's') {
e.preventDefault(); // override the browser "save page" dialog
useSnippetStore.getState().publishDraft();
return;
}
// --- Remaining shortcuts: never fire while typing in an input or Monaco ----
if (isInInteractiveContext()) return;
// Cmd/Ctrl + Shift + N -> new snippet
if (mod && e.shiftKey && e.key.toLowerCase() === 'n') {
e.preventDefault();
const created = useSnippetStore.getState().create();
navigate.toSnippet(created.id);
return;
}
// Cmd/Ctrl + K -> toggle Datasets manager (coordinator owns open/close + URL)
if (mod && !e.shiftKey && e.key.toLowerCase() === 'k') {
e.preventDefault();
toggleDatasets();
return;
}
// Cmd/Ctrl + , -> open the editor settings popover. Settings are distributed to
// per-pane disclosure popovers, not a modal (spec §07), so this opens the editor
// cluster (openSettingsPopover) — there is no 'settings' modal to open.
if (mod && e.key === ',') {
e.preventDefault();
openSettingsPopover('editor-settings');
return;
}
}
/** Returns true if it consumed the Escape (caller should preventDefault). */
function handleEscapeChain(): boolean {
// 1. Toast/message box would go here if it grew a blocking variant.
// 2. Active modal — route through the coordinator so the unsaved-change
// discard prompt runs and the URL is cleared. NEVER setActiveModal(null)
// here: that would silently drop in-progress dataset/chart-builder edits.
if (useAppStore.getState().activeModal) {
void closeModal();
return true;
}
// 3. Open menu / popover.
if (useAppStore.getState().openMenu) {
useAppStore.getState().setOpenMenu(null);
return true;
}
// 4. Active selection (e.g. selected snippet in the library).
if (useSnippetStore.getState().selectionId) {
useSnippetStore.getState().clearSelection();
return true;
}
return false;
}
function onPaste(e: ClipboardEvent): void {
// Paste-to-import (e.g. paste a Vega-Lite spec) must NOT hijack a paste the
// user makes inside the editor or an input.
if (isInInteractiveContext()) return;
// ... route clipboard text to the import handler ...
}
```
**The Escape chain is an explicit, ordered ladder, top-down.** Each rung
returns as soon as it consumes the event, so only the topmost active layer
reacts. Order matters: a blocking message box outranks a modal, a modal
outranks an open menu, a menu outranks a selection. Add new dismissible layers
by inserting a rung at the right priority — never by sprinkling
`document.addEventListener('keydown', …Escape…)` in a component.
**Shortcuts override browser defaults.** Each handled combo calls
`e.preventDefault()` so Cmd/Ctrl+S does not trigger "save page", Cmd/Ctrl+K
does not focus the browser search bar, etc.
Note the asymmetry: **Escape and Cmd/Ctrl+S are checked before the
interactive-context gate.** Escape, so it dismisses a modal even while focus is in
the editor; Cmd/Ctrl+S, because publishing the draft is something you do _while_
editing it — gating "save" behind "not typing" would defeat it (and it must
override the browser's "save page" regardless). The remaining shortcuts (new
snippet, toggle Datasets, settings) are checked **after** the gate, so they never
fire mid-typing or steal a key the editor wants (e.g. Monaco's own Cmd+K chord).
### 2.2 The single-source helper: `orchestration/focus-utils.ts`
There is exactly **one** function that answers "is the user currently typing in
an editable surface?" Every shortcut path and the paste handler call it. Never
inline element-type checks — one place to get it right, one place to fix it
when the DOM changes.
> **Monaco difference (important):** Astrolabe's spec editor is **Monaco**, not
> CodeMirror. Monaco renders into a `.monaco-editor` container and keeps focus
> on a hidden `<textarea class="inputarea">` inside it. The detector must match
> Monaco's DOM — a `.monaco-editor` ancestor (and/or the inputarea) — **not** a
> `.cm-editor` / `.cm-content` selector. If you copy a CodeMirror check here it
> will silently fail and global shortcuts will fire while the user edits a spec.
```ts
// src/app/orchestration/focus-utils.ts
/**
* True when focus is in an editable surface where global shortcuts and
* paste-to-import must be suppressed: <input>, <textarea>, <select>,
* contenteditable, or the Monaco editor.
*
* This is the SINGLE source of truth — do not inline these checks elsewhere.
*/
export function isInInteractiveContext(): boolean {
const el = document.activeElement as HTMLElement | null;
if (!el) return false;
const tag = el.tagName.toLowerCase();
if (tag === 'input' || tag === 'textarea' || tag === 'select') return true;
if (el.isContentEditable) return true;
// Monaco renders into a .monaco-editor container; its focused element is a
// hidden <textarea class="inputarea"> (already caught above) but guard the
// container explicitly so focus on any inner node still counts.
if (el.closest?.('.monaco-editor')) return true;
return false;
}
```
**Do**
- Route all global keyboard/paste/click through `EventRouter`; bind listeners
in exactly one place, started once at app init.
- Express Escape as an ordered chain that returns on first consumption.
- Call `isInInteractiveContext()` everywhere a global handler might collide
with typing; keep it the only definition.
- `preventDefault()` on every shortcut the app claims, so it overrides the
browser default.
**Don't**
- Don't add ad-hoc `window`/`document` keydown listeners in components.
- Don't inline `tagName === 'textarea'` / editor-class checks at call sites —
call the helper.
- Don't match a CodeMirror selector for the editor; Astrolabe is Monaco.
- Don't gate Escape behind `isInInteractiveContext()` — Escape should still
close a modal while the editor has focus.
---
## 3. Wiring at startup
Both subsystems start once, after the stores are hydrated from persistence, in
the app's init/orchestration step:
```ts
// src/app/orchestration/bootstrap.ts (sketch)
import { startUrlStateSync } from './UrlStateSync';
import { startEventRouter } from './EventRouter';
export function initApp(): void {
// ... load settings + hydrate snippet/dataset stores from IndexedDB/localStorage ...
startUrlStateSync(); // restore view from hash, then keep hash <-> stores in sync
startEventRouter(); // bind global keyboard/paste routing
}
```
Order: hydrate stores first (so hash-restore can resolve ids), then
`startUrlStateSync` (it reads the hash and may drive the stores), then
`startEventRouter`. Each `start*` is idempotent and has a matching `stop*` for
teardown in tests.
---
## 4. Testing notes
- **`parseHash` / `serializeHash`:** pure, so test directly. Cover every
`ViewState`, the empty hash, and at least one malformed hash → default.
Assert the round-trip identity.
- **`isInInteractiveContext`:** happy-dom test (the project's Vitest env). Mount
an `<input>`, a `contenteditable` div, and a `<div class="monaco-editor"><textarea/></div>`;
focus each and assert `true`; assert `false` for a focused `<button>`.
- **Escape chain:** with stores in known states, dispatch a synthetic Escape
and assert only the top active layer changed.
- **Restore-on-load with dead id:** seed an empty store, set
`location.hash = '#snippet-gone'`, call `startUrlStateSync()`, assert the
view fell back to default and the hash was cleaned.
@@ -1,737 +0,0 @@
# Rendering, Theming & Live Preview
How Astrolabe turns a user-authored Vega-Lite specification into a live chart in
the preview pane. This covers four mechanics: **embedding** a spec via
`vega-embed`, **theming** so charts match the active UI theme, **debounced
re-rendering** so typing stays smooth, and **error handling** so a broken spec
produces a readable message and self-heals. It deliberately stops at the
embedding boundary — the _content_ of the spec (resolving named-dataset
references, applying fit-mode sizing) is prepared upstream by a pure transform;
see §6.
---
## 1. The Embedding Boundary
The preview is a thin imperative layer wrapping the `vega-embed` library, driven
by reactive store state. The flow is always the same:
```
spec text ──parse──▶ Vega-Lite spec object
prepareSpecForRender(spec, { fitMode }) ← pure, src/core/rendering.ts
│ (operates on a COPY; never mutates the stored spec)
render(node, preparedSpec, config) ← src/app, this doc
vega-embed ─▶ View ─▶ SVG in the DOM node
```
`vega-embed` is the only place in the app that touches the chart DOM. Everything
above it is data; everything below it is a Vega `View` we own and must tear down.
### Rules
- **Do** keep all `vega-embed` calls behind one small renderer module. Components
ask the renderer to draw a spec into a node; they never import `vega-embed`
directly.
- **Do** treat the renderer as imperative glue driven by store state (via a
`subscribe` listener), not as reactive state itself.
- **Don't** scatter `vegaEmbed(...)` calls across components.
### The data inspector rides the boundary too
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 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.
---
## 2. vega-embed Integration
A single async `render` function embeds a prepared spec into a DOM node. Three
non-negotiable embed options, plus disciplined teardown of the previous view:
```ts
// src/app/services/chart-renderer.ts (sketch)
import vegaEmbed, { type Result as EmbedResult } from 'vega-embed';
import type { Config, TopLevelSpec } from 'vega-lite';
export interface RenderHandle {
/** Finalize the underlying Vega view and release its resources. */
destroy(): void;
}
export async function renderSpec(
node: HTMLElement,
spec: TopLevelSpec,
config: Config,
): Promise<RenderHandle> {
const result: EmbedResult = await vegaEmbed(node, spec, {
actions: false, // no built-in export/source/editor menu — clean chart
renderer: 'svg', // crisp, inspectable, copyable output
config, // theme config (see §3)
});
return {
destroy() {
// Frees timers, listeners, and the canvas/SVG the view created.
result.view.finalize();
node.replaceChildren(); // drop any leftover DOM the embed inserted
},
};
}
```
### The view lifecycle is the bug surface
Every successful `vegaEmbed` returns a `result.view` (a live Vega `View`
instance). It owns timers, signal listeners, and DOM. If you embed a new spec
into the same node _without_ finalizing the old view, the old one leaks — its
listeners keep firing and resources accumulate over a long editing session.
The renderer that drives re-rendering must therefore hold the previous handle and
destroy it before (or while) creating the next:
```ts
let current: RenderHandle | null = null;
async function rerender(node: HTMLElement, spec: TopLevelSpec, config: Config) {
current?.destroy(); // tear down the previous view first
current = await renderSpec(node, spec, config);
}
```
### Rules
- **Do** pass `actions: false`. Astrolabe owns its own export/copy affordances;
the library's overlay menu does not belong on the preview.
- **The per-chart export goes through the handle, not the raw view.** `RenderHandle`
exposes `toImageURL('png' | 'svg', { scale, background })` so the preview's Export
control can rasterize/serialize the live chart without any component importing
`vega-embed` or touching the `View` directly — the embedding boundary holds. PNG goes
via `view.toCanvas``blob:` URL (revoked after the download); SVG via `view.toSVG`
`data:` URL. Renderer-agnostic: both work from the SVG-backed LivePreview view, since
Vega draws to its own off-screen surface here. Two non-obvious details live in the
handle, not the caller: **(1) dpr-aware scale** — the PNG is drawn at
`scale × devicePixelRatio`, so a `1×` export is as crisp as the chart on a Retina
screen (raw `toImageURL` scaleFactor ignores dpr, so a naive 1× looks half-resolution
on a 2× display). **(2) background fill** — the chart config renders a transparent
background (so the on-screen chart shows the pane colour), which would make a naive
export transparent; an opaque colour is composited under the PNG canvas and added as a
full-bleed `<rect>` to the SVG. The spec-text exports (copy / `.vl.json`) need no view.
- **SVG is the default renderer, canvas is an opt-in for many-mark previews.** SVG is
crisp/inspectable/copyable and stays the default for the editor's LivePreview. But an
SVG chart renders one DOM node per mark, so a many-mark chart (e.g. the Chart Builder's
default one-bar-per-row on a 10k-row dataset) costs **seconds** of main-thread
layout/paint per render (the chart paints _after_ it first appears, freezing the tab). The **Chart Builder preview** therefore passes
`renderSpec(…, { renderer: 'canvas' })` — canvas is a single node and paints in
milliseconds. The raster trade-off is invisible for an ephemeral preview, and image
export (`view.toImageURL`) is renderer-agnostic.
- **Canvas has a hard max dimension; SVG doesn't.** A canvas larger than the browser's
limit (~32k px/side, less on Safari) fails to allocate and draws _nothing_ — silently.
So for canvas, `renderSpec` first runs a headless (`'none'`) layout probe, reads the
resolved height, and throws `ChartTooLargeError(heightPx, limitPx)` when it exceeds
`MAX_CANVAS_PX ÷ devicePixelRatio`, so the caller can show the real cause. This is a
**render-size** limit (the chart is physically too big), distinct from the readability
cardinality warnings — don't conflate them. Only an _unbounded_ axis overflows: a
`width: 'container'` axis is bounded, so it's the deleted (natural-height) axis to watch.
- **Load fonts before rendering.** Vega measures every text label via canvas
`measureText` **regardless of renderer** (even the `'none'` probe runs layout),
so a face that finishes loading after embed lays the whole chart out with
fallback metrics. `renderSpec` therefore gates on `document.fonts.load` for the
families a spec+config reference (`collectFontFamilies`, core) before any layout
pass. This is a non-critical enhancement, so it waits on `allSettled` + a
timeout: a face failing (offline, 404, a system family with no `@font-face`)
degrades to fallback metrics rather than failing the chart (the sanctioned
swallow under §7's fail-loud rule). Chart fonts are self-hosted in
`styles/chart-fonts.css` (offered by the Theme Builder's font control); only
their latin subsets are precached, the rest runtime-cached (vite.config Workbox).
User-uploaded faces are registered on `document.fonts` too, so the same gate
resolves them (§3 → User-uploaded fonts).
- **Do** call `view.finalize()` on every previous view before rendering a new
one, and on component unmount.
- **Do** keep exactly one live view per preview node.
- **Don't** re-embed into a node whose previous view you have not finalized.
- **Don't** keep a reference to a finalized view; null it out.
---
## 3. Theme Follows the UI Theme
A Vega-Lite **config** object styles every chart globally — fonts, axis colors,
background, the categorical color range. Astrolabe ships one config per UI theme
so charts visually belong to the app rather than looking like stock Vega-Lite.
`src/core/vega-themes.ts` is the single source of truth; each house config is
**two merged layers** (the full audit and forward plan live in
[`docs/exploration/chart-theming-scope.md`](../exploration/chart-theming-scope.md)):
- **Base** (`lightBaseConfig`/`darkBaseConfig`) — the legibility minimum:
`background: 'transparent'` (the pane shows through) plus guide colors on the
app's text/border tokens. Without it, stock black-on-white chart text is
illegible on the dark pane.
- **Expressive** (`lightExpressiveConfig`/`darkExpressiveConfig`) — the house
style: IBM Plex, the Carbon data-viz 14-color categorical palette, dotted
grid, bumped guide sizes/weights, no plot border.
`mergeChartLayers(base, expressive)` produces `lightChartConfig`/
`darkChartConfig`, and `chartConfigFor(uiTheme)` is the one UI-theme → config
mapping. The split exists so a non-house style can keep the base layer while
swapping the expressive one (future custom themes).
### Selectable chart themes
On top of the house pair, the user picks a **chart theme** (spec §04 → Chart
theme) — `ChartThemeSelection = 'astrolabe' | 'stock' | <vega-themes preset id>
| 'custom:<id>'`:
- `'astrolabe'` resolves via `chartConfigFor(uiTheme)` (follows light/dark);
- `'stock'` resolves to `{}` — nothing injected, pure Vega-Lite defaults;
- preset ids resolve to the `vega-themes` package's configs verbatim (the same
presets as the Vega editor's theme dropdown; the package is already in the
tree as a vega-embed dependency);
- `custom:<id>` resolves to a saved `CustomTheme` record's config (spec §09G).
Selection is keyed by record **id**, not name, so a rename never invalidates
the persisted preference; a missing record (themes hydrate async from
IndexedDB; the record may be deleted) resolves to the house config rather
than rendering unstyled, and deleting the actively-selected theme resets
`AppStore.chartTheme` to `'astrolabe'` (CustomThemeStore.remove).
`chartConfigForSelection(selection, uiTheme, customThemes)` is the only
resolver; `chartThemeOptions(customThemes)` derives the full picker list
(built-ins, customs, presets — memoize the call: it returns a fresh array). The
choice lives in `AppStore.chartTheme`, persisted as `ui.chartTheme` by
`orchestration/preferences.ts` (the `previewFitMode` pattern; persistence
validates with `isChartThemeSelection`, which accepts `custom:<id>` on shape
alone), and is surfaced by a `SelectControl` in the LivePreview header — **not**
inside the PreviewSettings popover: `SelectControl` and `SettingsPopover` share
the one-open-popover registry, so a select nested in the popover would close
(and unmount) its own parent on open. The "Edit themes…" action row opens the
Theme Builder without changing the selection (the VS Code theme-picker
pattern); it closes the custom-themes block — after the built-ins, **before**
the long preset roster — so it's visible without scrolling and sits next to
the entries it manages.
### Custom themes & the Theme Builder
`CustomTheme` records (`core/custom-theme.ts`) persist in their own IndexedDB
store through the standard stack: `infrastructure/theme-store.ts` (+ read-time
`theme-migrations.ts`), `stores/CustomThemeStore.ts` (the themes array plus the
builder's draft state), and `orchestration/theme-persistence.ts` (diffing
write-through, wired after hydrate in `startup.ts`) — the exact dataset
pattern, one tier each.
The Theme Builder modal (`ThemeBuilderModal`, registered as `themeBuilder`,
xlarge shell, no backdrop dismissal) edits a **draft** held in the store:
`{ name, configText }` plus `draftConfig` — the last text state that parsed.
The gallery (`core/theme-preview-specs.ts`, fixed inline-data swatch specs)
renders `draftConfig` per card through the shared `renderSpec` with the
**canvas** renderer and a per-card debounce + chain-lock (the LivePreview
serialization pattern, one lock per card) — so invalid JSON mid-edit never
blanks the preview, and seven concurrent embeds never interleave on a node. A
card whose render throws shows the error message in place of the chart (the same
fail-loud treatment as LivePreview, §7), never a silent blank.
`applyFontToConfig(config, family)` is the font control's transform: it sets
the top-level `font` and rewrites every `font`/`*Font` string slot at any
depth — explicit slots would otherwise keep overriding the new default.
Creation paths: the builder's "New theme" duplicates the currently selected
chart theme's resolved config, and the editor's **Extract Config to New
Theme** action (`runExtractConfigToTheme`, spec §03G) lifts a spec's `config`
block into a theme, selects it, and removes the block — the spec-to-library
direction of the same boundary the merge action crosses the other way.
Render-time precedence: vega-lite merges the injected config **under** the
spec's own `config` (`mergeConfig(opt.config, spec.config)` — the spec wins
key-by-key), so a snippet can always override or opt out locally. The
`core/spec-config.ts` merge/extract operations (spec §03G) move styling across
that boundary deliberately: merge bakes the selected theme into `spec.config`
(spec keys win — rendering unchanged), extract lifts `spec.config` out.
### User-uploaded fonts
Beyond the self-hosted roster, a user can upload font files (`.woff2`/`.woff`/
`.ttf`/`.otf`) for chart themes. A `FontAsset` (`core/font-asset.ts`) holds the
raw bytes and persists through the standard entity-store stack — a `fonts`
IndexedDB store, `infrastructure/font-store.ts` (+ `font-migrations.ts`),
`stores/FontStore.ts`, `orchestration/font-persistence.ts` — the same
one-tier-each shape as datasets and custom themes. The browser seam that turns
stored bytes into a live face, `infrastructure/font-faces.ts`, registers on
`document.fonts` at startup (before the first render, so the §2 font gate
resolves user faces like the roster) and on each upload; uploads/deletes are
orchestrated by `services/fonts.ts`. The Type panel lists user fonts ahead of the
roster and applies one via the same `applyFontToConfig` transform.
**Font dependencies are derived from the config, never stored on the theme.** A
`CustomTheme` carries no font field: the family a theme (or a snippet) uses is
already in its config's `font`/`*Font` slots, that JSON config is the source of
truth, and snippets use uploaded fonts with no theme to carry such a field. So a
used face is discovered by scanning configs/specs for the families they reference
(`collectFontFamilies`, core) and matching against the font library — a stored
field would only drift from the config it duplicates.
(Embedding the matched faces' bytes into the §08 workspace export and per-chart
SVG export is the remaining transfer work, on shared base64 machinery.)
**Variable fonts: weight is the only leverageable axis.** `parseFontAxes` reads
the OpenType `fvar` table (uncompressed `ttf`/`otf` only — `woff`/`woff2` wrap
their tables in compression we don't unpack, so those register as static), and a
variable face is registered with its `wght``weight` and `wdth``stretch` ranges
so one file serves the whole weight range, driven by the Type panel's weight
controls. Only those two axes take effect because Vega's text rendering emits a
CSS font shorthand (family/size/weight/style) with **no `font-variation-settings`
hook** — optical size, grade, and custom axes pin at the registered default and
can't be exposed. Declaring the `wdth` range also defaults the face to normal
width rather than a variable font's possibly-condensed default instance.
### Structured controls
The builder's panels — Color, Marks, Type, Title, Layout, Axes & grid, Legend,
Headers, Formats — are accelerators over the same `draftConfig`, one panel per
config domain (each an `XxxControls` component on the shared `ThemeFields`
primitives). `ThemeBuilderModal` holds the single `id → label → Panel` registry
(`THEME_TABS`); panels switch via a **vertical tab list** (APG vertical tabs),
and each panel groups its properties into a **single-expand accordion**
(`ThemeFields` `Accordion` — one section open at a time, each with a set-count
badge so customized sections are scannable while collapsed). Each control reads a
value and writes one back through `CustomThemeStore.mutateDraftConfig(fn)` — the
single transform path, which reparses, reformats, and updates `draftConfig` so
the JSON editor and gallery follow (a parse error disables the controls). The raw
JSON below the panels is the full-power escape hatch for the long tail the
structured controls deliberately omit. The pure transforms live in
`core/theme-controls.ts`: immutable config path get/set, leaf coercion, the
named-scheme catalog (`THEME_SCHEMES`), and `schemeColors` (scheme name → hex
swatches, from the `vega-scale` registry — a focused vega sub-package). A color
family holds **either** a named scheme as Vega's range-scheme **object**
`{ scheme: name }` **or** an explicit color array; the picker materializes one to
the other. Family by scale: `range.category` (nominal), `range.ramp` (continuous;
`range.heatmap` for `rect`), `range.diverging` (continuous color with a
`domainMid`).
Repeated control labels across panels ("Size", "Color", "Weight") get a
qualified accessible name while keeping the short visible label; the section is a
`role="group"` labelled by its heading (APG group pattern), so the name a screen
reader announces is unambiguous.
- **Do** bind a panel's writes to the shared `useConfigSetter()` hook (in
`CustomThemeStore` — beside `mutateDraftConfig`, not the JSX field module, which
stays component-only for fast refresh). It is `mutateDraftConfig` + `setConfigValue`:
sets a value at a path **immutably, preserving sibling keys**, and deletes
(pruning emptied ancestors) on `undefined` so a theme stays a diff. A new panel
uses it rather than re-inlining the pair.
- **Don't** rebuild the config from a fixed schema: vega-themes presets carry
Vega-_layer_ keys (`symbol`/`shape`/`path`/`group`) absent from the Vega-Lite
`Config` schema but forwarded to Vega — a rebuild drops them. Merge in place.
- **Do** write a named scheme into `range.*` as the object `{ scheme: name }`. A
bare scheme-name string passes vega-lite _compile_ but Vega rejects it at
_render_ ("Unrecognized scale range value"), blanking the chart.
`normalizeRangeSchemes` (core) heals the bare form at the render-resolution
points (`chartConfigForSelection`; the builder gallery) for configs authored or
saved before this was enforced.
- **Do** give every control a visible mirror in the `theme-preview-specs.ts`
gallery, and keep those sample specs on **bare marks** (`mark: 'point'`, not
`{ type: 'point', size: 80 }`): a property hard-coded in a spec overrides the
injected config, making the matching control a no-op in the preview. Mark
styling belongs in the config (a theme), never inline in a card. Default chart
size and tooltips are the unavoidable exceptions — fixed-size swatches, and
hover-only respectively.
### Rules
- **Do** keep `chartConfigForSelection` as the _only_ place that maps the user's
selection (and UI theme) to a Vega config.
- **Do** set chart `background: 'transparent'` in the house configs so the
pane's own background shows through and theme switches look seamless. Preset
themes carry their own backgrounds (often white) and render as their authors
intended — honest preview beats pane-matching.
- **Do** keep the Chart Builder preview and onboarding thumbnails on
`chartConfigFor(uiTheme)` — they are app surfaces, not destination previews.
- **Don't** inline colors or fonts into individual specs to "match the theme" —
that is the config's job, and per-spec styling drifts from the app.
- **Don't** write the injected config into the user's stored spec implicitly;
it is applied at embed time, leaving the spec theme-agnostic. Baking it in is
the explicit, user-invoked merge action only.
### Theme flow (end to end)
Theme spans several layers; the path is:
`AppStore.uiTheme` (+ `toggleTheme`) → `orchestration/theme.ts` mirrors it onto
`<html data-theme>` and writes through to `infrastructure/settings-store.ts`
(localStorage `ui.theme`). On load, `initTheme()` — called from `main.tsx`
**before** `createRoot().render` — hydrates the saved theme. Chart and editor
follow by subscribing to `uiTheme`: `LivePreview` re-embeds with
`chartConfigForSelection(chartTheme, uiTheme)`, `SpecEditor` sets the Monaco
theme. UI chrome repaints
purely from the `[data-theme]` token swap in `styles/tokens.css`. The header
`ThemeToggle` is the user control.
- **Do** hydrate the theme **synchronously before first paint** — an async
hydrate (e.g. inside `initApp`) flashes the default theme on load.
- **Do** keep the store browser-free: the `data-theme` write and the localStorage
write-through live in `orchestration/theme.ts`, never in the store or a component.
- The control currently lives in the header; spec §07 houses it in the Settings
modal (M5), which will share the same `ui.theme` key.
---
## 4. Field-Name Escaping
Vega-Lite treats `.`, `[`, and `]` inside a `field:` string as **nested-property
accessors**: `field: "user.age"` reads `row.user.age`, not a column literally
named `"user.age"`. Astrolabe renders arbitrary user data whose column names may
contain those characters, so any column name placed into a `field:` (or `as:`,
`groupby:`, tooltip `field:`, etc.) must be escaped first.
```ts
// src/core/rendering.ts (sketch)
/** Escape `.`/`[`/`]` so Vega-Lite treats the string as a literal field name. */
export function escapeVegaField(name: string): string {
return name.replace(/([.[\]])/g, '\\$1');
}
```
```ts
// usage when constructing/normalizing an encoding that references a column:
encoding.x = { field: escapeVegaField(columnName), type: 'quantitative' };
```
This matters wherever Astrolabe _constructs_ spec fragments from data-derived
column names — most notably the chart builder (see _Chart Builder_ spec) and any
helper that injects an encoding. For specs the user authored by hand, escaping is
the user's responsibility; Astrolabe does not rewrite hand-authored `field:`
values.
### Rules
- **Do** route every data-derived column name through `escapeVegaField` before it
lands in a `field:` (or any field-position key).
- **Don't** ever pass a raw column name to `field:`. If the name came from data,
it is unescaped until proven otherwise.
---
## 5. Debounced Preview
Rendering must never compete with typing. The preview re-renders only after the
user pauses, the pending render is cancelled on each new keystroke, and a render
in flight never blocks the editor.
The debounce delay is **user-configurable** via the `performance.renderDebounce`
setting (range ~5005000 ms). Read it live so changes take effect without reload.
```ts
// src/app/services/debounced-renderer.ts (sketch)
export interface DebouncedRenderer {
/** Schedule a render after the debounce window; resets the timer. */
schedule(): void;
/** Render now, skipping the debounce (e.g. on fit-mode change or theme flip). */
flush(): void;
/** Cancel a pending render without rendering. */
cancel(): void;
}
// The factory wires the three operations over one timer. schedule() does
// `setTimeout(run, delayMs())`, clearing any pending timer first (delayMs read fresh
// so a settings change applies live); flush() clears the timer and runs now; cancel()
// clears it and bumps `generation`. The non-obvious part is out-of-order protection:
function createDebouncedRenderer(opts): DebouncedRenderer {
let generation = 0;
const run = async () => {
const mine = ++generation; // capture this render's turn
opts.setBusy(true);
try {
await opts.render();
} finally {
if (mine === generation) opts.setBusy(false); // only the latest render clears it
}
};
// …schedule/flush/cancel as above. A slow render that resolves after a newer one
// fails the `mine === generation` check, so it can't clobber the fresh view/indicator.
}
```
### Wiring it to the store
Startup subscribers observe the inputs that affect the picture — the current spec
text, the active fit mode, the UI theme — and call `schedule()` (debounced) for
spec edits, or `flush()` for instantaneous controls like a fit-mode toggle:
```ts
// wired once at startup
useEditorStore.subscribe((s, prev) => {
if (s.currentSpecText !== prev.currentSpecText) renderer.schedule(); // react to edits
});
useSettingsStore.subscribe((s, prev) => {
if (s.previewFitMode !== prev.previewFitMode || s.uiTheme !== prev.uiTheme) {
renderer.flush(); // immediate, no debounce
}
});
```
### Implemented policy: what renders immediately vs. debounced
> The service above is a **sketch** — its `useEditorStore`/`useSettingsStore` are
> illustrative placeholders; the real inputs are `useAppStore` (`previewFitMode` +
> `uiTheme`) and `useSnippetStore` (draft text / `bufferEpoch`). The shipped renderer
> lives inline in `LivePreview.tsx` (one `setTimeout` whose delay is computed per
> change) and subscribes to the stores via hooks rather than startup subscribers.
> When it is extracted into a service, preserve this policy.
The debounce exists to stay out of the way **while typing** — nothing else. So the
delay is `0` (immediate) for everything except keystrokes (spec §03C):
- **Immediate** — a _programmatic buffer load_ (`SnippetStore.bufferEpoch` changed:
select / create / duplicate / revert / hydrate) or a _Draft↔Published switch_
(`editorView` changed). These are the cases §03C names; the editor and preview
both key off `bufferEpoch` to tell a load from a keystroke.
- **Debounced** — a keystroke (only `shownText` changed). This is the churn the
debounce protects against.
Detect "this was a keystroke" by elimination: `shownText` changed but `bufferEpoch`
and `editorView` did **not**. Fit-mode and theme changes currently fall through the
debounce too (harmless; not typing) — flush them if instant feedback is wanted, but
never debounce a load or a view switch.
### Busy indicator
`setBusy(true/false)` toggles store state that the preview reads to overlay a
**subtle, non-blocking** spinner/shimmer. It sits _over_ the existing chart so the
last good render stays visible while the next one computes — the pane never goes
blank mid-edit.
### Rules
- **Do** read `renderDebounce` fresh on each `schedule()` (via the `delayMs()`
thunk) so a settings change applies immediately.
- **Do** cancel the pending timer on every new input before scheduling the next.
- **Do** guard against out-of-order completion (the `generation` counter): a slow
render that resolves after a newer one must not clobber the indicator or view.
- **Do** keep the busy indicator non-blocking and overlaid; never clear the chart
to show "rendering…".
- **Don't** render synchronously on every keystroke.
- **Don't** await a render inside an input/keydown handler.
### A second preview surface: the Chart Builder
The editor's `LivePreview` is **bound to the snippet editor** — it reads `SnippetStore`
(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.
The builder's **X/Y axis controls live in the preview pane, not the config pane**: the
on-chart Columns/Rows shelves (`OnChartShelves`) sit _above_ `BuilderPreview`, because axis
position is a property of the chart (Tableau's Columns/Rows metaphor). The field shelf and
the Colour/Size Marks card stay in the config pane. A reserved faceting slot in each shelf
is a placeholder only.
---
## 6. Rendering Contract Lives Upstream (reference)
Before a spec reaches `renderSpec`, it passes through a **pure** transform in
`src/core/rendering.ts`:
```ts
prepareSpecForRender(spec, { fitMode }): TopLevelSpec
```
It does two deterministic things, on a **deep copy** of the spec:
1. **Dataset reference resolution** — replaces any named-data reference with the
referenced dataset's actual contents (inline values, raw CSV/TSV text, or a
URL reference), recursing into layered/concat/child sub-specs.
2. **Fit-mode sizing** — rewrites `width`/`height` per the active fit mode using
Vega-Lite's `"container"` keyword (Original = untouched; Width sets
`width:"container"` and **removes** `height`; Height sets `height:"container"`
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:
> `prepareSpecForRender` runs on a copy and returns a new spec. The renderer
> embeds that returned spec. **The user's stored spec is never mutated by
> rendering.**
The container-relative fit modes (Width/Height/Full) depend on `"container"`
sizing to follow the pane. Re-fitting on a **pane resize** is _not_ a re-embed:
the existing view is re-measured via a `ResizeObserver`-driven event — see §8.
### Rules
- **Do** call `prepareSpecForRender` between parse and embed, every render.
- **Don't** put reference resolution or fit-mode logic in the renderer — it is
pure core logic and must be unit-testable without a DOM.
- **Don't** mutate the input spec anywhere in the pipeline.
- **Fit modes overwrite the spec's own sizing** (Width replaces `width` _and
deletes_ `height`, etc.), so a surface that lets the user set an explicit
width/height must pass `fitMode: 'default'` while either is set and reserve the
container fit for auto sizing — the Chart Builder preview does exactly this.
---
## 7. Error Handling
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 the one error state the preview owns:
| Stage | Failure | Surfaced as |
| -------------------------------- | -------------------------------------- | --------------------------------- |
| 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
async function render(): Promise<void> {
const text = useEditorStore.getState().currentSpecText.trim();
// Empty/blank is NOT an error — render nothing, clean pane.
if (!text) {
current?.destroy();
current = null;
setError(null); // `error`/`busy` are the pane's local state, not a store
return;
}
let parsed: unknown;
try {
parsed = JSON.parse(text);
} catch (e) {
setError(`Invalid JSON · ${(e as Error).message}`);
return; // keep the last good chart underneath the error, or show the message
}
try {
const { previewFitMode, uiTheme } = useSettingsStore.getState();
const prepared = prepareSpecForRender(parsed, { fitMode: previewFitMode });
const config = chartConfigFor(uiTheme);
current?.destroy();
current = await renderSpec(node, prepared, config);
setError(null); // success clears any prior error
} catch (e) {
setError(`Render error · ${(e as Error).message}`);
}
}
```
The preview component renders the chart node when `error` is `null`, and the
error panel when it is set. Because **every successful render clears the error**,
recovery is automatic: the next valid edit re-renders and wipes the message — no
manual retry, no reload.
### Rules
- **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 — 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.
---
## 8. Container Sizing & Pane Resize (two gotchas that cost real time)
Vega-Lite's `"container"` sizing is responsible for the Width/Height/Full fit
modes, and it has **two non-obvious failure modes**. Both were rediscovered the
hard way; this section is the shortcut.
### Gotcha 1 — the embed host shrink-wraps, collapsing `width:"container"`
`vega-embed` brands the element you embed into with its own
`.vega-embed { display: inline-block }`, injected into `<head>` at runtime so it
**wins the cascade** over a class you put on that same element. `inline-block`
shrink-wraps horizontally, and `"container"` width reads `host.clientWidth` — so
the chart collapses to near-zero width. (Height often survives because a tall box
keeps `clientHeight`, which is why the symptom is "Width broken, Height fine".)
Note also: `vega-embed` only adds its responsive `chart-wrapper` (the element its
`width:100%` rule targets) **when `actions` are enabled** — we pass
`actions: false`, so that path is dead and the host is branded directly.
**Fix:** embed into a dedicated **inner host** with a _static_ className (React
never re-reconciles it, so Vega's runtime classes survive) nested inside a
**React-owned frame** that carries the fit-mode class. Size the host with
**two-class selectors** (`.fitWidth .host { width: 100% }`) that out-specify
`.vega-embed`. Original mode lets the host stay natural and the pane scrolls.
### Gotcha 2 — Vega re-measures only on `window:resize`
The compiled `width`/`height` signals re-evaluate `containerSize()` **only** on
`events: "window:resize"`. Consequences: `view.resize()` re-runs layout with the
**stale** size (it does _not_ re-measure), and a pane drag fires no window resize,
so a responsive chart does **not** follow the pane on its own.
**Fix:** a `ResizeObserver` on the host → `window.dispatchEvent(new Event('resize'))`
(behind `RenderHandle.resize()`, keeping the Vega knowledge in the renderer).
`ResizeObserver` callbacks are frame-batched, so this tracks a drag without a
debounce. Because only the container-bound dimension carries the resize handler,
Width re-fits width and leaves height natural automatically — no fit-mode
bookkeeping. Gate the observer to responsive modes (Original needs no re-fit).
### Rules
- **Do** give `vega-embed` its own inner host element; never put a React-managed,
changing `className` on the element `vega-embed` brands.
- **Do** out-specify `.vega-embed` (two-class selectors) when you must size the host.
- **Do** bridge pane-resize via a synthetic `window:resize`, not `view.resize()`.
- **Don't** assume `actions: false` leaves you the responsive `chart-wrapper` — it doesn't.
- **Don't** re-embed just to re-fit a resize; re-measure the existing view.
---
## Summary
| Concern | Mechanism | Source of truth |
| ------------- | ------------------------------------------------------------------------------------ | ----------------------------------------------- |
| Embedding | One `renderSpec` over `vega-embed`, `actions: false`, `renderer: 'svg'` | `src/app/services/chart-renderer.ts` |
| View teardown | `view.finalize()` before each re-render and on unmount | the renderer's `RenderHandle` |
| Theming | Vega `Config` per UI theme, applied at embed time | `chartConfigFor()` in `src/core/vega-themes.ts` |
| Field names | `escapeVegaField` on every data-derived `field:` | `src/core/rendering.ts` |
| Debounce | Inline timer; `0` on buffer-load/view-switch, `renderDebounce` on keystroke (§5) | `LivePreview.tsx` (service not yet extracted) |
| Spec prep | `prepareSpecForRender` (pure, on a copy) | `src/core/rendering.ts` (see _Live Preview_) |
| 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`) |
-393
View File
@@ -1,393 +0,0 @@
# Type Inference & Data Profiling
How Astrolabe looks at a tabular dataset and figures out, for each column, what
kind of data it holds — `number`, `string`, `date`, or `boolean` — and how it
rolls those facts up into the **profile** stored on a dataset record.
This is pure, portable logic. It lives in `src/core/`, touches no browser APIs
and no React, takes plain values in and returns plain data out, and is covered
by Vitest unit tests. Anything that needs a profile (the create form, the edit
flow, the detail panel) calls into this module; nothing here reaches back out.
---
## 1. Why infer types at all
A dataset is just rows of values. The UI wants to _describe_ it without
re-parsing the payload every time: how many rows and columns, what the columns
are called, and roughly what each column contains. The inferred type drives the
small type indicator next to each column name in the dataset detail panel and
the meta line in the list. It is a **display hint**, not a contract — nothing
downstream coerces values based on it, and Vega-Lite does its own type handling
at render time. Because it is only a hint, a wrong guess is cheap, and the rules
below favour being simple and predictable over being clever.
We support exactly **four** inferred types:
| Type | Meaning |
| --------- | ---------------------------------------------------- |
| `number` | Every non-empty value is numeric. |
| `boolean` | Every non-empty value is `true`/`false` (any case). |
| `date` | Every non-empty value parses as a date. |
| `string` | The fallback — anything that isn't one of the above. |
There is deliberately no integer/float split, no datetime-vs-date distinction,
and no JSON type. Those distinctions add branches and edge cases without
changing what the user sees. Keep it at four.
---
## 2. Inferring one column
Given the values of a single column, decide its type.
### The shape of the algorithm
1. **Drop the empties.** Filter out `null`, `undefined`, and empty/whitespace-only
strings before doing anything. Empty cells carry no type signal — a column of
numbers with a few blanks is still a number column.
2. **All-empty → `string`.** If nothing survives the filter (the column is
entirely empty, or there are zero rows), default to `string`. There is no
evidence for any other type.
3. **Run the type checks in precedence order.** For each candidate type, ask:
_does **every** surviving value match this type?_ The first candidate for
which the answer is yes wins. This is the **"all values match → that type,
else fall back"** rule: one stray value that doesn't fit knocks the column
down to the next candidate, and ultimately to `string`.
### Precedence order matters
The order of the checks is not arbitrary — it exists because the value-sets
overlap, and we want the most specific interpretation that fits.
1. **boolean** first. The strings `"true"`/`"false"` are not numbers and not
dates, so booleans never collide with the other checks — but putting them
first keeps a `0`/`1`-free true/false column out of `string`. (We do _not_
treat `0`/`1` as boolean; that's a number column.)
2. **number** second. `Number("2024")` is a perfectly good number, so a column
of bare years would read as `number` — which is the honest answer. Numbers
are checked before dates so that plain numeric columns never get
mis-classified as dates by an over-eager date parser.
3. **date** third. Date parsing is the loosest, most permissive check, so it
goes last among the positive checks. By the time we reach it we already know
the column isn't all-boolean and isn't all-numeric.
4. **string** is the fallback when no positive check matches every value.
> Mnemonic: **boolean → number → date → string**, narrowest evidence to widest.
### What counts as each type
- **numeric**: trim the string form; reject empty; `Number(trimmed)` must be
finite and not `NaN`. (Native `number` values pass directly.) Reject blank and
whitespace so `Number("") === 0` doesn't sneak through.
- **boolean**: native `boolean` values pass; otherwise the trimmed,
lower-cased string must be exactly `"true"` or `"false"`.
- **date**: guard _before_ parsing. Require the trimmed value to look
date-shaped (a leading `YYYY-MM-DD` or `YYYY/MM/DD`, or `M/D/YYYY`) **and**
then confirm `Date.parse` returns a finite timestamp. The shape guard is
essential: `Date.parse` will happily accept `"42"` or `"March"` on some
engines, which would swallow number and string columns. Never rely on
`Date.parse` alone.
### Sketch
```ts
// src/core/type-inference.ts
export type ColumnType = 'number' | 'string' | 'date' | 'boolean';
const isEmpty = (v: unknown): boolean =>
v === null || v === undefined || (typeof v === 'string' && v.trim() === '');
const isNumeric = (v: unknown): boolean => {
if (typeof v === 'number') return Number.isFinite(v);
if (typeof v !== 'string') return false;
const t = v.trim();
if (t === '') return false;
const n = Number(t);
return !Number.isNaN(n) && Number.isFinite(n);
};
const isBoolean = (v: unknown): boolean => {
if (typeof v === 'boolean') return true;
if (typeof v !== 'string') return false;
const t = v.trim().toLowerCase();
return t === 'true' || t === 'false';
};
// Shape guard first, then confirm it actually parses.
const DATE_SHAPE = /^\d{4}[-/]\d{2}[-/]\d{2}|^\d{1,2}\/\d{1,2}\/\d{4}/;
const isDate = (v: unknown): boolean => {
if (typeof v !== 'string') return false;
const t = v.trim();
return DATE_SHAPE.test(t) && !Number.isNaN(Date.parse(t));
};
/**
* Infer one of four column types from a sample of column values.
* Empty cells are ignored; an all-empty column is `string`.
* Precedence: boolean → number → date → string.
*/
export function inferColumnType(values: readonly unknown[]): ColumnType {
const present = values.filter((v) => !isEmpty(v));
if (present.length === 0) return 'string';
if (present.every(isBoolean)) return 'boolean';
if (present.every(isNumeric)) return 'number';
if (present.every(isDate)) return 'date';
return 'string';
}
```
### Robustness notes
- **Mixed columns** fall through to `string` automatically — the `every` check
fails on the first non-conforming value, so a column of mostly-numbers with
one label is `string`, which is the safe, honest answer.
- **Whitespace** is trimmed in every check, so `" 42 "` reads as numeric and
`" "` is treated as empty.
- **Empty columns** (all cells blank, or a zero-row dataset) return `string` by
the all-empty rule — never throw, never guess.
- **Large columns**: see §4. `inferColumnType` itself just consumes whatever
array it's handed; the caller decides whether to sample.
### Do / Don't
- **Do** ignore empty cells before classifying.
- **Do** keep the precedence boolean → number → date → string.
- **Do** guard date detection with a shape regex before trusting `Date.parse`.
- **Don't** classify a column unless _every_ present value matches — one
outlier means `string`.
- **Don't** add more types (integer, float, datetime, json). Four, no more.
- **Don't** let `Number("")`, `Date.parse("42")`, or `0`/`1` leak into the wrong
bucket.
---
## 3. Profiling a dataset
A **profile** is the set of derived summary fields stored on a dataset record so
the UI can describe it without re-parsing the payload. Per the data model, a
profiled dataset carries:
| Field | Type | Meaning |
| ------------- | ---------------------------------------------------------- | ------------------------------------------------------------------ |
| `rowCount` | `number \| null` | Data rows, or `null` when N/A. |
| `columnCount` | `number \| null` | Columns, or `null` when N/A. |
| `columns` | `string[]` | Column names, in order. |
| `columnTypes` | `Array<{ name; type }>` | Per-column inferred type (see §2). |
| `columnStats` | `Array<{ name; distinct; distinctCapped; numericExtent }>` | Per-column cardinality + numeric range (see §3.3). Empty when N/A. |
| `size` | `number` | Approximate payload size in bytes. |
`null` row/column counts and an empty `columns`/`columnTypes`/`columnStats` are
how the UI shows **"N/A"** — see §3.2.
### 3.1 What gets profiled
Profiling applies to any **tabular payload**, whether pasted inline or fetched
from a URL (the snapshot model stores a URL dataset's data locally, so it profiles
through the same path as inline data — `snapshotFromText` shapes the fetched body,
then `computeDatasetProfile` runs):
- **JSON** that is an array of objects.
- **CSV** (comma-separated, header row).
- **TSV** (tab-separated, header row).
Everything else is **not profiled**:
- **Unfetched URL datasets** — a URL reference with no snapshot yet (e.g. one
migrated from an older record), so there is nothing to scan. Counts are `null` /
N/A until it is refreshed.
- **Non-tabular data** — a single JSON object, TopoJSON, or anything we can't
read as rows-of-columns. Counts are `null` / N/A.
For the not-profiled cases, `size` is still computed (it's just the byte length
of the stored payload), but `rowCount` and `columnCount` are `null`, and
`columns`/`columnTypes` are empty.
### 3.2 The algorithm
1. **Compute `size`** from the raw payload regardless of whether it's tabular —
byte length of the text (CSV/TSV) or of the JSON-serialized value.
2. **Decide if it's tabular.** Map `(format, parsed shape)` to a row set:
- `csv` / `tsv` → parse into rows-of-objects using the matching delimiter.
- `json` that is a non-empty **array of objects** → use it directly.
- anything else (`topojson`, a lone JSON object, an empty array) → not
tabular; return the N/A profile (`rowCount: null`, `columnCount: null`,
`columns: []`, `columnTypes: []`, `columnStats: []`, plus `size`).
3. **Derive columns** from the union of keys across the rows (or the CSV/TSV
header), preserving first-seen order.
4. **Infer each column's type and stats** by collecting that column's values across
the rows (sampling per §4) and calling `inferColumnType` (§2) plus deriving its
cardinality + numeric extent (§3.3) — one pass per column over the same sample.
5. **Assemble** `rowCount`, `columnCount`, `columns`, `columnTypes`, `columnStats`,
`size`.
### Sketch
```ts
// src/core/profile.ts
import { inferColumnType, type ColumnType } from './type-inference';
export interface DatasetProfile {
rowCount: number | null;
columnCount: number | null;
columns: string[];
columnTypes: Array<{ name: string; type: ColumnType }>;
columnStats: Array<{
name: string;
distinct: number; // capped at DISTINCT_CAP
distinctCapped: boolean; // true ⇒ real cardinality ≥ DISTINCT_CAP
numericExtent: { min: number; max: number } | null; // numeric columns only
}>;
size: number;
}
const NA = (size: number): DatasetProfile => ({
rowCount: null,
columnCount: null,
columns: [],
columnTypes: [],
columnStats: [],
size,
});
/** Profile a dataset payload. `rows` is the tabular form (CSV/TSV/JSON-array)
* already parsed to rows-of-objects, or null for non-tabular / URL data. */
export function profileData(
rows: ReadonlyArray<Record<string, unknown>> | null,
size: number,
): DatasetProfile {
if (!rows || rows.length === 0) return NA(size);
// Column order = first-seen order across all rows.
const columns: string[] = [];
const seen = new Set<string>();
for (const row of rows) {
for (const key of Object.keys(row)) {
if (!seen.has(key)) {
seen.add(key);
columns.push(key);
}
}
}
if (columns.length === 0) return NA(size);
const sample = sampleRows(rows);
// One pass per column over the sample: type + stats (cardinality, numeric extent).
const columnTypes = [];
const columnStats = [];
for (const name of columns) {
const values = sample.map((r) => r[name]);
const type = inferColumnType(values);
columnTypes.push({ name, type });
columnStats.push(columnStatsFor(name, values, type)); // see §3.3
}
return {
rowCount: rows.length,
columnCount: columns.length,
columns,
columnTypes,
columnStats,
size,
};
}
```
Parsing CSV/TSV text and detecting the payload shape happen _upstream_ of
`profileData`; this function takes already-parsed rows so it stays pure and
trivially testable. The caller passes `null` for URL and non-tabular datasets.
### 3.3 Column stats: cardinality + numeric extent
Alongside the display type, each column carries the two data-shape signals the
**Chart Builder** needs for its data-aware Tier-B hints (`chart-builder.ts`
`builderWarnings`) **and** for its default pre-population (`smartDefaultEncodings`
prefers a low-cardinality category over a high-cardinality key, so the builder never
opens on a degenerate chart; spec §06):
- **`distinct`** — the count of distinct non-empty values **in the sample**,
counted only up to `DISTINCT_CAP` (50). Past the cap the exact number stops
mattering — a legend or category axis with 50 entries is already unreadable — so
counting stops and **`distinctCapped`** flags that the real cardinality is at
least the cap. Powers the crowded-legend (discrete colour) and crowded-category
warnings.
- **`numericExtent`** — `{ min, max }` over the numeric values, for `number`
columns only (`null` otherwise), using the **same numeric test** as
`inferColumnType` so the two never disagree. Powers the negative-value **Size**
guard (size encodes magnitude → negatives mislead; Draco `hard.lp:56`).
Both are derived in the same sample pass as type inference, so they are nearly
free, and both are **estimates** (sampled from the head, like the type) — fine for
soft advisories, not guarantees. URL / non-tabular data and datasets stored before
this field carry empty `columnStats`, and the dependent hints simply skip.
---
## 4. Sampling vs. full scan
`rowCount`/`columnCount`/`size` always reflect the **whole** dataset — they're
cheap (a length and a byte count). Only the **per-value** work — type inference
and the §3.3 column stats — has a per-row cost, and it's the one place a huge
dataset could hurt.
So: derive types **and** stats from a **bounded sample** of rows, not the full
column. A fixed cap (e.g. the first ~200 rows) keeps profiling fast and
predictable on large pasted datasets while still being more than enough signal to
classify a column and estimate its cardinality / range.
```ts
const SAMPLE_SIZE = 200;
const sampleRows = <T>(rows: ReadonlyArray<T>): ReadonlyArray<T> =>
rows.length <= SAMPLE_SIZE ? rows : rows.slice(0, SAMPLE_SIZE);
```
Trade-off to be aware of: a column that is numeric for its first 200 rows but
turns to text later will be mis-typed as `number`. That's an accepted cost — the
type is a display hint, the mistake is cheap, and the speed win on large
datasets is worth it. Sampling the head (rather than randomly) keeps results
**deterministic**, which matters for tests and for not surprising the user when
the same paste profiles the same way twice.
### Do / Don't
- **Do** count rows/columns and size over the full payload.
- **Do** cap type-inference **and column-stats** sampling at a fixed head slice for
determinism (so `distinct` / `numericExtent` are estimates, like the type).
- **Don't** randomly sample — non-deterministic profiles break tests and confuse
users.
- **Don't** scan every value of a million-row paste to guess a type.
---
## 5. Testing
Both functions are pure, so tests are plain input/output assertions in Vitest —
no mocks, no DOM, no fixtures beyond literal arrays.
Cover at least:
- **`inferColumnType`**: each type detected from a clean column; mixed columns
fall to `string`; empty/whitespace cells ignored; all-empty and zero-length →
`string`; precedence (a `["true","false"]` column is `boolean` not `string`; a
`["2024","2025"]` column is `number` not `date`); date shape guard rejects
`"42"` and `"hello"` even though one engine's `Date.parse` might accept them;
`0`/`1` are `number`, not `boolean`.
- **`profileData`**: a JSON-array dataset profiles fully; `null` rows (URL) and
an empty array (non-tabular) return the N/A profile but still carry `size`;
column order follows first-seen key order across ragged rows; sampling cap is
respected (a dataset longer than the cap still profiles, using only the head).
---
## Summary
- Four types only: **boolean → number → date → string**, checked in that order.
- **All present values must match** a type or the column falls through; empty
cells are ignored; an all-empty column is `string`.
- Guard date detection with a shape regex before trusting `Date.parse`.
- A **profile** carries `rowCount`, `columnCount`, `columns`, `columnTypes`,
`size`; URL and non-tabular datasets get a **null/N-A** profile (still sized).
- Counts and size scan the whole payload; **type inference samples the head** for
speed and determinism.
- All of it is **pure `src/core/` logic, unit-tested with Vitest**.
@@ -1,516 +0,0 @@
# Naming & Relationships
How Astrolabe keeps entity **names unique** within a collection, and how it
tracks the **bidirectional links** between snippets and datasets so they stay
consistent as entities are created, imported, and renamed.
Two concerns live here, and they reinforce each other:
1. **Name uniqueness** — every dataset has a unique name. Names are the primary
key users see and the key snippets reference, so duplicates would be
ambiguous. We reject duplicate names on create/rename, and auto-suffix
collisions during bulk import.
2. **Relationship tracking** — a snippet references datasets _by name_ through
its `datasetRefs: string[]` field. This is a bidirectional, name-based link:
from a snippet you read its refs; from a dataset you scan snippets to find
who uses it. Renaming a dataset must propagate to every snippet that points
at it, in both the spec and the `datasetRefs` array, or the links rot.
The hard, testable logic is **pure** and lives in `src/core/`. The parts that
read and mutate stores live in `src/app/services/`.
---
## 1. Why names, not IDs, are the link
Datasets carry a numeric `id`, but snippets reference them **by name** because
that is what Vega-Lite uses: a spec resolves data through a named-data
reference, `{ "data": { "name": "MyDataset" } }`. The name _is_ the contract
between a spec and the dataset library. Storing a numeric id in the spec would
mean the spec is no longer a standalone, paste-anywhere Vega-Lite document.
The consequence: names must be unique (two datasets named `Sales` would make
`{ "data": { "name": "Sales" } }` ambiguous), and renaming a dataset is a
**graph operation**, not a single field write — every reference to the old name
must move with it.
---
## 2. Name uniqueness (pure — `src/core/naming.ts`)
### 2.1 Uniqueness check
Comparisons are **case-insensitive** (`Sales` and `sales` collide), so a single
display name maps to a single dataset regardless of how a user types a
reference. The check takes an optional `excludeId` so a rename can ignore the
record being renamed (renaming `Sales` to `Sales` is not a collision with
itself).
```ts
// src/core/naming.ts
/** Case-insensitive set of names already in use, minus an optional excluded id. */
export function isNameTaken(
desired: string,
datasets: ReadonlyArray<{ id: number; name: string }>,
excludeId?: number,
): boolean {
const lower = desired.trim().toLowerCase();
return datasets.some((d) => d.id !== excludeId && d.name.toLowerCase() === lower);
}
```
### 2.2 Making a unique name
When a desired name is taken — during import, "extract inline data", or
"build chart" — we do **not** overwrite the existing dataset. We derive the
next free name by appending a numeric suffix: `Name``Name 2``Name 3`.
The function takes the set of existing names so it has no store dependency and
is trivially unit-testable.
```ts
// src/core/naming.ts
/**
* Returns `desired` if free, else the first available `${desired} ${n}` (n >= 2).
* `existingNames` is the set of names already in the collection.
* Comparison is case-insensitive; the returned name preserves `desired`'s casing.
*/
export function makeUniqueName(desired: string, existingNames: Iterable<string>): string {
const taken = new Set<string>();
for (const n of existingNames) taken.add(n.toLowerCase());
const base = desired.trim();
if (!taken.has(base.toLowerCase())) return base;
let n = 2;
while (taken.has(`${base} ${n}`.toLowerCase())) n++;
return `${base} ${n}`;
}
```
> If a base name already ends in a number (`Q1 2024`), the suffix still appends
> (`Q1 2024 2`). That is intentional: we never parse meaning out of the name,
> we only guarantee a free slot. Keep this dumb and predictable.
**Do**
- Use `isNameTaken` to reject duplicate create/rename in the UI before saving,
and surface an error toast.
- Use `makeUniqueName` for every non-interactive path (import, extract, build)
where blocking the user would be worse than a silent, reported rename.
- Pass `excludeId` on rename so an unchanged or case-only edit is allowed.
**Don't**
- Don't compare names case-sensitively anywhere — pick `toLowerCase()` once and
use it consistently.
- Don't let `makeUniqueName` mutate a store or read store state; it takes plain
data and returns a string.
---
## 3. The bidirectional snippet ↔ dataset link
```
datasetRefs: ["Sales", "Regions"] (forward, on the snippet)
Snippet ───────────────────────────────────────────────────► Dataset "Sales"
▲ │
└──────────── scan all snippets for "Sales" in datasetRefs ◄──────┘
(reverse, derived)
```
- **Forward** (snippet → datasets): read `snippet.datasetRefs`. Cheap, stored.
- **Reverse** (dataset → snippets): there is no stored back-pointer. We compute
it by scanning snippets. Keeping it _derived_ means it can never disagree with
the forward links — there is one source of truth.
`datasetRefs` is **derived from the spec**, not hand-maintained. It mirrors the
dataset names referenced by the **draft** spec — the version being edited — and
is recomputed on every change to the draft (auto-save, the Extract-to-Dataset
rewrite, revert) and on publish. Tracking the draft (not only the last publish)
keeps a snippet's linked-datasets display and the reverse lookup in step with
what the editor shows — so a hand-typed reference or an Extract links its dataset
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 What counts as a reference, and extracting them (pure — `src/core/spec-data.ts`, `spec-refs.ts`)
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-data.ts — the shared classifier (mirrors vega-lite/src/data.ts)
/** 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;
}
```
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.
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.
**Do**
- 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 — 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.
- 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?
The Dataset Manager shows a **usage badge** and a **Linked Snippets** list; the
Snippet Library shows a snippet's linked datasets. Both come from one scan — no
stored back-pointer to drift.
The scan itself is **pure** and lives in core, taking the snippets as a parameter
so the _same_ implementation serves two callers: the reactive UI (which passes its
live `snippets` selection straight in) and the non-reactive service wrapper (which
passes a store snapshot for programmatic callers). This is what makes "who
references this" have exactly one implementation (§6).
```ts
// src/core/relationships.ts — pure scan (unit-tested)
/** Snippets whose datasetRefs include `name` (case-insensitive). */
export function snippetsReferencingDataset<T extends { datasetRefs: readonly string[] }>(
snippets: readonly T[],
name: string,
): T[] {
const lower = name.toLowerCase();
return snippets.filter((s) => s.datasetRefs.some((ref) => ref.toLowerCase() === lower));
}
/** Usage counts for the whole library in one pass, keyed by lower-cased name. */
export function datasetUsageCounts(
snippets: readonly { datasetRefs: readonly string[] }[],
): Map<string, number>;
```
There is no app-layer wrapper module around the scan. The reactive UI — a
component subscribed to `snippets` — calls the core helper directly, so the
badge and Linked Snippets list update the moment any snippet's draft changes
its refs (auto-save) or it is published. A non-reactive caller binds the same
helper to a snapshot inline at its call site
(`snippetsReferencingDataset(useSnippetStore.getState().snippets, name)`) —
one matching implementation, no `getState()` wrapper that would go stale in
reactive code.
**Do**
- Keep reverse lookup a pure scan, parameterized on the snippets so both the
reactive and snapshot callers share it. It is O(snippets) but the collections
are small (library budget ~5 MB); clarity beats an index.
- Call the core helper directly from a store-subscribed component for reactivity;
bind it to a `getState()` snapshot inline for non-reactive code.
**Don't**
- Don't add a `referencedBy` array to datasets. A stored reverse pointer is a
second source of truth that _will_ fall out of sync with `datasetRefs`.
---
## 5. Import: auto-suffix collisions, then report
On import we never overwrite an existing record. A record whose name collides
is renamed to a unique name via `makeUniqueName`, and **every rename is
collected and reported to the user** (toast / summary) so the change is never
silent. Crucially, names are reserved _as we go_ — within a single import, two
incoming `Sales` datasets become `Sales 2` and `Sales 3`, not two `Sales 2`.
The helper is generic over `{ name: string }` because two record kinds key on a
unique name: datasets and custom chart themes (only dataset renames need
propagation — nothing references a theme by name).
```ts
// src/core/import-normalize.ts
import { makeUniqueName } from './naming';
export interface NameRename {
from: string;
to: string;
}
/**
* Returns incoming records with collision-free names, plus the renames applied.
* `existing` are names already in the collection; `incoming` are records to add.
*/
export function dedupeIncomingNames<T extends { name: string }>(
existing: ReadonlyArray<string>,
incoming: ReadonlyArray<T>,
): { records: T[]; renames: NameRename[] } {
const reserved = new Set(existing.map((n) => n.toLowerCase()));
const renames: NameRename[] = [];
const records = incoming.map((r) => {
const unique = makeUniqueName(r.name, reserved);
reserved.add(unique.toLowerCase()); // reserve so later imports don't collide
if (unique !== r.name) renames.push({ from: r.name, to: unique });
return unique === r.name ? r : { ...r, name: unique };
});
return { records, renames };
}
```
> If imported snippets reference the renamed dataset, their `datasetRefs` and
> specs must be rewritten to the new name too. The import flow does this purely,
> before anything is committed: `applyDatasetRenamesToSnippets`
> (`core/import-normalize`) rewrites the incoming snippet set per applied rename
> using the same `renameDatasetInSpec` machinery as §6.
### 5.1 Where the import/export flow lives, and its rules
**Flow:** header (Import/Export buttons in `App.tsx`) → `services/transfer.ts`
(the only store-touching layer) → pure core (`core/import-normalize.ts` shape
detection + normalization + the dedupe/rename/id-reassign helpers; `core/export-envelope.ts`)
- browser IO (`infrastructure/file-transfer.ts`). The pure helpers are unit-tested
hardest; `transfer.ts` only orchestrates (read stores → call core → commit →
notify). The behavior is recorded in spec §08.
Three rules a future change must keep:
- **Datasets commit before snippets** (`DatasetStore.addDatasets` then
`SnippetStore.addSnippets`) so a snippet's by-name reference resolves against the
just-added (possibly suffixed) dataset.
- **Imported datasets get fresh monotonic numeric ids** (`addDatasets`), not their
envelope ids. Safe — and necessary — because datasets are linked **by name, not
id** (§1): id reuse would collide in IndexedDB, but renaming the _id_ breaks
nothing. (This is why the old `Date.now()`-collision TODO on `add` doesn't bite import.)
- **Rename propagation reads the spec, not only `datasetRefs`.**
`applyDatasetRenamesToSnippets` finds the referenced name via
`extractDatasetRefs(spec)` `datasetRefs`, so an imported snippet whose
`datasetRefs` is absent/stale (a hand-crafted or foreign file) still gets its
spec rewritten — the renderer resolves by spec, so a missed rename would break it.
**Do**
- Reserve each chosen name immediately so collisions _within_ one import are
also resolved.
- Return the rename list and show it; a silent rename looks like data loss.
**Don't**
- Don't overwrite or merge a same-named existing dataset on import. Suffix and
keep both — the user decides what to delete.
---
## 6. Rename propagation: keep the link consistent
Renaming a dataset is the operation that ties §2–§5 together. A rename must, in
one atomic step:
1. Update the dataset's own `name`.
2. For **every snippet referencing the old name**: rewrite the named-data
references inside its spec (`{ "data": { "name": "old" } }`
`{ "data": { "name": "new" } }`) — in **both** `spec` and `draftSpec`.
3. Recompute that snippet's `datasetRefs` from the rewritten **draft** spec (the
tracked surface), so the forward link mirrors reality and the reverse scan
stays correct.
The spec rewrite is pure; the orchestration reads and writes stores.
```ts
// src/core/spec-refs.ts — pure rewrite
/** Returns a copy of `spec` with every data.name === oldName replaced by newName. */
export function renameDatasetInSpec(spec: Json, oldName: string, newName: string): Json {
const obj = typeof spec === 'string' ? safeParse(spec) : spec;
const selfDefined = selfDefinedNames(obj); // never rename a spec's own inline dataset name
const rewrite = (node: Json): Json => {
if (Array.isArray(node)) return node.map(rewrite);
if (node && typeof node === 'object') {
const out: Record<string, Json> = {};
for (const [k, v] of Object.entries(node as Record<string, Json>)) {
// A `data` object is a reference site: rename a matching name, but never
// recurse into its payload. `datasets` (self-defined inline data) is left
// whole. Same prune as extractDatasetRefs — see §3.1.
if (k === 'data' && v && typeof v === 'object' && !Array.isArray(v)) {
const dv = v as Record<string, Json>;
out[k] = dv.name === oldName && !selfDefined.has(oldName) ? { ...dv, name: newName } : dv;
} else if (k === 'data' || k === 'datasets') {
out[k] = v;
} else {
out[k] = rewrite(v);
}
}
return out;
}
return node;
};
const rewritten = rewrite(obj);
// Preserve the original spec's stored shape (string vs object).
return typeof spec === 'string' ? JSON.stringify(rewritten, null, 2) : rewritten;
}
```
The one rename implementation in the live app is the store action
**`SnippetStore.renameDatasetRefs(oldName, newName, now)`** — the action that
owns the snippet collection. It matches by spec+draft _content_ (not
`datasetRefs`, which mirrors only the draft), rewrites `spec`/`draftSpec` via
`renameDatasetInSpec`, recomputes `datasetRefs` from the rewritten draft, and
returns the number of snippets changed. Keeping the loop in the store means the
reactive editor buffer is refreshed in the same atomic update when the active
snippet's draft is rewritten. `DatasetStore.update` calls it whenever a save
changes a dataset's name, so a rename propagates everywhere as part of the one
user action — there is no separate coordinator module to call.
> **Collision on rename.** The UI rename form rejects a name already in use via
> `isNameTaken(newName, datasets, dataset.id)`. Programmatic renames (e.g. an
> import flow) instead resolve with `makeUniqueName` first. The propagation
> action itself does not invent a name — it assumes `newName` is the agreed
> target.
**Do**
- Rewrite `spec` **and** `draftSpec`. A user mid-edit must not see their draft
silently break because the dataset was renamed underneath them.
- Recompute `datasetRefs` from the rewritten **draft** spec rather than
string-replacing the array — the draft is the source of truth, the array is
its mirror.
- Find affected snippets by scanning each one's **spec and draft content** for
the old name (`extractDatasetRefs`), not by its `datasetRefs` array. Because
`datasetRefs` mirrors the draft, a name referenced only by the still-published
spec (the user removed it from the draft but hasn't published) is absent from
the array; matching on content rewrites it anyway, so the published spec can't
rot to a renamed-away dataset. Scan the content (don't reserialize) so a
non-referencing snippet's text stays byte-for-byte intact. The import-side
`applyDatasetRenamesToSnippets` already follows this spec-content rule (§5.1).
**Don't**
- Don't update `datasetRefs` without also rewriting the spec — the rendered
named-data reference would still point at the old, now-missing name.
- Don't rename the dataset and skip propagation "for now". A half-applied rename
is the exact inconsistency this whole document exists to prevent.
---
## 7. Snippet name provenance — the naming hierarchy
Snippet names (unlike dataset names) need no uniqueness; what they need is a rule for
**who may rewrite them**. Each snippet carries `nameSource` (spec §09A): `'user'` names
are frozen — set by an explicit rename (`SnippetStore.renameSnippet`) and never touched
by the app again; `'auto'` names are app-picked and keep tracking the spec. On publish,
an auto-named snippet is re-named from the now-published content in priority order: the
spec's `title` (string, line array, or `{ text }` forms), else a mark + encodings
description, else the existing name stands. The derivation dialect is deliberately the
same one `generateChartName` uses for builder output, so manually authored and
builder-built snippets read alike in the library.
Flow: `core/snippet.ts` (`deriveSnippetName`, `isAutoNamed`, `isDefaultSnippetName`) →
`SnippetStore.publish` (the only rewrite site) / `renameSnippet` (the freeze site) →
`SnippetLibrary`'s metadata panel (which must adopt a publish rename — arch 01 §2,
editing buffers). Records predating `nameSource` have no provenance; `isAutoNamed`
treats them as user-named unless the name is **provably** app-picked — the timestamp
default shape, or identical to what `deriveSnippetName` returns for the record's own
published spec. The conservative default is deliberate: rewriting a chosen name is worse
than failing to track an auto one.
---
## 8. Where things live
| Concern | Location | Pure? | Tested |
| ------------------------------------------------------------------------ | -------------------------------- | ------------------- | ----------- |
| `makeUniqueName`, `isNameTaken` | `src/core/naming.ts` | yes | unit |
| `extractDatasetRefs`, `recomputeDatasetRefs` | `src/core/spec-refs.ts` | yes | unit |
| `renameDatasetInSpec` | `src/core/spec-refs.ts` | yes | unit |
| `snippetsReferencingDataset`, `datasetUsageCounts` (reverse-lookup scan) | `src/core/relationships.ts` | yes | unit |
| `renameDatasetRefs` → updated count (rename propagation) | `src/app/stores/SnippetStore.ts` | no (mutates stores) | integration |
| `dedupeIncomingNames` (datasets + custom themes) | `src/core/import-normalize.ts` | yes | unit |
| `deriveSnippetName`, `isAutoNamed` (snippet name provenance) | `src/core/snippet.ts` | yes | unit |
The dividing line: anything that takes plain data and returns plain data is
**core** and unit-tested in isolation; anything that reaches into a Zustand store
is a **store action or app service**. The rule of thumb — _the **draft** spec is the source of
truth, `datasetRefs` mirrors it, the reverse lookup is derived_ — is what keeps
the bidirectional link from ever needing manual repair.
@@ -1,514 +0,0 @@
# 08 · Borrowed Techniques from vega/editor
> The official Vega-Lite editor ([vega/editor](https://github.com/vega/editor)) solves the
> exact "edit a Vega-Lite spec as JSON, validate it, render it live" problem Astrolabe sits
> on top of — minus the snippet/dataset library. This doc distills the techniques worth
> 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 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."
## Source of these findings
A read-only clone of vega/editor lives at `/Users/oleh/code/reference/vega-editor` (shallow
clone of `main`, HEAD `4fdbb59`). Re-clone with
`git clone --depth 1 https://github.com/vega/editor`. Citations below are `file:line` into
that tree.
## Stack delta (read this first — it changes how directly we can borrow)
| | vega/editor | Astrolabe |
| ----------------- | -------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- |
| UI framework | **React** | **React** (moved off Preact before build start) |
| State | Redux-ish single `State` in React context (`useState`) | Zustand **stores**_not_ Redux |
| Monaco | `@monaco-editor/react` + `@monaco-editor/loader` (CDN-loaded Monaco, **workers auto-wired**) | **raw `monaco-editor`** via Vite (**we must wire workers ourselves**) |
| Rendering | **hand-rolled** `vegaLite.compile``vega.parse``new vega.View().runAsync()` | **`vegaEmbed()`** (wraps that same pipeline) |
| Schema validation | Monaco JSON worker **+** standalone `ajv ^8` (two independent layers) | same two-layer model planned |
Because both apps are now React, vega/editor's **component lifecycle patterns port more or
less directly** — the friction is only in (a) state (their Redux-flat-state → our Zustand
stores) and (b) Monaco worker wiring (their CDN loader → our explicit Vite workers).
---
## Decision · Monaco integration (self-hosted, raw API)
> **Decided.** Astrolabe uses **raw `monaco-editor` from npm, bundled and self-hosted**, with
> workers wired explicitly via Vite `?worker` — **not** vega/editor's
> `@monaco-editor/react` + `@monaco-editor/loader` (CDN) setup. Two independent axes:
**Axis A — sourcing: self-hosted/bundled, not CDN. (Forced by Astrolabe's values.)**
vega/editor's `@monaco-editor/loader` fetches Monaco's AMD bundle from a CDN at runtime. For
us that breaks three things at once: (1) **offline** — the CDN bundle is outside Vite's module
graph, so `vite-plugin-pwa`/Workbox never precaches it and offline silently fails; bundled npm
assets are hashed files in `dist/` that Workbox precaches automatically; (2) **privacy** — a
third-party fetch on load contradicts SOUL's "the only outbound requests are user-created
URL-dataset fetches"; (3) **determinism** — npm + `package-lock` is integrity-pinned and
reproducible, a runtime CDN resolve is not. This axis is not a close call; vega/editor's CDN
choice is right _for an online hosted tool_ and wrong for an offline, installable, private app.
**Axis B — React integration: raw API, not `@monaco-editor/react`. (A lean, not forced.)**
The wrapper helps with the easy 80% (mount a JSON editor, lifecycle) and adds nothing to the
load-bearing 20% this app needs:
- **Workers** are still ours — the wrapper never manages `MonacoEnvironment` (see §1 gotcha).
- The **M2 schema service** (`jsonDefaults.setDiagnosticsOptions`, `fileMatch`) is namespace-level;
you reach _through_ the wrapper via `onMount`, so it saves nothing there.
- Its headline **`value`/`onChange` controlled-input model is a hazard**: driving Monaco's
content from React state causes cursor jumps and undo-stack churn, against §10's "typing
stays fluid" — you end up using it uncontrolled, i.e. the raw pattern anyway.
- Its **CDN-by-default** is a standing footgun (works in dev online, fails offline in prod
unless you remember `loader.config({ monaco })`).
Against that, raw costs **one testable `useMonacoEditor` hook** (~5080 lines: create in
`useEffect`, `dispose` on unmount, push value, subscribe to `onDidChangeModelContent`, resize).
That's the **same imperative-teardown discipline already adopted for `vega-embed`** in doc 05
(`view.finalize()`), and consistent with already using raw `vegaEmbed()` over a React chart
wrapper — "thin integration layers we own" (SOUL). Lock-in is low either way, so the final
raw-vs-wrapper call is confirmable at the Monaco spike; what is **not** up for revisiting is
self-hosting.
**Accepted cost:** the explicit worker wiring (§1) is inherent to self-hosting — it is the
price of offline, paid in any non-CDN setup, and the wrapper would not remove it.
### Entry point: `edcore.main`, never `editor.api` (trim languages, not features)
Self-hosting raw Monaco forces a choice of ESM entry point, and the granularity matters:
| Import | What you get | Use? |
| --------------------------- | ------------------------------------------------------------------- | -------------------------------------------------------------------------------------- |
| `monaco-editor` (barrel) | All features **+ every basic language** (sql, abap, solidity, …) | ❌ language bloat (~20 dead chunks) |
| `esm/vs/editor/editor.api` | The API surface only — **zero feature contributions** | ❌ a text box: no folding, suggest widget, `Cmd+Backspace`, find, bracket colorization |
| `esm/vs/editor/edcore.main` | `editor.all` (all 59 feature contributions) + API, **no languages** | ✅ full editor UX, JSON-only weight |
Import **`edcore.main`** and add only the JSON language service
(`esm/vs/language/json/monaco.contribution`). `edcore.main` ships no `.d.ts` of its own —
add an ambient `declare module … { export * from '…/editor.api'; }` so types (and Monaco's
global `MonacoEnvironment` augmentation) resolve. Two editor options worth setting because
they bite Vega-Lite specs specifically: `showFoldingControls: 'always'` (fold arrows always
visible), and `quickSuggestions: { strings: true }` (VL enum values like `"bar"` live inside
JSON strings, where Monaco disables auto-suggest by default).
> Reaching for `editor.api` to "drop unused languages" silently strips every editor feature —
> the languages live elsewhere. This is the concrete case behind AGENTS.md's **"trim content,
> not capability"** rule: cut the unwanted _content_, keep the _behavior_, and verify the
> behavior survived by exercising the editor, not by a green build.
---
> **The single biggest surprise:** vega/editor does **not** use `vega-embed` for its live
> preview. It builds the compile→parse→View pipeline by hand; `vega-embed` is imported only
> for types and the exported standalone-HTML snippet. This is _good news_ — `vega-embed` is
> exactly the wrapper they wrote by hand, so we get it for free. But their hand-rolled
> version (`src/components/renderer/renderer.tsx`) is the best available documentation of the
> lifecycle/cleanup discipline `vega-embed` still expects from us.
---
## 1 · Monaco + Vega-Lite schema wiring (M2 — highest from-scratch risk)
All of vega/editor's Monaco setup is one file: `src/utils/monaco.ts`.
**What to borrow:**
- **Bundle the schema; never fetch it.** They `import vegaLiteSchema from 'vega-lite/vega-lite-schema.json'`,
resolved by a Vite alias to the package's `build/` output (`monaco.ts:7-8`, `vite.config.ts`).
The schema version is pinned to the installed `vega-lite` — offline-safe, version-locked,
no runtime network call. Astrolabe should do the same.
- **Attach via the JSON language service**, once, globally:
`monaco.languages.json.jsonDefaults.setDiagnosticsOptions({ schemas, validate:true, ... })`
(`monaco.ts:51-57`).
- **`markdownDescription` patch** (`monaco.ts:12-13`, `utils/markdownProps.ts`): recursively
copy every schema `description``markdownDescription` before registering. Monaco renders
rich hover docs only from `markdownDescription`; without this, hovers are plain text. Do it
once at setup.
- **Replace the built-in JSON formatter** with `json-stringify-pretty-compact` via
`registerDocumentFormattingEditProvider('json', …)` (`monaco.ts:60-61,71-80`) for Vega's
compact array-on-one-line style.
- **Editor options worth copying** (`spec-editor/renderer.tsx:263-274`): `folding:true`,
`minimap.enabled:false`, `scrollBeyondLastLine:false`, `wordWrap:'on'`,
`quickSuggestions:true` (this is what makes schema completions appear without an explicit
trigger), `stickyScroll.enabled:false`.
**Gotchas / where we improve:**
- ⚠️ **Workers are on us.** vega/editor never configures Monaco workers — the CDN loader does.
With raw `monaco-editor` + Vite we **must** set `self.MonacoEnvironment.getWorker` to return
the `json.worker` for label `'json'` and `editor.worker` otherwise (via `?worker` imports):
```ts
import EditorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker';
import JsonWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker';
self.MonacoEnvironment = {
getWorker: (_id, label) => (label === 'json' ? new JsonWorker() : new EditorWorker()),
};
```
The `json.worker` runs schema validation + autocomplete. **No worker ⇒ no squiggles, no
completions.** Upside: dropping the CDN loader makes `monaco` synchronously importable — no
`await loader.init()` dance, just call `setDiagnosticsOptions(...)` at module load.
- ⚠️ **`$schema`-based binding vs `fileMatch`.** They register schemas under versioned `uri`s
(`.../vega-lite/v6.json`) and bind by matching the doc's `$schema` value — **no `fileMatch`**
(`monaco.ts:15-46`). Consequence: a spec with **no `$schema` gets zero validation/autocomplete.**
Astrolabe should prefer `fileMatch` against our model URIs so validation works regardless of
whether the user wrote a `$schema` line.
- ⚠️ **Set `enableSchemaRequest:false`** for our offline-first app. They set it `true`
(`monaco.ts:54`), which lets the worker network-fetch any unbundled `$schema` URL — failing
network calls for an offline app. Register all schema versions locally instead.
- The schema is multi-MB; register it **once globally**, never per-model.
## 2 · Live preview with `vega-embed` (M1 lifecycle, M2 fit-mode)
This is doc [05](05-rendering-theming-preview.md)'s territory; these are the concrete details
vega/editor's hand-rolled renderer (`src/components/renderer/renderer.tsx`) reveals.
**What to borrow:**
- **Theme = a `vega-themes` config object merged into the spec config.** There is no automatic
light/dark sync in vega/editor — theme is an explicit choice baked in at compile
(`config-editor/config-editor-header.tsx:5-37`). For Astrolabe: pass the chosen `theme`/`config`
to `vegaEmbed`, and when our theme changes, re-embed with the new config.
- **`"width":"container"` / `"height":"container"` is how VL responsiveness works** — it
compiles `width`/`height` to signals that re-read `containerSize()` **only on a
`window:resize` event** (`renderer.tsx:78-90` detects container sizing). Two things a
from-scratch impl _will_ get wrong (we did): (1) `view.resize().runAsync()` does **not**
re-measure — it re-runs layout with the stale size; (2) a pane drag fires no window resize, so
nothing re-fits on its own. The fix is exactly vega/editor's
`window.dispatchEvent(new Event('resize'))` (`renderer.tsx:101-122`) — **not a hack, the
actual mechanism** — driven by a `ResizeObserver` on the pane. It also leaves a non-container
dimension natural for free. Full write-up in doc [05](05-rendering-theming-preview.md) §8.
- **Reuse the view for cheap changes.** They rebuild the `View` only on spec change; renderer
(svg/canvas) and tooltip toggles re-`initialize()` the existing view (`renderer.tsx:367-371`).
- **Capture warnings separately from errors** via a buffering logger (see §4's `LocalLogger`).
**Gotchas / where we improve:**
- ⚠️ **Finalize before re-embed, or leak.** Every spec change must `view.finalize()` the old
view _and_ clear the container before mounting the new one (`renderer.tsx:218-226`). `vegaEmbed`
returns `{ view, finalize }` — call `finalize()` before the next embed and on unmount. This is
already a Do-rule in doc 05; vega/editor confirms how easy it is to leak otherwise.
- ⚠️ **Race on rapid edits.** `runAsync` is async; a stale render can resolve after a newer one
mounts. vega/editor mitigates only with debounce. **We should add a render-generation token**
and ignore stale resolves (an improvement over the reference).
- ⚠️ **Wrap `runAsync` 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 (`renderer.tsx:247-259`).
- The **CSP-safe expression interpreter** (`vega-interpreter` + `vega.parse(..., {ast:true})`)
matters only under a strict no-`eval` CSP. A local offline app doesn't need it — keep it
opt-in.
## 3 · Two-tier validation & error surfacing (M2, spec §03E)
vega/editor runs **two independent schema-validation systems** with no reconciliation, and
sorts errors into two tiers. Both are worth copying.
**The two layers:**
1. **Monaco JSON worker** → inline **squiggles, hovers, autocomplete** in the editor.
2. **`ajv ^8`** (`src/utils/validate.ts`) → runs at parse time, feeds the **error/log pane**.
It does _not_ create editor markers.
**The two error tiers (keep them separate):**
- **Fatal / blocking** — thrown exceptions: JSON syntax error, VL compile error, Vega runtime
error. These set a single `error` and suppress the chart.
- **Advisory** — ajv schema-validation findings and `$schema` version mismatch. These are a
warnings list and do **not** block rendering. (Vega-Lite emits many benign warnings; treating
ajv output as fatal would wrongly hide specs that render fine.)
The orchestration is `app.tsx:188-291`: `parseJSONCOrThrow` → `$schema` semver check (warn) →
`validateVegaLite` (ajv, warn) → `vegaLite.compile` (throw=fatal) → render (`renderer.tsx`,
throw=fatal).
**ajv setup specifics that _will_ bite a from-scratch impl** (`validate.ts:9-17`):
- `new Ajv({ strict: false })` — the VL/Vega schemas fail ajv strict-mode at **compile** time
otherwise.
- The VL schema is **draft-06** → must `ajv.addMetaSchema(json-schema-draft-06.json)` (ajv 8
defaults to draft-07/2020) or `compile` throws.
- Register a no-op `color-hex` format (`ajv.addFormat('color-hex', () => true)`) plus
`addFormats(ajv)`; the schema references formats ajv-formats doesn't cover.
- **Compile the validator once at module load and cache it** — the schema is huge; compiling
per keystroke is a perf killer.
**Where we improve:** ajv errors are shown as JSON-pointer text (e.g. `/encoding/x`) with **no
editor position** — vega/editor does not map them to markers. Only JSON _syntax_ errors get a
line/col (via jsonc-parser's visitor, `utils/jsonc-parser.ts:3-17`). If our §03E wants inline
ajv markers, we map `instancePath` → editor offsets ourselves via jsonc-parser's node tree —
something the reference does _not_ do.
## 4 · Data flow & debouncing (M1/M2 — translate to Zustand stores)
vega/editor keeps **`editorString` (the text) as the single source of truth**; the parsed spec
and compiled Vega spec are _derived_ and recomputed by a subscriber when text/mode/config change
(`app.tsx:338-365`). Errors don't clobber the last-good derived specs.
**The Zustand-store translation (this is the shape to build):**
```
text (store field, debounced writer on editor change)
└─▶ parsedSpec (derived: JSONC parse + collect syntax/diagnostic errors)
└─▶ renderInput (derived: prepareSpecForRender — refs, fit-mode)
└─▶ effect: deep-equal guard → vegaEmbed(); finalize previous view
```
**What to borrow:**
- **Debounce only at edit→state**, not state→render. vega/editor debounces the editor at
**1200 ms** (`spec-editor/renderer.tsx:66`) and guards the render with a `deepEqual` prop
diff (`renderer.tsx:340-349`). (1200 ms is _their_ number; tune ours — our settings expose a
render-debounce preference.)
- **A manual-parse escape hatch** (Ctrl/Cmd+S re-parses without waiting) maps to a future
live-vs-manual preview toggle (`renderer.tsx:89-111`).
- **`LocalLogger` pattern** (`utils/logger.ts`): a logger that buffers `errors/warns/infos/debugs`
into arrays instead of writing to console. This lets a **pure** `src/core` compile/validate
step _return_ structured diagnostics with zero browser coupling — e.g.
`validateSpec(spec) → { errors, warns }`. Ideal core-first fit.
- **`json-stringify-pretty-compact`** for the format action and prettify-on-load — much nicer
than `JSON.stringify(…, null, 2)` for VL specs.
**Persistence note:** vega/editor snapshots its whole state to localStorage on _every_ change,
stripping non-serializable fields (`view`, `runtime`, editor refs) and restoring via
`{ ...DEFAULT_STATE, ...parsed }` (`context/app-context.tsx`). Our **debounced auto-save to
IndexedDB** (doc 01/02) is the better pattern — but the "strip non-serializable, restore with
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 |
| ----------------------------------------------------------------------------------------- | -------------------------------------- | --------- |
| Bundle VL schema from package `build/`; `setDiagnosticsOptions` | `src/app/infrastructure/` Monaco setup | M2 |
| `markdownDescription` patch + compact formatter | Monaco setup | M2 |
| Explicit Vite worker wiring (`MonacoEnvironment.getWorker`) | Monaco setup | M2 |
| `fileMatch` schema binding (improvement over `$schema`-only) | Monaco setup | M2 |
| jsonc-parser tolerant parse + line/col syntax errors | `src/core/` | M1/M2 |
| ajv wrapper (`strict:false`, draft-06, color-hex, compile-once) → structured diagnostics | `src/core/` | M2 |
| `LocalLogger`-style buffered diagnostics from pure compile | `src/core/` | M2 |
| Fatal-vs-advisory two-tier error model | rendering/store contract | M1/M2 |
| `"container"` sizing + `ResizeObserver` → synthetic `window:resize` (not `view.resize()`) | `chart-renderer` + LivePreview | M2 |
| `finalize()`-before-reembed + **render-generation guard** | LivePreview | M1 |
| theme = `vega-themes` config merged into `vegaEmbed` | preview + settings | M5 |
| `json-stringify-pretty-compact` format action | editor | M2 |
## Where we deliberately do better than the reference
- **Wire Monaco workers explicitly** (they sidestep it via the CDN loader).
- **Map ajv errors to editor positions** via jsonc-parser offsets (they show pointer text only).
- **Render-generation guard** against stale async renders (they rely on debounce alone).
- **`fileMatch`-based schema binding** so validation works without a `$schema` line.
- **Debounced auto-save to IndexedDB** rather than write-the-whole-state-on-every-change.
## Key files in the reference (for deeper reads)
- `src/utils/monaco.ts` — all Monaco/schema wiring
- `src/utils/markdownProps.ts` — the `markdownDescription` patch
- `src/utils/validate.ts` — ajv setup + cached validators
- `src/utils/jsonc-parser.ts` — tolerant parse + line/col syntax errors
- `src/utils/logger.ts` — `LocalLogger` / `DispatchingLogger`
- `src/components/renderer/renderer.tsx` — the hand-rolled View lifecycle (finalize, sizing, errors)
- `src/components/app.tsx:188-365` — parse → $schema check → ajv → compile → render orchestration
- `src/components/error-pane/renderer.tsx` — error/log display
- `src/constants/default-state.ts` — the full app-state shape
- `src/components/input-panel/spec-editor/renderer.tsx` — editor component, 1200ms debounce, $schema→mode detection
-541
View File
@@ -1,541 +0,0 @@
# 09 · Visual Design Language
> **Status:** foundational design pass. This is the _visual_ contract — the
> counterpart to `docs/spec/` (behavior) and the rest of `docs/architecture/`
> (structure). `styles/tokens.css`, `styles/base.css`, component CSS Modules, and
> `src/core/vega-themes.ts` implement _to this doc_.
>
> **Companion:** [`visual-specimen.html`](./visual-specimen.html) — a standalone,
> openable "kitchen sink" that renders every token and element with a live
> theme/accent switcher. Edit tokens there first, eyeball them, then port the
> settled values into `styles/tokens.css`.
Astrolabe's look is **inspired by the IBM Design Language / Carbon**, but Carbon is
**not a dependency** — we transcribe the values we want and reinterpret the
principles in our own words. We borrow IBM's _engineered structure_; we keep
_color and theming free_.
---
## 1. Principles
IBM's four design principles map almost exactly onto Astrolabe's SOUL ("the spec is
the star; the UI is a thin, considered shell"). Restated for us:
1. **Considered**_remove everything gratuitous._ No decoration that isn't
carrying meaning. Whitespace is a feature.
2. **Unified** — a _small fixed kit_ (one type family, a neutral ramp, one accent,
a handful of components) reused systematically. Identity comes from consistency,
not novelty per screen.
3. **Executed**_everything communicates, including what we leave out._ Alignment,
rhythm, and empty space are decisions, not leftovers.
4. **Progressive**_every element reduces friction._ If it doesn't help the user
read, edit, or find a snippet faster, it doesn't earn its place.
…plus our own, where we part ways with IBM:
5. **Structure is rigorous; color is free.** The grid, type scale, spacing, and
square geometry are systematic and fixed. Color, accent, and theming are the
_expressive_ layer — open, swappable, and meant to be played with.
---
## 2. Deliberate divergences from Carbon
What we **borrow** vs. where we **diverge** — recorded so future readers know these
were choices, not drift:
| Topic | IBM/Carbon | Astrolabe |
| ------------------------------- | --------------------------------------------------- | ----------------------------------------------------------------------------------------- |
| Adoption | A framework + component lib | **Inspiration only.** Transcribed tokens, our own components |
| Structure (grid, type, spacing) | 8px mini unit, modular type scale | **Borrowed wholesale** — it's the rigorous part worth having |
| UI chrome corners | ~02px (near-square) | **Fully square, `radius: 0`** — one notch more austere/engineered |
| Icons | Rounded exteriors, 2px soft corners + 90° interiors | **Kept rounded** (use Carbon's icon set) — the one warm, human touch |
| Color | "Blue at the core"; other hues only for purpose | **Dropped.** Color/theming is free and expressive; accent is a token, many themes welcome |
| Neutrals | Carbon gray ramp | **Borrowed** — accessible, well-tuned, a good legible base |
| Motion | Productive vs. expressive | **Productive only** — subtle, purposeful, reduced-motion-aware |
| Secondary button | A dark gray **fill** (`$button-secondary`) | **Outlined** (Carbon's _tertiary_ shape) — one filled button per region stays the rule |
---
## 3. Tokens
All tokens are CSS custom properties on `:root`, themed by overriding them on
`[data-theme]` (and, for accent, `[data-accent]`). As of M1.5 the settled values
live in `styles/tokens.css`; the specimen remains the sandbox for trying new
tokens/themes before porting them across.
### 3.1 Typography — IBM Plex
- **Families:** `IBM Plex Sans` for UI, `IBM Plex Mono` for the editor, code,
numeric/tabular data, and inline spec fragments. Self-hosted in production via
`@fontsource/ibm-plex-sans` + `@fontsource/ibm-plex-mono` (offline/PWA — never a
CDN). The specimen uses a CDN purely for preview convenience.
- **Scale (px), from Carbon's modular scale:** `12 · 14 · 16 · 18 · 20 · 24 · 28 ·
32 · 42`. Body is **14/20** (already our `--font-size-base`). Captions/labels 12.
- **Weights:** 400 regular, 600 semibold for emphasis/headings; 300 light reserved
for large display only.
- **Breathing room:** Plex _"requires space to breathe."_ Don't over-tighten —
body line-height ≥ 1.4, default tracking (no negative letter-spacing on text).
Flush-left, clear hierarchy.
### 3.2 Spacing — the 8px base unit
IBM's product/web rule: _"the 8px mini unit guides everything."_ Every gap, pad,
and size is a relationship of 8 (with 2/4 as fine sub-steps):
`--space-1: 2px · --space-2: 4px · --space-3: 8px · --space-4: 12px · --space-5:
16px · --space-6: 24px · --space-7: 32px · --space-8: 48px · --space-9: 64px`.
> Note: this renumbers our current M0 scale to anchor on 8. The migration is
> mechanical (search/replace `--space-*` usages) and lands with the design pass.
### 3.3 Color — role-based, theme-free
Color is expressed as **roles**, never raw hexes, so themes can repaint the whole
UI by swapping one set of values. Borrowed from Carbon's layering model:
| Role token | Meaning |
| ---------------------------------------------------- | -------------------------------------------------------------------------------------- |
| `--bg` | App canvas (lowest layer) |
| `--layer-01` / `--layer-02` | Raised surfaces (panels, cards, popovers) — elevation by lightness step, not shadow |
| `--border` / `--border-strong` | Subtle separators / component boundaries (3:1 non-text contrast) |
| `--field-01` / `--field-02` (+ `--field-hover-*`) | Field fills: one step off the canvas / off a `--layer-01` surface |
| `--text` / `--text-secondary` / `--text-placeholder` | Text hierarchy |
| `--accent` / `--accent-hover` / `--accent-contrast` | The expressive accent — **swappable**; UI must never hardcode a hue |
| `--accent-soft` / `--accent-soft-hover` | Low-emphasis accent **wash** (accent mixed into `--bg`) for a tinted-but-quiet surface |
| `--focus` | Focus-ring color (defaults to `--accent`) |
| `--support-error / -success / -warning / -info` | Status only — color = meaning |
- **Neutrals** use the Carbon gray ramp (`#f4f4f4#161616`) — accessible and
legible. **Accent and theming are open**: the default accent is **deep teal**
(`#0e7490` / dark `#2dd4bf`), with opt-in alternates (blue, indigo, amber, rose)
and light/dark themes to prove the system is free, not blue-bound. Pick, add, or
invent themes freely.
- **Status palette** (borrowed, stable): error `#da1e28`, success `#198038`,
warning `#f1c21b`, info `#0043ce` — tuned per theme for contrast.
- **Contrast:** target WCAG AA (4.5:1 text, 3:1 large/UI). Accent-on-`--bg` and
text-on-`--accent` must both pass for any shipped theme. **`--border-strong` is a
component boundary, not decoration — it must hold 3:1 against the surface it
bounds (WCAG 1.4.11)**: gray-50 `#8d8d8d` light (3.32:1 on `--bg`, 3.02:1 on
`--layer-01`), gray-60 `#6f6f6f` dark (3.60:1 on `--bg`). Carbon's gray-30/gray-70
"strong" values fail this; don't drift back to them.
- **Soft accent is derived, not hardcoded.** `--accent-soft` /
`--accent-soft-hover` are `color-mix(in srgb, var(--accent) 1220%, var(--bg))`,
so the wash follows whatever accent + theme is active rather than carrying a
per-accent value. Use it for a surface that should be _noticed_ without competing
with a primary action (the header's **Support** button).
- **Field-on-layer (Carbon layering).** A field's fill is **one step off the
surface it sits on**, alternating like Carbon's field set: `--field-01` on the
canvas (gray on white / near-black on black), `--field-02` on a `--layer-01`
surface (white on gray / a step lighter on dark). The alternation is what keeps
a field visible without a box and prevents three indistinct grays from stacking
(canvas → panel → field — the snippet-metadata-panel bug). Mechanism: components
consume the contextual **`--field`** / `--field-hover` tokens only; an elevated
surface sets `--field: var(--field-02)` (and the hover variant) once on its
container — the same pattern as `--control-hover-fill`. Setters today: the modal
chrome and body (ModalShell), the library metadata panel, the settings popover.
### 3.4 Shape & elevation
- `--radius: 0` for all chrome (buttons, fields, cards, panels). Square is the look.
- **Icons are exempt** — they keep their rounded geometry (Carbon icon set, 2px
corners). Icons are SVG, not chrome, so `--radius` doesn't touch them.
- **Elevation is lightness, not shadow.** Stack `--bg → --layer-01 → --layer-02`.
Shadows, if ever used, are minimal and reserved for true overlays (modals,
popovers).
- **Borders are 1px**, `--border` subtle by default.
### 3.5 Motion
- **Durations (productive):** `--dur-fast: 70ms`, `--dur-fast-2: 110ms`,
`--dur-moderate: 150ms`. Nothing slower in the core UI.
- **Easing:** standard productive `cubic-bezier(0.2, 0, 0.38, 0.9)`.
- **Restraint:** animate only what's vital (state changes, entrances of meaningful
elements). No gratuitous motion. All transitions are already neutralized under
`@media (prefers-reduced-motion: reduce)` in `base.css`.
---
## 4. Component conventions
- **How a shared look travels.** Exactly four mechanisms, in escalating order:
**design tokens** (`styles/tokens.css`) for values; **contextual custom
properties** set once by a surface and consumed by everything on it
(`--control-hover-fill`, `--field`); **`base.css` element baselines** at zero
specificity for looks every instance of an element shares (the focus ring, the
field recipe); **React primitives** (`Button`, `IconButton`) when shared
_behavior_ or enforced variants justify a component. Nothing else — no
CSS-module `composes`, no utility classes, no mixin layer. A fifth mechanism is
drift, even when it's locally cleaner. Every
interactive control is `--control-height` (32px) or `--control-height-lg`
(40px) — tokens.css. 32px is THE control height: buttons, fields, selects,
segmented controls, icon buttons, anything in a toolbar, form row, or dialog
action row. 40px is reserved for standalone primary CTAs (the library's Build
Chart, a modal list-pane's New X) and modal footers. A third height is drift —
the pre-token codebase accumulated 26/28/30/36/42px variants one component at a
time, which read as "shaky" the moment controls shared a row. The rule is
enforced mechanically: action buttons are the **`Button`** primitive, icon-only
buttons are **`IconButton`** (24px `sm` exists solely for controls nested
_inside_ a 32px control — a search field's clear, a toast's dismiss); writing
`height:` on a new ad-hoc button is the code smell.
- **Color inputs go through `ColorField`** — the one home for the native
`type="color"` chrome reset and the optional paired hex field (read/copy/retype
a value). A bare `<input type="color">` is the code smell; the Theme Builder
swatches (`hex`) and the Chart Builder constant-colour binding (`size="sm"`,
inside a pill) share it. Per-context removal is the caller's, not the field's.
- **Buttons:** square, via the `Button` primitive. Variants: **primary** (filled
`--accent`), **secondary** (1px `--border-strong`, `--bg` fill —
so a bordered control on a gray panel goes white, never a darker gray), **ghost** (text-only, borderless; a transparent
border holds the box size), **soft-accent** (ghost on an `--accent-soft` wash —
a low-emphasis solicitation, e.g. Support), **danger** (filled
`--support-error` — the confirm step), **danger-outline** (secondary geometry,
red label, filling solid red on hover/focus — a destructive action sitting
among peers, e.g. a detail view's Delete). 13px 600-weight label. Clear hover/active and a visible
focus ring. **Inline link-style actions are a separate kind, not a Button
variant**: small accent-text actions embedded in content ("+ Add filter",
"Swap X/Y", "Use a constant", a popover's Reset) deliberately sit below the
control scale — content-sized, 1112px, no box — so they read as part of the
prose/panel they act on, not as toolbar controls. Don't "promote" them to
Buttons; their smallness is the emphasis level.
- **Borders mark function, not decoration.** Fields and select-like triggers are
**not boxes** — they're the quiet-field recipe below (fill + bottom border). A
1px `--border-strong` **box** is reserved for the few value-holding controls
that need full enclosure: segmented controls, secondary buttons, drop targets
(dashed), the color-swatch input. Plain actions are ghost or filled — never
outlined boxes; passive chrome (tags, badges, type glyphs) takes `--border`,
never `--border-strong`. List rows are flat (hairline dividers + hover fill),
not stacked boxes. With square chrome, every box makes alignment errors
visible, so each border must earn its place; when a region looks "busy",
remove boxes before shrinking anything.
- **Emphasis hierarchy (Carbon button/usage).** A region carries **one**
high-emphasis (primary) button at most; everything else is lower emphasis. In
toolbars/headers full of utilities, the utilities go **ghost** so they recede
behind the work area and read as a row of equals — only the genuine call to
action is filled. Worked examples: the **editor toolbar** (Publish is the lone
primary; Extract/Revert are secondary, and collapse to icons when narrow — see
[arch 10 §8](10-interaction-and-feedback.md)), and the **header** (Datasets /
Import / Export / About are **icon-only** IconButtons per Carbon's UI-shell
header — global actions as a right-aligned icon row — with accessible names
that carry scope, e.g. "Export workspace" vs the preview's per-chart "Export";
a divider then sets off the soft-accent Support, which keeps its text label —
a solicitation needs the word — and the ghost theme toggle).
- **Hover is variant-specific:** filled buttons (primary/danger) **darken**
(`--accent-hover` / a slight brightness drop); outlined/ghost buttons **gain a
fill one elevation step above their surface** — on `--bg` → `--layer-01`, on a
`--layer-01` surface (dialogs, panels) → `--layer-02`. Filling to the _same_
layer as the surface reads as no hover at all (the collision that left the
confirm dialog's Cancel looking dead). The step is mechanical: Button/IconButton
hover with `var(--control-hover-fill, var(--layer-01))`, and an elevated surface
sets `--control-hover-fill: var(--layer-02)` once on its container (the header,
the modal chrome, the confirm card, the library's metadata panel) — controls
inherit the right step instead of each re-encoding it.
- **Focus ring:** a 2px `--focus` outline (offset 12px). Always visible on
keyboard focus — accessibility is non-negotiable (principle 4).
- **Fields** (text, textarea, select-trigger, search): **the quiet field** —
square, `--field` fill (one step off the surface, §3.3), **bottom border only**
in `--border-strong`, no box; 2px `--focus` ring hugging the box (offset 2px)
on focus. Mono font for spec/JSON inputs. The recipe is declared **once** in
`styles/base.css` as a zero-specificity element baseline (text-like `input`
types + `textarea`, excluding Monaco's internal widgets); component modules add
only idiosyncrasies — width, padding, font size — never a competing border or
fill. Select-like triggers are `<button>`s the baseline can't reach, so
SelectControl/SortControl restate it (they are fields, not buttons: a trigger
holds a value); their hover/open fill is `--field-hover`. Writing `border:` on
an input is the code smell — the field look has exactly one home. The quiet
treatment is Carbon's; the boxed alternative (GOV.UK's canon — 2px solid
enclosure) is equally legitimate a11y-wise but spends a box on every field,
and in a square-chrome editor UI boxes are reserved for the few controls that
need full enclosure. A field's boundary is carried by its fill step plus the
3:1 underline.
- **List rows** (snippet library): compact, full-row hover (`--layer-01`),
active row marked by an accent left-border + `--layer-01` fill, secondary
metadata in `--text-secondary`. Row-level actions reveal on hover.
- **Status indicators:** a small dot/tag for draft vs. published; a dataset glyph
when references exist. Status colors only.
- **Toasts:** `--layer-02`, 1px border in the support color, square, brief.
- **Dialogs (confirm / alert):** centered card on a dimmed backdrop
(`rgb(0 0 0 / 0.5)`), `--layer-01` fill, 1px `--border`, minimal overlay shadow,
square. A title, a `--text-secondary` message, and a right-aligned action row:
**Cancel** (secondary) + the primary, which is a **danger** button (filled
`--support-error`, `--on-status` label) for destructive intent. The in-app
replacement for `window.confirm`; see [arch 03 → Confirmation & alert dialogs](03-modal-system.md#confirmation--alert-dialogs)
for behavior (Carbon transactional rule: backdrop does **not** dismiss; Cancel
takes focus for danger). Use a dialog only when a decision is required —
non-blocking outcomes are **toasts**.
- **Code / editor surfaces:** `--font-mono`, `--layer-01`, generous line-height.
---
## 5. Iconography
> Geometry and the accessibility floor are set elsewhere: icons keep Carbon's
> rounded 2px geometry and are exempt from `--radius` (§3.4), and every icon-only
> control carries an accessible name with meaning never resting on colour alone
> ([arch 10 §56](10-interaction-and-feedback.md)). This section is the **usage**
> contract — _when_ a thing earns an icon, and how the set stays coherent.
**Stance: balanced, label-first.** Astrolabe is a tool its user returns to often
— the one context where GOV.UK concedes icons earn their place: _"Icons can be
more useful in case working systems, where users are familiar with the interface
and return to it frequently … In most cases it's still helpful to include a
visible text label alongside any icons"_ (GOV.UK, `styles/images`). So we are
neither icon-rich (Carbon's default density) nor icon-austere (GOV.UK's
public-service default): the default is **text**; an icon is added only when it
does real work, and usually _alongside_ the text, not instead of it.
### 5.1 The icon-vs-text decision
Apply in order:
1. **Default to text.** If a label alone is clear, ship the label. An icon that
only decorates fails principle 1 (Considered) and invites the ambiguity GOV.UK
warns of — _"people can understand a single icon to mean different things."_
2. **Add an icon when it does a job** — one of: speeds scanning of a list/row read
repeatedly (dataset marker), signals status/type at a glance (draft dot), or
affords a high-frequency action (delete). Carbon's rule holds: _"employ icons
sparingly and strategically … to reduce cognitive load."_
3. **Pair icon + text by default.** In any labelled control, menu item, or row,
the icon rides _alongside_ its text — recognition support (NN/g #6), not a
replacement for the word.
4. **Icon-only is the exception, and a closed set.** Permitted only for the
**universal set** (§5.2) — glyphs whose meaning is unambiguous and which recur
everywhere. A new icon-only control is never created ad-hoc; admitting one to
the set is a contract change, not a per-component decision.
| Form | When | Accessible name |
| ----------- | -------------------------------------------------------- | ------------------------------------------------- |
| Text only | The default. Label is clear on its own. | The visible text |
| Icon + text | Icon aids scanning/status; text stays the primary label. | The visible text; icon `aria-hidden` (decorative) |
| Icon only | Universal-set glyph in a space-constrained control. | `aria-label` on the control (APG button pattern) |
> Accessible-name mechanics (APG button pattern): a control's name comes from its
> text content, or from `aria-label`/`aria-labelledby` when there is none. So an
> icon **beside visible text** is `aria-hidden` (the text names it — avoiding the
> duplicate screen-reader readout GOV.UK flags); an icon **alone** needs an
> `aria-label`.
### 5.2 The icon vocabulary (controlled set)
One icon = one meaning, **app-wide** — GOV.UK: _"Do not use a single icon to
represent more than one thing."_ The vocabulary is a **uniqueness ledger**, not a
hall of fame: every glyph is registered here so the same meaning always reuses its
glyph and no glyph is ever repurposed — even a one-off gets a row, so it can't be
reused for something else later. The registry lives in code at
[`src/app/components/Icon.tsx`](../../src/app/components/Icon.tsx) (the `IconName`
union + `GLYPHS` map); this table is its prose mirror. Glyphs are traced from
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) |
| References a dataset | `DataTable` | icon + text | Library row marker, Linked-datasets list, header **Datasets**, editor **Extract**⁴ |
| Add / create-new | `Add` | icon + text → icon-only⁴ | Library "Create New Snippet" (collapses to "+" when the pane is narrow), Datasets "New …" |
| Delete | `TrashCan` | icon-only ⭐ (danger) | Library row delete¹ — text "Delete" in the panel² |
| Import workspace | `Upload` | icon + text | Header **Import** (a file is brought into the app) |
| Export workspace | `Download` | icon + text | Header **Export** (the workspace is written out) |
| About / information | `Information` | icon + text | Header **About** |
| Revert draft | `Reset` | icon + text → icon-only⁴ | Editor toolbar **Revert** (restore last published) |
| Live search | `Search` | icon-in-field⁵ | Library search box (leading magnifier; the input's `aria-label`/placeholder names the field) |
| 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_
encodes which pane it toggles. Icon-only by design — position is the meaning — each
carrying an `aria-label`:
| Meaning | Glyph | Form | Surface |
| ------------------- | ------------------------------ | ------------ | ----------------- |
| Toggle library pane | panel frame, **left** filled | icon-only ⭐ | `PaneToggleStrip` |
| Toggle editor pane | panel frame, **centre** filled | icon-only ⭐ | `PaneToggleStrip` |
| Toggle preview pane | panel frame, **right** filled | icon-only ⭐ | `PaneToggleStrip` |
**Status set** — Carbon's **filled** notification glyphs, one per severity. Unlike
the outline UI set, these are coloured **by status** (not by surrounding text) and
are a deliberate _filled_ sub-family. They add a **redundant, non-colour severity
channel** (WCAG 1.4.1): meaning never rests on the bar colour alone, and the
triangle shape-codes warning apart from the round error/success/info — so severity
survives colour-blindness. Used wherever a status is signalled (toasts today; inline
notifications/validation as they arrive):
| Meaning | Carbon glyph | Colour | Surfaces |
| ------- | -------------------- | ----------------------- | ------------------------------------------- |
| Error | `ErrorFilled` | `--support-error` | `Toaster` (error) |
| Warning | `WarningAltFilled` ▲ | `--support-warning-fg`³ | `Toaster` (warning), Chart Builder warnings |
| 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**:
| Meaning | Carbon glyph | Form | Surface / note |
| ---------------- | ------------------ | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Swap / transpose | `ArrowsHorizontal` | icon + text | Chart Builder "Swap X/Y" — the button **ships** today with an interim Unicode ``, not a registry `Icon`. `ArrowsHorizontal` stays reserved here so the meaning is claimed; promote the button to it (add the `IconName` + glyph) when polishing the swap. |
⭐ = **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 `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
named by the glyph. ✕ means **close only**; delete is `TrashCan`, never ✕ (that
collision is exactly what one-glyph-one-meaning forbids). Admitting a glyph to ⭐ is
a contract change (§5.1 rule 4), not a per-component call.
¹ Row delete is hover/focus-revealed and reddens on hover/focus (arch 10 — reveal &
destructive-intent rules). ² "Duplicate" and "Delete" in the detail panel stay
**text** (label-first; lower frequency, not a dense row). ³ The raw warning yellow
fails contrast on light surfaces, so the warning glyph uses `--support-warning-fg`
(darkened amber; the yellow `--toast-accent` stays on the decorative border). ⁴
**Responsive collapse**, not membership in the icon-only set: these are icon+text
that _shed the label_ under width pressure (the editor toolbar's secondary actions,
and the library's standalone Create CTA, when the pane is narrow — [arch 10
§8](10-interaction-and-feedback.md)), keeping the accessible name in
`aria-label`/`title`. A degradation that preserves the name is distinct from a
permanent icon-only control (§5.1 rule 4), so it isn't a closed-set change. ⁵
**Icon-in-field**: a decorative leading glyph _inside_ a labelled control — the
search input's magnifier is `aria-hidden`, and the input itself carries the
accessible name. Not icon-only (the control is named by its label, not the glyph).
### 5.3 Size
Carbon's icon scale, paired to our type. The tokens are live in
`styles/tokens.css`; the `Icon` component's `size` prop selects one:
| Token | Size | Pairs with | Use |
| ----------- | ---- | ------------------------------ | ------------------------------------------- |
| `--icon-sm` | 16px | 14px body (`--font-size-base`) | Default — inline with text, row markers |
| `--icon-md` | 20px | 16px text | Slightly emphasised controls (theme toggle) |
| `--icon-lg` | 24px | — | When a larger icon is genuinely needed |
| `--icon-xl` | 32px | — | Rare; large display only |
- _"16px and 20px icons are optimized to feel balanced when paired with 14pt and
16pt IBM Plex"_ (Carbon) → **16px (`sm`) is our default**, since body is 14px.
- Use an icon **at its scale** — don't rescale a 16px glyph to 11px or 13px (the
old 11px dataset glyph and 18px toggle were the drift this fixed).
- Carbon's glyphs are drawn on a 32-unit grid with a built-in stroke weight per
size; because we render them **filled** (see §5.4) there is no stroke token to
set — sizing the SVG is all that's needed.
### 5.4 Style, colour & alignment
- **Geometry & fill:** Carbon's rounded 2px corners, per §3.4. Carbon's UI icons
are **filled** shapes (`fill: currentColor`) that read as outlines — _not_
`stroke`-drawn. Our `Icon` primitive draws fill; the size classes set width and
height only. (The two original hand-rolls used `stroke`; tracing the real Carbon
glyphs moved us to fill.)
- **Colour:** monochrome, one colour, **inherits `currentColor`** so it matches
its text — Carbon: _"match your icon colour with your text colour … don't use
different colours for text and icons"_; must pass contrast. Two sanctioned
recolours: **destructive intent** (delete reddens to `--support-error` on
hover/focus — arch 10) and the **status sub-family** (§5.2), coloured by severity
rather than by text — those are graphical status objects (WCAG 3:1), and warning
uses the darkened `--support-warning-fg` so it clears contrast on light surfaces.
- **Alignment:** centre-align with adjacent text — never baseline-align (Carbon).
- **Sourcing:** Carbon is **not a dependency**; we transcribe the glyph's SVG
geometry into `Icon.tsx`'s `GLYPHS` map (Carbon's third-party rule, inverted — a
new glyph must be _"visually balanced"_ with the set). Match an existing icon's
32-grid when adding one.
- **Hit area:** the interactive target (the button), not the glyph, owns the click
size — our 32/40px buttons already clear comfortable targets; never shrink the
target down to the icon.
### 5.5 Current state
The contract is implemented across the M1M4 surfaces:
- **Infrastructure:** `--icon-*` tokens in `styles/tokens.css`; a shared
[`Icon`](../../src/app/components/Icon.tsx) primitive + the `GLYPHS` registry as
the single source of truth. Components import `Icon`, never inline an SVG.
- **Core set live:** `Close` (ModalShell, Toaster — replacing the bare ✕/×),
`Asleep`/`Light` (ThemeToggle, now on-scale), `DataTable` (library row + linked
list, replacing the 11px cylinder), `TrashCan` (row delete), `Add` (both
"create-new" buttons). The draft dot is unchanged.
- **Status set live:** the four filled glyphs (`ErrorFilled` / `WarningAltFilled` /
`CheckmarkFilled` / `InformationFilled`) in `Toaster`, coloured by kind; the
Chart-Builder warnings reuse `WarningAltFilled` (replacing the old ⚠ character).
- **Deferred (scoped set):** only the Chart-Builder `ArrowsHorizontal` (swap-axes) —
registered, not built; a one-button polish revisited with the next Chart-Builder
pass.
No open status thread remains — the status-glyph question is settled here.
---
## 6. Charts (`src/core/vega-themes.ts`)
The chart `Config` is themed to match the app, per theme:
- `background: transparent` (inherits the surface), Plex font for titles/labels,
axis/grid colors derived from the neutral ramp + `--text-secondary`.
- **Categorical palette** for `range.category` is part of the _free color_ layer —
a distinct, colorblind-sequenced set (Carbon's data-viz palette is a good
starting point, but not mandatory). Light and dark variants. This is where
expressive color earns its keep.
- Config is applied **at embed time**, never baked into the user's stored spec.
---
## 7. Implementation map
| Artifact | Role |
| -------------------------------------------------- | -------------------------------------------------------------------- |
| [`visual-specimen.html`](./visual-specimen.html) | Living preview + token sandbox. Iterate here first |
| `styles/tokens.css` | The settled tokens — ported from the specimen in M1.5 |
| `styles/base.css` | Font wiring (`@fontsource`), reset, reduced-motion |
| `src/app/components/Button.tsx` / `IconButton.tsx` | The shared control primitives (§4) — every action / icon-only button |
| component `*.module.css` | Consume tokens only; no raw hexes, no hardcoded hue |
| `src/core/vega-themes.ts` | Chart `Config` per theme; categorical palettes |
**Order of work:** settle the specimen → port tokens to `tokens.css` → self-host
Plex in `base.css` → restyle existing M1 components against the tokens → align
`vega-themes.ts`. Verify by rendering the real app, not just the specimen.
**What the specimen is (and isn't).** It is the **token sandbox** (try accents,
ramps, themes before touching `tokens.css`) and a **catalog of reusable
primitives** in both themes — buttons, fields, tabs/status/tags, the library-row
pattern, toasts, the overlay dialog, the code surface. It is **kept in sync** with
those primitives: when a primitive's canonical look changes or a new one lands
(e.g. the confirm dialog), add/update its specimen entry. It does **not** mirror
**feature surfaces** — the Datasets / Settings / Chart Builder modals, the editor,
the full shell — those are app screens, verified in the running app (headless
Chrome, both themes), not catalogued here. That primitive-vs-feature line is what
keeps the specimen finite, honest, and worth trusting.
---
## 8. Inspiration sources — where to look for more
We treat IBM/Carbon as inspiration, so we mine its **source repos**, not the live
doc sites. The sites (`carbondesignsystem.com`, `ibm.com/design/language`) are
JS-rendered and don't fetch cleanly — **clone the repo and read it locally instead.**
Convention: clone under `/Users/oleh/code/reference/` with
`git clone --depth 1 https://github.com/carbon-design-system/<repo>.git`.
| Need | Repo | Where it lives |
| -------------------------------------------------------------------------------------------------------------- | ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Principles / the "why"** (philosophy, 2x grid, color rationale, type, motion, icon geometry) | `design-language-website` | `src/pages/`: `philosophy/principles.mdx`, `2x-grid.mdx`, `color.mdx`, `typography/*.mdx`, `animation/overview.mdx`, `iconography/ui-icons/{design,usage}.mdx` (~1.4 GB clone — image-heavy; the MDX is what we want). Icon **usage** rules (§5) also draw on `carbon-website/src/pages/elements/icons/usage.mdx` + GOV.UK `styles/images/index.md` |
| **Token values** (gray/blue ramps, type scale, font families, motion durations/easings, theme role→value maps) | `carbon` | `packages/colors/src/colors.ts`, `packages/type/src/{scale,fontFamily,fontWeight}.ts`, `packages/motion/src/index.ts`, `packages/themes/src/{white,g100}.ts` |
| **Component-level usage guidance** | `carbon-website` | `src/pages/**/*.mdx` |
| **Data-viz categorical chart palette** (for `vega-themes.ts` `range.category`) | `carbon-charts` | cloned in M1.5 → `packages/core/scss/_color-palette.scss` (the `'14'` pairing, white + g100); token→hex resolved against `carbon` `packages/colors/src/colors.ts` |
> The decisions we made _from_ these sources are captured above (§17) and in the
> specimen, so we don't need to re-derive them — only return to the repos to extend
> the research (e.g. the chart palette, or a component pattern we haven't tackled).
@@ -1,796 +0,0 @@
# 10 · Interaction & Feedback
> **Status:** interaction contract. Where [09 · Visual Design](09-visual-design.md) is the
> _visual_ contract (how the app **looks**), this is the _interaction_ contract (how the
> app **behaves while the user works in it**). It is the HOW for the cross-cutting,
> non-feature-specific behavior that spec [§10 Non-Functional](../spec/10-non-functional.md)
> mandates as the WHAT.
These rules already live, scattered, as one-off comments across the codebase (the
confirm-dialog's "transactional-modal rule," the toaster's "non-blocking outcomes are
toasts," the preview's "empty is not an error"). This document consolidates them into one
referenceable contract so a new feature **checks the rule instead of re-deriving it**
and diverges only on purpose.
**Upstream sources.** These principles are drawn from the design council (`/council`):
IBM Carbon, the **GOV.UK Design System**, the **WAI-ARIA Authoring Practices Guide**, and
**Nielsen Norman Group**. The council advises; this contract decides. When you face a new
interaction decision this doc doesn't cover, consult the council, then record the
resolution back here.
**Cite the spec for behavior; own the _how_.** Each `Resolved —` bullet below is the contract
for an interaction's _mechanics_ — the ARIA role, the keyboard model, the focus move — and
records _why_ (the council citation). It is **not** the source for **product behavior**: what
a surface shows, when it appears, what the data does. That lives in `docs/spec/`; a bullet
**names** the behavior in a clause, cites `(spec §NN)`, and never restates or overrides it. On
any disagreement, the spec wins and the bullet is the bug (see `00-overview` → "cite, don't
restate").
---
## 1. The feedback-channel decision table
Astrolabe has four distinct ways to tell the user something. They are **not**
interchangeable; picking the wrong one is the most common interaction bug. Choose by the
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 → 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.**
- **One blocking question at a time.** Confirm dialogs and feature modals are mutually
exclusive (the modal coordinator enforces this); a confirm may layer _over_ a modal
(discard-changes prompt), nothing else stacks.
- **Match disruptiveness to urgency** (Carbon). A toast interrupts less than a dialog;
don't use a dialog for something a toast can carry, and don't bury a
consent-for-destruction in a toast.
- **Errors persist; success fades.** An error/warning toast waits for the user (a critical
message must not vanish on a timer); success/info auto-dismiss (~6s).
- **Toasts sit bottom-right**, not top-right. Carbon's default is the top, but our header's
action cluster (Publish/Revert, theme/datasets) lives top-right — a toast there covers the
control the user just used. Bottom-anchored, the stack grows upward with the newest toast
nearest the corner, and still clears the centered confirm dialog. (`Toaster.module.css`.)
- **Toast copy: title states it, message adds to it.** Every toast renders a `title` and a
`message`. The title is the short headline — the action or what stopped, **no terminal
period** ("Snippet published", "Storage full"). The message is **one short sentence that
must not paraphrase the title** (Carbon, `components/notification/usage.mdx` §Body
content: _"Don't repeat or paraphrase the title"_); it carries the **consequence** for a
success ("Your draft is now the published version") or the **next step** for a fixable
error. Name the specific item in the message when toasts can stack — a delete confirms
_which_ one went ("…removed `\"Sales\"`…").
- **Toast only what the user can't already see.** A success toast is for an outcome with no
strong on-screen cue: a **side effect** (Extract creates a dataset off-screen while the
user is in the editor), a **disappearance** (delete), or a **state flip** (publish/revert).
An action whose result is immediately visible — a created snippet opening in the editor, a
new dataset shown selected — is confirmed by that visible change; adding a toast is noise
(NN/g aesthetic-and-minimalist; Carbon `notification/usage.mdx` "Deciding what to use").
An **invisible** outcome that still shouldn't toast is **copy-to-clipboard**: confirm it
_inline on the control_ ("Copied") with a polite `aria-live` announcement for assistive
tech, never a toast-per-copy. This refines the spec's earlier blanket "every action
toasts" (spec §01F/§02/§05, reconciled).
- **An action's confirmation belongs with the action, not its call site.** When an action is
reachable from more than one trigger (e.g. publish is both a toolbar button _and_
Cmd/Ctrl+S), pair the store mutation and its toast in **one `services/` helper**
(`publishActiveSnippet`) that every trigger calls — otherwise the toast rides one path and
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.
- **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
From NN/g's response-time limits (`reference/principles/nielsen-norman.md`). These are not
aspirations — they're the basis of the render pipeline's shape.
| Budget | Feels like | Owed feedback | Astrolabe surfaces |
| ---------- | --------------------- | ---------------------------------------------------------------------- | ------------------------------------------------------------------------ |
| **≤ 0.1s** | Instantaneous | None beyond showing the result | Keystrokes into the buffer, hovers, toggles, selection |
| **≤ 1s** | Uninterrupted thought | None needed, but direct-manipulation feel is lost | A typical render after the debounce; opening a modal; switching snippets |
| **> 1s** | Noticeably waiting | **Must not block input**; show a busy indication | A heavy spec / large-dataset render |
| **> 10s** | Attention lost | **Progress indicator + stay cancellable**; let the user work elsewhere | (guard for large M3+ dataset work) |
**Rules.**
- **Input is sacred.** Typing and navigation never block on rendering or persistence (spec
§10). The render pipeline is debounced (`RENDER_DEBOUNCE_MS`), runs async, and uses a
**generation token** so a slow render can't overwrite a newer one.
- **Auto-save is cheap and silent** (`AUTOSAVE_DEBOUNCE_MS`) — it never stalls typing and
produces no toast on success (only on failure).
- **A busy indication may overlay the preview but must not freeze the editor** (spec §10).
When a render _might_ exceed ~1s, owe a non-blocking indicator; never a frozen UI.
## 3. The non-happy-path triad
Every surface that shows data owes **three** designed states, not one. The empty and error
states are part of the feature, not an afterthought (Carbon empty-states, GOV.UK).
- **Loading** — when data isn't ready yet. Prefer a skeleton/placeholder over a spinner for
structural loads; show it only for a beat.
- **Empty** — when there is legitimately nothing. **Empty ≠ error**: a blank editor renders
a clean, calm empty preview, never an error (`LivePreview` treats blank as success). An
empty list says what would be here and how to add it.
- **Error** — when something went wrong. Split by **who can fix it**:
- **User-fixable** → state the consequence and the **next step** ("Storage full — delete
snippets to free space, then edit again"). A user action is mandatory (Carbon/GOV.UK).
- **Not user-fixable** → explain plainly **and** attach a reportable **diagnostic** (the
operation + underlying error) so it can be traced. See `services/storage-errors.ts`
this split is the module's whole reason for being.
- **Wording** (NN/g #9, GOV.UK, Carbon content): plain language, **no error codes in the
user-facing line**, second person, name what stopped in the title, one or two sentences
in the body, never flippant.
- **Status carries a glyph, not only colour** (arch 09 §5.2 status set; Carbon notification
taxonomy). Error/warning/success/info surfaces (toasts, inline warnings) lead with the
**filled status icon**, coloured by severity — a redundant non-colour channel so severity
reads under colour-blindness (WCAG 1.4.1), with the triangle shape-coding warning apart
from the round error/success/info. Colour + icon + title together; never colour alone.
## 4. Recovery & data-safety contract
The user must never silently lose work, and must always have a way back (NN/g #3 "user
control and freedom," #5 "error prevention"; spec §10 Reliability).
- **No silent data loss.** Edits auto-save as a **draft**; a known-good **published**
version is always preserved separately (§03D). A failed persist **surfaces as a toast**,
never a swallowed promise (`orchestration/snippet-persistence.ts`).
- **A marked exit from every committed change.** Revert restores the published spec;
Escape closes modals; delete/revert/reset require confirmation first.
- **Resilient rendering recovers on its own.** An invalid spec shows a readable error and
**auto-recovers when fixed** — it never leaves the app wedged (§04).
- **Non-destructive import.** Import merges, never overwrites; on failure the existing
workspace is left exactly as it was (§08).
## 5. Keyboard & focus contract
Astrolabe is keyboard-operable end to end (spec §10 Accessibility). The interaction
patterns follow **WAI-ARIA APG** (`reference/aria-practices/content/patterns/`); the wiring
lives in [04 · Routing & Global Events](04-routing-and-events.md).
- **One global router** owns document-level `keydown`/`paste`; components never attach
their own `window` listeners.
- **Escape is an ordered priority chain** that returns on first consumption (blocking
message → modal → menu → selection) and is checked **before** the interactive-context
gate, so it dismisses a modal even while the editor has focus. All _other_ shortcuts are
gated by `isInInteractiveContext()` so they never fire mid-typing.
- **Shortcuts claim their key** with `preventDefault()` so they override the browser
default (⌘S doesn't "save page," ⌘K doesn't focus the URL bar).
- **Focus moves into an overlay on open and returns to the trigger on close**; focus is
**trapped** within it (`useFocusTrap`). Destructive confirms focus Cancel first.
- **Match the APG pattern** for any new interactive widget — a modal is `dialog-modal`, a
destructive confirm is `alertdialog`, a resize handle is `windowsplitter`, a toast region
uses `alert`/`status` roles by severity. Don't invent keyboard models; adopt the
documented one.
**Resolved — a control that removes its own container.** When activating a control deletes
the element it lives in (e.g. a Chart Builder guidance hint's one-click **fix** button —
the hint re-derives away once applied), focus must not fall to `<body>`. The rule (council:
Carbon _Actionable notification_ + APG _Alert_): **announce the change politely and move
focus to a stable neighbour.** Concretely, the builder writes "Applied: `<label>`." to a
visually-hidden `role="status" aria-live="polite"` node and moves focus to the guidance
region if any hints remain, else the surrounding pane (`tabIndex={-1}` anchors, focused only
programmatically — no visible ring). Advisory hints themselves don't _grab_ focus (APG: an
alert "must not affect keyboard focus"); the fix's remedy lives in a **low-emphasis ghost
button** beside the advice (Carbon: inline actionable → ghost button, wraps under the body
on narrow widths), an _offer_, never a forced change.
**Resolved — pane resize handle (window splitter).** A `ResizeHandle` is a focusable
`role="separator"` that **reports the controlled pane's size**, per APG → Window Splitter:
- `aria-valuenow` on a **0100 scale** (0 = pane at its minimum, 100 = at its maximum),
with `aria-valuemin=0`, `aria-valuemax=100`, and `aria-valuetext="<n>%"` for a clean
announcement. The 0100 normalization (APG's "typical") beats raw pixels: it's stable and
announces as a percentage. The math is the pure `sideWidthValue()` in `PanesStore` (so it
is unit-tested, not trapped in the component); the live value needs the container width,
observed via `ResizeObserver` so it tracks window resizes, not just drags.
- `aria-controls` points at the pane it sizes (`pane-library` / `pane-preview`).
- **Keyboard**: ←/→ nudge; **Home** → smallest pane size, **End** → largest. **Enter-to-collapse**
now has a home — the hidden-pane state landed in M6 with the toggle strip (below). The strip
owns show/hide; wiring the splitter's Enter to it is an optional convenience, not required.
**Editor-hidden layout.** Pane model: the two **side** panes carry stored widths; the **editor
is the flex filler** (no stored width while shown). Show/hide is layout-aware only for the
editor — hiding **captures** the width it had (`capturedEditorWidth`), showing **pins it back**
and re-splits the rest at the side panes' ratio (`shownSideWidths`); side panes just flip
visibility and flex-fill the freed space (spec §01A). So `togglePane` needs `panesInner`
(panes-row width minus the toggle strip, measured by `PaneToggleStrip`) for the editor only.
With the editor hidden, library and preview become adjacent and share a **second splitter**,
`PaneSplitHandle` — separate from `ResizeHandle` because the geometry differs: `ResizeHandle`
resizes one side pane while the editor absorbs the change (the opposite side is untouched);
`PaneSplitHandle` re-proportions **two** panes with no filler between them (editor-hidden, the
side panes render `flex: width 1 0` and share the span), the pure `splitLibraryWidth()` holding
each above its min. **valuenow trade-off:** it reports the library's 0100 position
(`splitValue()`) from the **stored ratio**, deliberately container-independent — so unlike
`ResizeHandle` it does **not** observe the container, and `aria-valuenow` can drift from the
rendered position after a window enlargement (worst near the extremes). Drag/keyboard read live
`clientWidth`, so resizing itself stays accurate; only the announced value drifts.
**Horizontal variant — the data-inspector divider.** The chart ↔ data-inspector divider in
the Live Preview (`InspectorSplitHandle`, spec §04) is the same window-splitter contract with
the axis flipped: `aria-orientation="horizontal"`, ↑/↓ + Home/End, sizing a stacked region's
**height** instead of a pane's width. It reads its flanking siblings (chart above, inspector
below) like `PaneSplitHandle`, with the gesture in `useRowResizeDrag` (the row twin of
`useColResizeDrag`) and the clamp/value math pure in `AppStore` (`clampInspectorHeight` /
`inspectorHeightValue`). Rendered only while the inspector is open.
_(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
gives the cluster a **single tab stop** with a **roving tabindex**, which APG names as the way
to reduce tab stops for a control group. Vertical keyboard model: **Up/Down** move among
controls, **Home/End** jump to first/last, **Tab/Shift+Tab** move into/out and restore the
last-focused control on re-entry.
- The three pane controls are **toggle buttons**`aria-pressed` with a **stable** accessible
name that does **not** change with state (`aria-pressed="true"` ⇔ pane visible; the name stays
"Library pane" / "Editor pane" / "Preview pane"; only the icon may swap). This matches the
`ThemeToggle` precedent and APG's toggle-button rule — _"it is critical the label on a toggle
does not change when its state changes."_ These are **independent booleans**, so toggle
buttons — never a radio/segmented group; reserve `role="switch"` for genuine single-setting
on/off.
- The **Datasets** control is a plain **command button** (no `aria-pressed`) in the _same_
toolbar — APG permits mixed control types — set off from the toggles by a visual divider (and
optionally a nested `role="group"`), but kept in the roving sequence as its last element.
- The **pressed (pane-visible) state has its own visual cue**: an inset accent edge bar plus the
full-strength glyph on the filled chip (the activity-bar convention). A fill alone cannot be
the state cue — hover uses the same one-step fill, so a fill-only pressed state is
indistinguishable from hovering an off toggle (the collision class arch 09 names for hover);
and a bar, not a border, keeps the strip boxless per the arch 09 box-discipline rule. The
unlabeled glyph rail itself is the established activity-bar convention (NN/g #4) — toggles
carry tooltips per Carbon's icon-button rule; no visible labels.
- **Focus**: show/hide is only ever initiated **from the strip**, so the activating toggle
already holds focus when its pane disappears and **retains it** (the button stays, flips to
not-pressed) — no orphaned focus, no restoration logic. The strip is **never itself hidden**,
so even with **all panes hidden** it stays the always-reachable "emergency exit" (NN/g #3 user
control). The pane appearing/disappearing plus the `aria-pressed` flip is the status feedback
(NN/g #1 visibility of system status).
_(Consulted via `/council` → WAI-ARIA APG `toolbar` + `button` (toggle); NN/g #1/#3. This
bullet is the contract; cite it, not the APG files.)_
**Resolved — segmented (single-select) controls.** A "pick one of N" control (fit modes,
the Draft/Published view) is a **radio group**, never a row of `aria-pressed` toggles
(those model N independent booleans). Use the shared `SegmentedControl`: `role="radiogroup"`
- `role="radio"`/`aria-checked`, a **roving tabindex** (only the selected option is a tab
stop), and Arrow/Home/End to move-and-select (APG → Radio Group). One widget so the keyboard
model is defined once. _(Not a toggle switch: APG defines `role="switch"` as on/off of a **single** setting, but
Draft/Published selects between two **named peer views** with no natural "on" side — a radio
group is the right semantics. Reserve the switch for genuine on/off settings.)_ A per-option
`title` (tooltip for a terse label) doubles as the option's accessible name, so it must
**lead with the visible label** ("Original — the natural size from the spec"), or
speech-input users can't address the control they see (WCAG 2.5.3 label-in-name).
**Resolved — selectable lists.** A row the user selects must be a real `<button>` (or a
proper option), not a click handler on `<li>` (mouse-only, no keyboard, no role). It is
**not** an APG `listbox` when a row contains its own controls (e.g. a delete button) — APG
forbids interactive children in a listbox. Mark the active row with `aria-current="true"`
**only on that row** (don't emit `aria-current="false"` everywhere). Arrow-key roving
_between_ rows is a later enhancement; button-per-row tab stops are the acceptable baseline.
**Resolved — snippet-list status indicator.** Spec §02 owns the behavior — the row
distinguishes a snippet with **unpublished draft changes** from a **fully-published** one; this
bullet owns the _how_. The row flags only the _exceptional_ state: a single **accent dot** for
the unpublished case; a fully-published snippet shows **no dot** (presence = draft, absence =
published). We do **not** give "published"
its own glyph — GOV.UK's Tag guidance notes one status suffices when absence is self-evident,
and Carbon's status-indicator pattern says not to highlight what isn't significant. Meaning
never rests on **hue** (WCAG 1.4.1): it rides on presence/absence **plus** the dot's accessible
label, and the colour is the neutral **accent** — not a warning hue, because unpublished work is
a normal state, not a problem. The dot sits in the row's secondary metadata line (with the
relative date and size), in a fixed-width leading slot so it never shifts adjacent text.
_(Consulted via /council → GOV.UK Tag, Carbon status-indicator-pattern, APG. This bullet is the
contract; cite it, not the external source.)_
**Resolved — storage composition indicator.** The library-footer Storage Monitor (spec §02)
shows what storage is **made of** — Snippets · Datasets · App — as a proportional bar plus a
labelled legend, **not** a "used of quota" gauge. The browser quota is a padded, unreliable
approximation (web.dev → _storage-for-the-web_), so a budget fraction is false precision; we
show real measured sizes instead.
- **Not a meter.** No `role="meter"`/`progressbar`: a meter needs a meaningful maximum, and a
composition with no trustworthy ceiling has none (APG → `meter`: _"should not be used to
represent a value … [without] a meaningful maximum"_). The visual bar is **decorative**
(`aria-hidden`); the **legend's text labels + sizes are the accessible source of truth**, so
meaning never rests on hue (WCAG 1.4.1).
- **Part-to-whole in a tiny space.** A single proportional stacked bar suits a **few** segments
(we have three) — FT Visual Vocabulary (Part-to-whole) + Datawrapper (stacked bar for "a few
shares"; bar "when precise reading matters") — paired with absolute byte labels for the precise read.
- **Unavailable degrades, not disappears.** Snippets + datasets are measured from our own data, so
they always show; only the **App** segment (which needs the origin estimate) drops out when the
Storage Manager API is absent.
- **No proactive "almost full" warning.** A percentage gauge would key off the untrustworthy
quota, and a fake fuel gauge fails NN/g #1 (_visibility of system status_) more than it
serves it. The genuine out-of-room event surfaces at **save time** as an
actionable error (`services/storage-errors.ts` → recover by deleting), satisfying NN/g #9.
_(Consulted via /council → WAI-ARIA APG `meter`, FT Visual Vocabulary + Datawrapper (part-to-whole),
web.dev storage, NN/g #1/#9, WCAG 1.4.1. This bullet is the contract; cite it, not the sources.)_
**Resolved — library search (Carbon active-search).** The snippet-library search (spec
§02) is an **unlabelled active-search input** pinned above the list: `type="search"` with a
leading magnifier and `aria-label="Search snippets"` (no visible label — the icon +
placeholder name it), filtering the list on **each keystroke** (no Search button, no results
page). A **clear (✕)** appears only when the box is non-empty; it empties the box **and
returns focus to the input** (NN/g #3 user control). Matching is case-insensitive substring
across **name + comment + draft spec text** (the pure `snippetMatchesQuery` in
`core/snippet-sort.ts`). Search affects **visibility only** — it never changes
`activeSnippetId` or any data. A **polite `aria-live` region announces the result count,
including no results** (Carbon: _"always include the number of results, including no
results"_); it stays silent for the default unfiltered list. _(Consulted via /council →
Carbon search/active-search, NN/g #3. This bullet is the contract; cite it, not the source.)_
**Resolved — library sort (APG menu-button + NN/g recognition).** The Sort control (spec
§02) reuses the **disclosure popover** primitive (the settings-popover model above), **not**
an ARIA menu — but its trigger shows the **current state for recognition** ("Modified
↓"), per NN/g #6 (recognition over recall), instead of a bare gear. (The visible text drops
the "Sort:" verb prefix to stay compact in the narrow library rail — §8; the full name
"Sort by Modified, descending" rides in `aria-label`.) Fields are **Modified /
Created / Name / Size**; the active field shows a **direction arrow** (↓ desc / ↑ asc) in both
the trigger and the field row, and the arrow's meaning is mirrored into the field's
`aria-label` ("Modified, descending") so it isn't carried by the glyph alone. **Selection
model** (spec §02): re-selecting the **active** field flips direction; selecting a
**different** field switches to it and **resets to descending** — encapsulated in
`SnippetStore.setSort` and tested in core. APG disclosure keyboard/focus is inherited from
the shared popover (Enter/Space open; **Esc closes and returns focus to the trigger**; outside
click closes; one open at a time). Field+direction **persist across sessions** (ux-prefs;
default Modified/desc); search is **not** persisted (it's a transient view narrowing).
_(Consulted via /council → WAI-ARIA APG disclosure + menu-button, NN/g #6. This bullet is the
contract; cite it, not the source.)_
**Resolved — the empty-library onboarding canvas (and the list's single empty state).** The
snippet list owes **one empty state****no search matches** ("No snippets match your search",
with a hint to try a different term). There is **no separate "empty library" list state**,
because a genuinely empty library never shows the list at all: rather than seeding a placeholder
snippet (the old behavior), the workspace replaces the **entire pane chrome — toggle strip,
library list, editor, and preview — with a full-width onboarding canvas** (spec §02 →
First-Run & Empty Workspace): with no snippets, the library controls and pane toggles have
nothing to act on. (So the list's own empty copy is reached only mid-search, never on a cold
start.) The canvas is a welcome, a primary "Create your first snippet", and a gallery of
example snippets; leaving it (creating the first snippet) lays the panes out at a default
**25·25·50** split via `PanesStore.applyOnboardingSplit` so the first chart opens with a
generous preview. Each card **renders live through the shared `chart-renderer`** (no parallel
embed path; each card owns its `RenderHandle` and finalizes on unmount — the per-card nodes are
independent, so they don't touch `LivePreview`'s single-host serialization), and adds as an
ordinary snippet. Empty stays calm and positive, never an error (NN/g aesthetic-and-minimalist;
§3 empty ≠ error). Three rules govern the canvas. (1) **Each card preview is decorative**
`aria-hidden`, skipped by screen readers (Carbon empty-states a11y / WCAG decorative images);
the card **name + one-line description + a uniquely-labelled `Add` button** ("Add Bar chart",
APG button) carry the meaning, so AT users reach no dead end. (2) The canvas is the **single
empty surface** — because it replaces the library list outright, there is no competing "No
snippets yet" status elsewhere to keep in sync. Its heading **owns the app identity** ("Welcome
to Astrolabe") and it carries the only Create nudge, so the empty-state message lives in exactly
one place (Carbon "keep words to a minimum"; no duplication). (3) The **primary action
dominates** (accent "Create your first snippet" first; the example gallery is framed as a
secondary "Or start from an example") — Carbon sanctions starter content as an in-depth
first-use empty state only when one action stays primary. (4) **Domain vocabulary stands**:
"Vega-Lite", "JSON", "snippet" are kept despite Carbon's avoid-jargon rule, because SOUL #2
(Vega-Lite Native) makes them the user's real language — a deliberate divergence. _(Consulted
via /council → Carbon empty-state + content, GOV.UK headings, WAI-ARIA APG button, NN/g. This
bullet is the contract; cite it, not the source.)_
**Resolved — the creation surface is builder-forward, Monaco-intact (3D).** The guided
path must be visible where the intent to make a chart forms: the library's creation surface
is a **primary "Build Chart"** (opens the Chart Builder) beside a **ghost "New JSON
snippet"** (the old instant create, unchanged) — two plain buttons with clear hierarchy, no
split/menu-button widget (two static choices don't earn an ARIA menu). The expert path
stays one visible click away, never hidden (NN/g #6 recognition over recall, #7 flexibility/
efficiency; Carbon: one primary per surface, a tertiary for the secondary CTA). The builder
itself opens **without a preselected dataset** (it picks the most recently modified; a
header **Dataset picker** switches without leaving), and with an empty dataset library it
shows a **no-datasets state** per the Carbon no-data pattern: what the space does + one
primary next step ("Add a dataset" → the Datasets create form) — never a dead end. The
dataset-row "Build Chart" stays as the contextual shortcut; the onboarding canvas gains the
data-first door ("Build a chart from your data") beside its primary. _(Consulted via
/council → NN/g #6/#7, Carbon empty-states; recorded in
docs/exploration/chart-builder-enhancement-scope.md §3 · 3D. This bullet is the contract; cite it, not
the source.)_
**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
calculated field, a filter in expression mode) is **not** an assertive `alert`: that would
interrupt on each character (APG _Alert_ "avoid frequent interruptions"; WCAG 2.2.4). It is a
**polite `role="status"`** line, and severity reads from a **status glyph** (round error /
triangle warning, arch 09 §5.2) **plus** colour — never colour alone (§3; WCAG 1.4.1), exactly
like the guidance warnings above it. The owning `<input>` carries `aria-invalid` for the state
and `aria-describedby` pointing at the message node so the text is available on focus, not only
when it changes (GOV.UK _error-message_ field association). Contrast the editor's **render**
error, which is a discrete, post-debounce result and stays the single `alert` of the rule
above. _(Consulted via /council → WAI-ARIA APG Alert, GOV.UK error-message, NN/g #9. This
bullet is the contract; cite it, not the source.)_
**Resolved — feature-modal dismissal & initial focus.** A feature modal (Datasets, Chart
Builder) is a **passive** `dialog-modal`: dismissed by the close
button, Escape, or a backdrop click (a passive modal carries no in-flight transaction, so
an outside click is a safe cancel — unlike the `alertdialog` confirm, where backdrop-dismiss
is forbidden). `role="dialog"` + `aria-modal` + `aria-labelledby` the title; focus is trapped
and **returns to the trigger** on close. Backdrop-dismiss stays correct even for the
multi-view Datasets manager because an in-progress create/edit form is guarded separately by
the discard prompt. **Initial focus depends on size** (APG dialog-modal): a large manager
with semantic content (list + detail) focuses a **static title** (`tabindex="-1"`) so the
content's start is perceived rather than skipped to the first control; a small form modal
(Extract) focuses its **primary field**. _(Consulted via /council → WAI-ARIA APG
`dialog-modal`. This bullet is the contract; cite it, not the APG file.)_
**Resolved — settings are distributed, not a modal; each cluster is a disclosure popover.**
Preferences (spec §07) live next to what they affect and apply **live**: theme is the header
toggle, editor settings open from the editor toolbar, render debounce from the preview, date
format from the library. This matches the already-distributed theme + fit-mode controls,
makes a change's effect visible in the pane being configured, and keeps each block
independently extensible — so there is **no central Settings modal and no Apply/Cancel/dirty
commit step** (changes are individually reversible; the editor cluster offers a Reset). The
disclosure mechanism is a **gear button + non-modal popover**, _not_ an ARIA menu: a menu
lists actions/commands (`menuitem`/`menuitemcheckbox`/`menuitemradio`), but these panels hold
sliders, number/text inputs, and radio groups, so the container is a labelled `group`. The
gear carries `aria-expanded` + `aria-controls`; Enter/Space toggle; **Esc closes and returns
focus to the gear**; an outside click closes; at most one is open at a time; focus moves to
the first control on open (so `Cmd/Ctrl+,`, which opens the editor cluster, lands inside it).
Non-modal — **no focus trap** (unlike the feature modal above). The panel is portaled to
`<body>` and positioned `fixed` because the panes clip their content. The same primitive and
single-open registry serve any pane-header disclosure, not only settings: the per-chart
**Export** control (preview header — _Import & Export → Per-chart export_) is a disclosure
whose `group` holds a few **action buttons** (Copy / Download) plus option controls. A small
set of action buttons in a disclosure stays a `group` — an ARIA menu is reserved for true
`menuitem`/`menuitemcheckbox`/`menuitemradio` command lists, which this app does not use.
_(Consulted via /council → NN/g #4 consistency, #6 recognition-over-recall, #8 minimalist;
WAI-ARIA APG disclosure + menu-and-menubar; Carbon popover/overflow-menu/text-toolbar. This
bullet is the contract.)_
**Resolved — value pickers are the SelectControl disclosure, not native `<select>`.** A
native select's popup can't be token-styled and renders differently on every browser/OS — a
foreign object inside a designed surface — so anywhere a control is part of one,
`SelectControl` replaces it: the same disclosure primitive as the settings popovers (trigger
with `aria-expanded`/`aria-controls`; portaled, `fixed`-positioned panel; labelled `group` of
option buttons — **not** an ARIA menu or combobox; single-open registry; Esc closes and
refocuses the trigger; outside press closes; open lands focus on the selected option;
Arrow/Home/End rove). The selected option carries `aria-current` and a visible ✓, never
colour alone. The same control doubles as an **action picker** (no `value`; e.g. "Add field
to which channel?"). A custom `triggerClassName` _replaces_ the default trigger styling, so
chip-styled triggers (the pill's type chip, the shelf's field chips) stay chips; the default
trigger sits on the **32px compact control scale** (arch 09 §6), like the sort trigger and
search input it shares surfaces with. A long option list can carry **group separators**: an
option's `dividerBefore` draws a `role="presentation"` rule above it — purely visual, never
in the keyboard order, never a heading. **The boundary is set where the list is built**: the
module that decides the option order marks the divider-carrying option (e.g.
`chartThemeOptions` stamps the first preset); a consumer must never recompute a group
boundary by index arithmetic, which silently misplaces when the producer's ordering changes.
A value list may carry an **action row** (the VS Code theme-picker pattern — e.g. "Edit
themes…" inside the chart-theme picker): permissible because options are real buttons, not
listbox options (APG's no-interactive-children listbox constraint doesn't apply); the row's
label ends in "…" (the opens-further-UI convention) and sets the option's `hasPopup` so AT
hears `aria-haspopup` — no special visual styling beyond an adjacent group divider. The
default trigger caps its value label at **16ch with ellipsis**, so a long value (a preset or
user-named theme) can't blow out a crowded pane header; the full label remains in the open
list and the trigger's accessible name.
The single-open registry means **disclosures cannot nest**: a SelectControl inside a
settings popover would close — and unmount — its own parent on open. A control that needs
its own popover sits beside the gear in the pane header, never inside the panel.
_(Consulted via /council → WAI-ARIA APG disclosure/menu-button/radio, Carbon, NN/g #4. This
bullet is the contract; cite it, not the source.)_
**Resolved — editor commands need a visible home; hidden surfaces are accelerators only.**
A command that exists _only_ in Monaco's right-click context menu or F1 palette is
undiscoverable (NN/g #6 recognition-over-recall — those surfaces demand the user already
know the command exists). Every editor command gets a **visible toolbar home**; when the
toolbar can't afford a dedicated button (Carbon menu-buttons: "use an overflow menu when
additional options are available and there is a space constraint"), the home is a
SelectControl **action picker** grouping related commands (e.g. the spec editor's _Config_
menu: merge chart theme / extract config), with `detail` lines saying what each does.
Context-menu and palette registrations stay, as the NN/g #7 expert accelerators, but they
call the same functions as the visible control — one code path, two doors.
_(Consulted via /council → NN/g #6/#7, Carbon menu-buttons/overflow-menu. This bullet is
the contract; cite it, not the source.)_
**Resolved — content-gated toolbar actions hide; state-gated actions disable.** Two ways a
toolbar action can be inapplicable, with opposite affordances. **State-gated** — applicable
to this object in principle, just inert right now (Revert with no draft changes, Config in
the read-only Published view) → **disabled**: the user can act (edit / switch view) and it
lights up. **Content-gated** — inapplicable to _this spec's shape_, and nothing the user can
do in the moment changes that (_Extract to Dataset_ needs inline data; _Open in builder_
needs a builder-representable spec referencing an existing dataset) → **hidden**. A
permanently-disabled control the user cannot enable reads as broken or teasing, not as
guidance (NN/g #6 — a disabled state must imply "do X and this becomes available"; Carbon
button states). So _Open in builder_ (spec §06) sits in the editor toolbar beside _Extract
to Dataset_ and follows its visibility — present only when the active snippet round-trips
through the builder and its dataset exists. This refines "disabled is for temporarily
unavailable actions" (below) from the unbuilt-feature case to the per-spec case.
_(Consulted via /council → NN/g #4/#6, Carbon button usage/states. This bullet is the
contract; cite it, not the source.)_
**Resolved — field→channel assignment: explicit choice, visible armed state.** Clicking a
shelf field with no channel armed opens an explicit **channel chooser** (the channels that
accept the field; an occupied one is labelled with what it replaces) — never a silent
first-empty-seat grab (NN/g #3, user control). Arming a channel slot short-circuits the
chooser (the fast path) and **must be visible where the next click happens**: the shelf
gains an accent ring plus a polite `role="status"` line naming the target ("Assigning to X —
choose a field below. Esc cancels"); **Esc disarms** without closing the modal (captured
before the dialog's own Escape handling). Chart-level properties (title/subtitle,
width/height) live **on the chart side**, in a strip under the preview — a chart property
belongs on the chart (NN/g #4; the Tableau/Lyra convention). A link-styled affordance that
_acts_ rather than navigates is mis-dressed: such actions are **ghost buttons** with
verb-first labels (Carbon links-vs-buttons; "Use a constant"). _(Consulted via /council →
NN/g #1/#3/#4, Carbon button/link usage. This bullet is the contract.)_
**Resolved — the intent front door is an APG toolbar of toggle chips (Tier C "do it for me").**
The Chart Builder's _"What do you want to show?"_ strip (spec §06 → Intent) is a **WAI-ARIA
`toolbar`** (one tab stop, roving tabindex, `aria-labelledby` the visible heading) of chips —
**not** seven independently-tabbable buttons (the pane-toggle precedent above). Each chip is a
**toggle button** (`aria-pressed`) whose pressed state is **derived from the configuration**
(the chip whose recommended layout the live chart matches), never stored — so a hand-edit
resolves to "Custom" (none pressed) for free. Arrow keys **move focus only**; **Enter/Space
applies** — because applying an intent reshapes the whole chart, a radiogroup's select-on-arrow
would do that on every keypress (so this is a toolbar, not a `SegmentedControl` radiogroup).
Selection shows as an **accent ring, never an accent fill** (fill stays the primary-action
signal — arch 09 §3.3). Intents the dataset can't satisfy are **disabled via `aria-disabled`
and kept arrow-reachable** (APG: focusable disabled controls "where discoverability of a
function is crucial"), with the reason in the chip's accessible name (`aria-label`
"Correlation — needs two number columns") plus a `title` for sighted hover — **never hidden**
(Tableau _Show Me_). The chips are framed as **intents, not chart shapes** (FT Visual
Vocabulary / Datawrapper organize by intent); _Heatmap_ is the one chart-type label retained
— a deliberate divergence for recognizability that also mirrors the mark selector's _Heatmap_
label, so the intent and the mark read as one thing (revisit if it confuses). _(Consulted via /council →
APG toolbar + button(toggle); FT Visual Vocabulary / Datawrapper intent framing; NN/g #6
recognition. This bullet is the contract.)_
**Resolved — an error names the right fix, not a boilerplate one.** Don't staple a generic
remedy onto every failure. A missing dataset reference is **not** a JSON/spec syntax problem,
so the preview gives it a tailored, fixable line — _"Dataset «X» not found. Create it from
Datasets (⌘/Ctrl+K), or check the dataset name in your spec."_ — instead of the catch-all
"check your JSON syntax" hint reserved for actual parse/Vega-Lite errors. The fix in the copy
must match the actual cause (NN/g #9, GOV.UK error-message). The thrown
`DatasetNotFoundError` carries `datasetName` so the surface can name it.
**Resolved — no affordance for unbuilt features.** A visible placeholder promising future
functionality (the Chart Builder's dashed "+ row facet · LATER" shelf slots, removed
2026-06-13) is roadmap language shipped to users: it speaks our planning vocabulary, not
theirs (NN/g #2), and competes with the working controls around it (NN/g #8). A gated or
later-phase feature gets **no placeholder, tag, or disabled stub** until it ships — disabled
states are for _temporarily unavailable_ actions, not unbuilt ones. Design the layout so the
future control can land without rework (e.g. shelf slots are row-shaped), and keep the
roadmap in `docs/`, not in the UI.
**Resolved — service-worker update prompt & persistent storage (web.dev seat).** The build
uses `registerType: 'prompt'`, so a new service worker waits and never takes over a running
session on its own — the app **must** tell the user, or "ask before updating" silently means
"never update." `orchestration/pwa.ts` consumes `virtual:pwa-register` and surfaces
`onNeedRefresh` as a **durable** (non-auto-dismissing) info toast with a **Reload** action
that calls `updateSW()`; `onOfflineReady` is a transient success toast. Separately, browser
storage is best-effort and evictable under pressure, which for a local-first workspace is data
loss — so we request `navigator.storage.persist()` once at startup
(`infrastructure/storage-persist`), feature-detected and silent on denial (Chromium decides
automatically; nothing for the user to act on). _(Consulted via /council → web.dev, with the
exact API from vite-plugin-pwa/Workbox; see `reference/principles/web-dev.md`. This bullet is
the contract; cite it, not the source.) **Known gap:** the manifest ships no icons, so the app
is not yet installable — a design-asset task, logged not passed._
## 6. Motion & accessibility as default
Not features to add later — the baseline every surface is built on.
- **Reduced motion is honored globally.** Animations/transitions are neutralized under
`prefers-reduced-motion` (`styles/base.css`); never gate meaning on motion.
- **Colour is never the sole signal** (WCAG 1.4.1; Carbon status pattern). Pair it with a
label, icon, shape, or text — a toast carries a title, an `alert`/`status` role, **and a
filled status glyph** coloured by severity (§3); the draft dot has a `title`/`aria-label`.
- **Every control is labelled.** Icon-only buttons, toggles, and fields carry accessible
names so assistive tech can announce them.
- **A binary toggle exposes its state, not just its action.** A theme/on-off control is a
toggle button (`aria-pressed`) or `switch` (`aria-checked`) with a **stable** name, so AT
announces the current state at parity with the icon a sighted user sees — not just "switch
to dark" (APG → Button / Switch). The `ThemeToggle` uses `aria-pressed` + a stable label.
- **The shell has a heading and a bypass.** The app exposes an `<h1>` (not a styled `<span>`)
so there's a heading outline, and a **skip link** as the first focusable element so
keyboard users can bypass the header into `#main` (WCAG 2.4.1 / GOV.UK). Same-type
landmarks carry distinct accessible names.
- **Contrast holds in every theme.** A theme that can't meet legible contrast in part of
the UI is not complete (spec §10 / §07).
---
## 7. Revealed actions & destructive affordances
How row/list actions appear, and how dangerous ones signal themselves. (Pairs with the
iconography contract, [arch 09 §5](09-visual-design.md).)
- **Reveal-on-hover is a per-surface choice, not a default.** Hiding a control until hover
cuts clutter in a **dense, repeated** list the user inevitably traverses (the snippet-row
delete) — there, arrival is guaranteed, so discoverability isn't lost. But a **rare or
load-bearing** action must stay **always-visible**, or it becomes effectively unreachable
(NN/g #6 — recognition over recall; a feature you can't see you can't use). Decide per
surface; when in doubt, show it.
- **A hover-revealed control must also reveal on keyboard focus.** Gate visibility on
`:hover` **and** `:focus-within`/`:focus-visible`, never hover alone — otherwise the
action is mouse-only and invisible to keyboard users (WCAG 2.1.1). The snippet row reveals
its delete on `.item:hover` _and_ `.delete:focus-visible`.
- **Destructive controls signal danger on hover _and_ focus.** A delete/remove affordance
reddens to `--support-error` on both `:hover` and `:focus-visible` — not colour-by-mouse
only — so the warning reaches keyboard users at parity. Colour is a _reinforcement_ here,
never the sole signal: the control still carries its label/`aria-label` and the
consequential ones still route through a confirm dialog (§4).
---
## 8. Space-constrained controls (responsive collapse)
The three work panes resize independently, so a toolbar's room is a function of its
**pane's** width, not the viewport's. Controls must stay usable at the pane minimum
without clipping, wrapping, or crowding.
- **Query the pane, not the window.** Use a CSS **container query**
(`container-type: inline-size` on the row, `@container` on the controls), not a
media query — the pane width is what changed. Scope the container to the toolbar
row itself, away from heavy children (e.g. the Monaco editor) whose own layout
shouldn't inherit size containment.
- **Shed labels under pressure; keep a contested primary labelled.** When a toolbar
would wrap or crowd, **secondary** actions collapse to icon-only and the label
moves to `aria-label`/`title`. A **primary that shares the row with secondaries**
keeps its text — the label is what marks it as _the_ action to take (the editor
toolbar below ~480px: Publish stays "Publish"; Extract/Revert become glyphs). A
**standalone primary CTA**, whose prominence is carried by fill + size + position
rather than its words, _may_ collapse to a universal-set icon at the pane floor
(the library's "Create New Snippet" → "+" below ~250px). Either way it's a
degradation that preserves the accessible name — distinct from the closed
icon-only set (arch 09 §5.1 rule 4).
- **Trim a compact trigger's prose, not its state.** A disclosure trigger that names
its current state for recognition (NN/g #6) may drop the **verb prefix** to fit a
narrow rail — the library Sort trigger shows "Modified ↓", not "Sort: Modified ↓"
— but the full name (`aria-label="Sort by Modified, descending"`) is preserved for
assistive tech, so only redundant visible words are cut.
- **A flex control must be able to shrink.** A side control (a Sort button) carries
`flex: 0 0 auto` so the flexible field (search) absorbs the slack; the field's
`<input>` needs `min-width: 0`, or its intrinsic ~20ch width overflows the slot
and overlaps its neighbour. Right-aligned toolbars (`justify-content: flex-end`)
clip their **leftmost** item on overflow — left-align so the trimmable end is a
settings affordance, not a primary control, and size the pane minimum so it
doesn't overflow at all.
---
## 9. Organizing a large control surface (two levels)
A control surface too big for one scroll (the Theme Builder spans most of the
Vega-Lite config) is organized in two levels, each with a settled widget so the
choice isn't re-litigated per surface:
- **Level 1 — switch by domain with tabs.** Mutually-exclusive top-level
categories (Color, Marks, Type, …) are an APG **tab set**, one panel visible at
a time. Past a handful of tabs a horizontal strip wraps raggedly and the active
tab shifts rows; a **vertical tab list** (`aria-orientation="vertical"`, Up/Down
- Home/End) scales without wrapping and keeps the active panel anchored. Never
**nest** tab sets — two roving tablists collide.
- **Level 2 — group within a panel by how it's read.** Sub-groups a user reads in
full get **flat headings** (`role="group"` labelled by the heading). Sub-groups
where a user tunes one or two and skips the rest get a **single-expand
accordion** (APG accordion — heading-button toggles a `role="region"`; Up/Down
between headers) — Carbon's rule: accordion is for content "not crucial to read
in full." A per-section **modified badge** (count of set properties) keeps an
override scannable while collapsed (NN/g #6, recognition).
_(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
**Do**
- Pick the feedback channel from §1's table by the _nature_ of the message.
- Treat loading/empty/error as three designed states for every data surface.
- 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**
- Don't show a blocking dialog for something a toast can carry, or hide a
consent-for-destruction in a toast.
- Don't let a render or a save block typing.
- Don't treat "empty" as "error."
- Don't put error codes in the user-facing line — put diagnostics in the detail
disclosure, the next step in the message.
- Don't invent a keyboard model, attach ad-hoc `window` listeners, or gate Escape behind
the typing check.
- 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."
-79
View File
@@ -1,79 +0,0 @@
# 11 — The Learning Section (`/learn/`)
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. 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
`/learn/` is a third Vite entry (`learn/index.html``src/learn/`) alongside the landing
(`/`) and the app (`/app/`), under the same marketing-surface rules:
- Reuses **`src/core` and the landing's `LandingChart`** only — never stores, modals,
orchestration, or app components. Vega is lazy-loaded through `LandingChart`, so the entry
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
A lesson is a `.md` file in `src/learn/lessons/`, discovered with `import.meta.glob` — so
**adding a lesson is dropping a file**, with no registry to edit. A lesson is a _free-form
document of ordered blocks_, not a fixed template:
| 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. 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` + `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
- **The engine consumes plain data, so the authoring surface is swappable.**
`SpecProgression`, `spec-diff`, and `formatSpec` take parsed blocks and specs and know
nothing about markdown — the parser is the only thing coupled to the `.md` format. The
authoring format can change without touching the widget.
- **Parsing, diff, and formatting are pure and live in `core`** (tested hardest); rendering
and the markdown library live in `src/learn`. **Core stays dependency-free**`marked` is
imported only by `src/learn/Markdown`.
- **`dangerouslySetInnerHTML` renders first-party lesson files** (repo content authored by
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.
@@ -1,853 +0,0 @@
<!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>
File diff suppressed because it is too large Load Diff
-15
View File
@@ -1,15 +0,0 @@
# Codebase Metrics
One row per `/eng-council` sweep. The trend is the point: accretion becomes visible
instead of felt. Methodology: LOC excludes `*.test.*`; "dead exports" is `npx knip`
unused **runtime** exports (unused exported _types_ tracked in the sweep report, not
here); "dup %" is `npx jscpd src --min-tokens 50 --format "typescript,tsx"` (tests
included — they dominate the clones; non-test clones are itemized in the sweep report).
| date | src files | src LOC | core LOC | app LOC | deps | knip dead exports | jscpd dup % |
| ---------- | --------- | ------- | -------- | ------- | ---- | ----------------- | ----------- |
| 2026-06-12 | 105 | 16876 | 4669 | 12130 | 12 | 0 | 2.69 |
| 2026-06-12 | 106 | 16695 | 4655 | 11963 | 12 | 0 | 2.03 |
The second 2026-06-12 row is the same sweep after its structural proposals landed
(usePopover/useColResizeDrag extraction, modal-cycle break, dead service deletion).
-24
View File
@@ -1,24 +0,0 @@
# Deployment & Distribution
Operational facts that live nowhere in the code.
## Hosting
- **astrolabe-viz.com** runs on **Cloudflare Pages**, project `astrolabe-viz`,
Git-connected to the private GitHub repo `olehomelchenko/astrolabe`.
- **Push to `main` auto-builds and deploys** (`npm run build`; the post-build
`check-light-entries` gate runs as part of it). There are no GitHub Actions.
- Live since 2026-06-20.
- **No analytics.** The app ships no beacon or tracking of any kind; Cloudflare keeps
standard aggregate access logs as any host does. The About modal's privacy copy states
exactly this — keep them in agreement.
## Distribution posture
- **Free to use; the code is private.** Astrolabe is not open source. User-facing copy
(landing, About, learn) must never claim or imply otherwise — the open thing is the
_format_ (Vega-Lite JSON), and copy attributes openness to it deliberately.
- **Feedback channel** is the branded address `feedback@astrolabe-viz.com`
(`src/app/feedback.ts`), not a public issue tracker.
- **Release cadence**: unreleased pre-1.0; the first public release is `1.0.0`, cut on
the maintainer's signal (see `docs/IMPLEMENTATION-PLAN.md` and the `/release` skill).
-447
View File
@@ -1,447 +0,0 @@
# Embedding Vega-Lite in a real app: the parts the docs don't warn you about
Vega-Lite is a joy to author and a little treacherous to embed. The grammar is
well documented; the _runtime_ — view lifecycle, sizing, fonts, export, theming —
is where you lose an afternoon to a blank chart with no error in the console.
This is a field guide from building a browser app that renders arbitrary,
user-authored Vega-Lite specs live: type JSON on the left, see the chart on the
right, export it, theme it, keep it responsive. Everything below is something that
actually cost us time, with the fix and — more importantly — _why_ it happens, so
you can recognize the next variant of it.
It assumes `vega-embed`. If you hand-roll `compile → parse → new View()`, the same
issues apply; you just own more of the plumbing.
---
## 1. The view is the bug surface, not the spec
Every successful `vegaEmbed()` hands back a `result.view` — a live Vega `View`. It
owns timers, signal listeners, event handlers, and DOM. Render a new spec into the
same node without disposing the old view and the old one **leaks**: its listeners
keep firing, resources accumulate, and a long editing session slowly degrades.
```ts
let current: Awaited<ReturnType<typeof vegaEmbed>> | null = null;
async function rerender(node: HTMLElement, spec, config) {
current?.view.finalize(); // tear down the previous view FIRST
node.replaceChildren(); // drop any DOM the previous embed left behind
current = await vegaEmbed(node, spec, { config });
}
```
Two rules that pay for themselves:
- **`finalize()` before every re-embed, and on unmount.** This is the single most
important discipline. `finalize()` is not optional cleanup; it's how you avoid a
zombie view.
- **Keep all `vegaEmbed()` calls behind one small module.** Components ask it to
"draw this spec into this node" and get back a handle with `destroy()`,
`toImageURL()`, `resize()`. Nothing else imports `vega-embed` or touches a `View`
directly. This one boundary is what makes every other fix in this article land in
exactly one place.
---
## 2. Container sizing breaks in two completely different ways
`width: "container"` / `height: "container"` is how Vega-Lite does responsive
sizing. It's also the source of the two most baffling bugs we hit, and they look
nothing alike.
### Gotcha A — the chart collapses to zero width
Symptom: `width: "container"` charts render a sliver; `height: "container"` is
often fine. Classic "width is broken, height works" head-scratcher.
Cause: `vega-embed` injects `.vega-embed { display: inline-block }` into `<head>`
at runtime. Because it's injected late, it **wins the cascade** over a class you put
on that same element. `inline-block` shrink-wraps horizontally, and `"container"`
width reads `host.clientWidth` — which is now ~0. (Height survives because a tall
parent still gives the box a `clientHeight`.)
A nasty wrinkle: `vega-embed` only adds its responsive `chart-wrapper` element —
the thing its own `width: 100%` rule targets — **when the actions menu is enabled**.
If you pass `actions: false` (you probably do; see §7), that path is dead and your
element is branded `inline-block` directly, with nothing fixing it.
Fix: embed into a dedicated **inner host** with a _static_ className, nested inside
an outer frame you control. Size the inner host with a **two-class selector** so you
out-specify `.vega-embed`:
```css
/* one class loses to .vega-embed; two classes win */
.fitWidth .host {
width: 100%;
}
```
Keep the inner host's class static so React (or whatever owns the DOM) never
re-reconciles it and stomps Vega's runtime classes.
### Gotcha B — the chart never follows a resize
Symptom: a responsive chart sizes correctly on first render, then ignores the pane
being dragged wider.
Cause: Vega-Lite compiles `"container"` sizing into width/height signals that
re-read `containerSize()` **only on a `window:resize` event**. Two consequences
people rediscover the hard way:
1. `view.resize()` does **not** re-measure. It re-runs layout with the _stale_ size.
2. A pane drag (a splitter, a layout change) fires no `window:resize`, so nothing
re-fits on its own.
Fix: observe the host with a `ResizeObserver` and synthesize the event Vega is
actually listening for.
```ts
const ro = new ResizeObserver(() => {
window.dispatchEvent(new Event('resize')); // the mechanism, not a hack
});
ro.observe(host);
```
This is the documented mechanism, not a workaround — it's literally what the Vega
editor does. `ResizeObserver` callbacks are frame-batched, so it tracks a drag
smoothly with no debounce. Bonus: only the container-bound dimension carries the
resize handler, so a width-only chart re-fits width and leaves height natural for
free, with zero bookkeeping.
---
## 3. Fonts must finish loading _before_ you render — any renderer
This one is invisible until you ship a custom font. The chart renders, the text
looks slightly wrong (spacing off, labels colliding or over-padded), and it
_sometimes_ fixes itself on the next edit.
Cause: Vega measures every text label with canvas `measureText` **regardless of
renderer** — SVG, canvas, even the headless `'none'` renderer runs a layout pass. If
a web font is still loading when you embed, the entire chart is laid out with
_fallback_ font metrics. When the real font swaps in, the glyphs change but the
layout was already computed against the wrong widths.
Fix: gate the render on the fonts the spec actually references.
```ts
async function ensureFontsLoaded(families: string[]) {
if (!document.fonts?.load) return; // no-op in tests / old browsers
const loads = families.flatMap((f) =>
['400', '600', '700'].map((w) => document.fonts.load(`${w} 16px ${f}`)),
);
// allSettled, not all: a missing face (offline, 404, a system family with no
// @font-face) is EXPECTED — degrade to fallback metrics, never fail the chart.
await Promise.race([
Promise.allSettled(loads),
new Promise((r) => setTimeout(r, 3000)), // bound a slow first fetch
]);
}
```
Two judgment calls worth copying: use `allSettled` (a font failing to load is not a
chart error — it's a render-with-fallback), and cap the wait with a timeout so a
slow network never freezes the preview. A cached face resolves near-instantly; the
timeout only ever bounds the very first fetch of an uncached subset.
---
## 4. SVG vs canvas is a real performance cliff, and canvas has a silent ceiling
The default `renderer: 'svg'` is the right call almost always — crisp at any zoom,
inspectable, copyable, themeable. But SVG renders **one DOM node per mark**. A chart
with thousands of marks (say one bar per row of a 10k-row dataset) costs _seconds_
of main-thread layout and paint per render. We measured ~6.5s of paint on ~10k rows —
and the freeze lands _after_ the chart first appears, because the browser paints the
SVG tree lazily. The tab locks up holding a chart that looks done.
Switch many-mark charts to `renderer: 'canvas'`: a single node, painted in
milliseconds. The raster trade-off (not crisp on zoom) is invisible for an ephemeral
preview, and — crucially — image export is renderer-agnostic (§7), so you lose
nothing downstream.
But canvas has its own trap: a **hard maximum dimension**. Browsers cap a canvas
backing store at ~32,767px per side (less on Safari, which is also area-bound). Past
that, the canvas fails to allocate and draws **nothing** — no error, no exception,
just a blank surface and sometimes a null 2D context. A tall categorical chart
(hundreds of natural-height rows) blows past this easily.
Fix: before committing to canvas, run a headless layout probe and read the resolved
size. The `'none'` renderer computes layout without allocating a canvas:
```ts
const probe = await vegaEmbed(detachedDiv, spec, { renderer: 'none', config });
const height = probe.view.height();
probe.view.finalize();
const limit = 32767 / (window.devicePixelRatio || 1); // backing store is dpr×
if (height > limit) throw new ChartTooLargeError(height, limit);
```
Now you can tell the user the _real_ cause ("this chart is 50,000px tall") instead
of handing back a blank box. SVG has no such cap — it just gets slow — so the probe
is canvas-only.
---
## 5. Exporting an image has three sharp edges
You'd think `view.toImageURL()` is the export story. It isn't, quite.
**Retina blur.** `toImageURL`'s `scaleFactor` ignores `devicePixelRatio`. A naive
"1×" PNG export comes out at half resolution on a 2× display — soft, obviously
wrong next to the crisp on-screen chart. Multiply the scale by dpr yourself:
```ts
const dpr = window.devicePixelRatio || 1;
const canvas = await view.toCanvas(scale * dpr); // "1×" now matches the screen
```
**Transparent background.** If your theme sets `background: 'transparent'` (you
probably do, so the chart shows the pane color through it — §6), every export is
also transparent. Usually not what someone wants in a PNG. Composite an opaque color
under the canvas, and inject a full-bleed `<rect>` as the first child of the root
`<svg>` for the vector path:
```ts
svg = svg.replace(/(<svg\b[^>]*>)/, `$1<rect width="100%" height="100%" fill="${bg}"/>`);
```
**SVG drops your fonts.** `view.toSVG()` serializes only the `font-family` _name_.
Open that SVG anywhere the font isn't installed and it falls back to a system font.
If the font is one your users uploaded, embed it as a base64 `@font-face` rule inside
a `<style>` at the top of the SVG:
```ts
const rule = `@font-face{font-family:"${family}";src:url(${dataUri}) format("woff2");}`;
// SVG is XML, and a family name can contain & or <, so wrap the CSS in CDATA —
// and defensively split the one sequence CDATA can't contain:
const css = rule.replace(/]]>/g, ']]]]><![CDATA[>');
svg = svg.replace(/(<svg\b[^>]*>)/, `$1<style type="text/css"><![CDATA[${css}]]></style>`);
```
PNG needs none of this — the raster already baked the glyphs in. Only the vector
format leaks the font dependency.
One nice property to lean on: export is **renderer-agnostic**. `view.toCanvas()` and
`view.toSVG()` draw to their own off-screen surface, independent of how the chart is
displayed. So you can show an SVG chart on screen and still export a high-res PNG, or
show a canvas preview (§4) and still export a clean SVG.
---
## 6. Theme is a config you merge at embed time — and Vega is picky about it
A Vega-Lite **config** object styles every chart globally: fonts, axis colors,
background, the categorical palette. The right model is to keep the config _out_ of
the user's stored spec and inject it at embed time, so the same spec re-themes for
free when the UI flips light/dark:
```ts
await vegaEmbed(node, spec, { config: chartConfigFor(theme) });
```
Three things that bit us:
- **The spec wins, key by key.** Vega-Lite merges your injected `config` _under_ the
spec's own `config` (`mergeConfig(opt.config, spec.config)`). That's the behavior
you want — a snippet can override or opt out locally — but know it: you can't force
a style the spec contradicts.
- **Don't rebuild a config from a fixed schema.** If you let users edit a config
through structured controls, mutate the config object _in place_; don't reconstruct
it from a known set of keys. Preset themes (and Vega proper) carry Vega-_layer_
keys — `symbol`, `shape`, `path`, `group` — that aren't in the Vega-Lite `Config`
type but are forwarded to Vega at render. A rebuild silently drops them.
- **A bare scheme name passes compile but fails at render.** Writing
`range: { category: "tableau20" }` (a bare string) survives Vega-Lite _compilation_
and then Vega rejects it at _render_ with "Unrecognized scale range value" — and
blanks the chart. The accepted form is the object: `range: { category: { scheme:
"tableau20" } }`. This compile-passes/render-fails split is a recurring Vega theme;
when a chart goes blank with a console error but no compile error, suspect a value
that's structurally valid JSON but semantically wrong for the runtime.
If you want themes that follow the system light/dark, keep exactly **one** function
that maps `(selection, uiTheme) → config`. Every render resolves through it; nothing
else decides a chart's styling. (We also offer the `vega-themes` package's presets
verbatim — it's already in your tree as a `vega-embed` dependency, so the famous
FiveThirtyEight / Excel / Carbon looks are free.)
---
## 7. `actions: false` does more than hide a menu
You'll almost certainly want `actions: false` — the built-in "Save as / View Source /
Open in Vega Editor" overlay doesn't belong on most embeds, and you'll provide your
own export. Just know two side effects:
- As noted in §2, it removes the responsive `chart-wrapper`, so you own host sizing.
- You also give up the built-in PNG/SVG export, so build your own through the view
(§5). That's a feature, not a cost — you get dpr-correct, background-filled,
font-embedded exports the built-in menu never gave you.
And for tooltips: pass `tooltip: { disableDefaultStyle: true }` so `vega-tooltip`
doesn't inject its own light/dark stylesheet. The tooltip element (`#vg-tooltip-
element`) is appended to `<body>`, so once the default style is gone you style it
entirely from your own CSS — and because it lives under `<html>`, it inherits your
`[data-theme]` cascade for free. `vega-tooltip` still handles positioning and the
`.visible` toggle; you just supply the look.
---
## 8. Field names with dots are not what you think
If you construct specs from data-derived column names (a chart builder, an
auto-encoding helper), this _will_ bite you. Vega-Lite treats `.`, `[`, and `]`
inside a `field:` as **nested-property accessors**: `field: "user.age"` reads
`row.user.age`, not a column literally named `"user.age"`. Real-world CSVs have
columns like `Price ($)` or `2021.Q3` all the time.
```ts
const escapeField = (name: string) => name.replace(/([.[\]])/g, '\\$1');
encoding.x = { field: escapeField(columnName), type: 'quantitative' };
```
Escape every data-derived name before it lands in any field-position key — `field`,
`as`, `groupby`, tooltip fields, the lot. (For specs a user hand-authored, escaping
is their responsibility; don't rewrite their `field:` values.)
---
## 9. Never mutate the spec you render
Rendering should be a pure function of (spec, config). If your pipeline rewrites the
spec on the way to the view — resolving dataset references to inline values, applying
a responsive sizing mode, escaping fields — do it on a **deep copy**:
```ts
const prepared = structuredClone(userSpec);
// ...mutate `prepared` freely: resolve refs, set width:"container", etc.
await vegaEmbed(node, prepared, { config });
// userSpec is untouched — what the user sees in the editor is still what they wrote.
```
The moment rendering mutates the stored spec, you get spooky action: a fit-mode
toggle permanently rewrites the user's `width`, an export inlines a 2MB dataset into
the document they're editing. Keep the transform pure and copy-first, and it stays
unit-testable without a DOM as a bonus.
A related subtlety: a "fit to container" mode that sets `width: "container"` should
also _delete_ the spec's explicit `height` (and vice-versa) so the unconstrained
dimension recomputes naturally. Which means a surface that lets the user type an
explicit width/height must opt _out_ of fit mode while they do — the two fight over
the same keys.
---
## 10. Live editing: debounce the input, guard the output
For a live preview that re-renders as the user types, two independent concerns:
**Debounce edit → state, not state → render.** Re-rendering must never compete with
typing. Debounce the editor's text changes (we make the delay user-configurable,
~5005000ms); render only after a pause. But render _immediately_ for non-typing
changes — loading a different spec, a theme flip, a fit-mode toggle. The debounce
exists for keystroke churn and nothing else; detect "this was a keystroke" by
elimination (the text changed but the document identity didn't).
**Guard against out-of-order renders.** `vegaEmbed`/`runAsync` is async, so a slow
render can resolve _after_ a newer one already mounted. A bare debounce doesn't cover
this. Stamp each render with a generation token and let only the latest one win:
```ts
let generation = 0;
async function render() {
const mine = ++generation;
const handle = await renderSpec(node, spec, config);
if (mine !== generation) {
handle.destroy();
return;
} // a newer render superseded us
current = handle;
}
```
And keep the _last good chart on screen_ while the next render computes — overlay a
subtle busy indicator rather than blanking the pane. A pane that flickers to empty on
every keystroke feels broken even when it's fast.
---
## 11. Errors: a blank spec is not an error, and recovery should be automatic
Three stages fail, and you want them distinguishable in the message: JSON parse
("Invalid JSON: …"), spec preparation ("Dataset not found: …"), and embed itself
("Rendering error: …", the Vega-Lite compile or Vega runtime failure). Funnel all
three to one error field the preview reads.
The behaviors that make it feel solid:
- **Empty/blank text renders nothing** — a clean pane, not an error. Finalize the
current view, clear the error, stop.
- **Every successful render clears the error.** Recovery is then automatic: the next
valid edit re-renders and wipes the message. No retry button, no reload.
- **Keep the last good chart visible under a parse error** if you can, so a
half-typed keystroke doesn't strobe the whole pane.
- **Wrap `runAsync`/embed in try/catch and finalize on failure.** Vega won't catch
runtime errors for you, and a half-initialized view leaks if you don't finalize it.
- **Don't dump a raw stack trace.** Give the reason plus a hint ("check your JSON and
that the spec is valid Vega-Lite").
---
## 12. If you also embed an editor (Monaco) — wire it yourself
Optional, but if you're putting users in front of raw spec JSON you'll want schema
validation and autocomplete. The non-obvious parts:
- **Bundle the schema; never fetch it.** `import schema from
'vega-lite/vega-lite-schema.json'` and register it once, globally, via the JSON
language service (`setDiagnosticsOptions`). Version-locked to your installed
Vega-Lite, offline-safe, no runtime network call. Set `enableSchemaRequest: false`
so the worker can't go fetch an unbundled `$schema` URL behind your back.
- **Bind by `fileMatch`, not by the doc's `$schema` value.** If you key validation
off the `$schema` line, a spec without one gets zero validation and zero
completions. Match your model URIs instead so it always works.
- **Monaco workers are on you.** With a CDN loader they're automatic; self-hosted,
you must set `MonacoEnvironment.getWorker` to return the JSON worker for label
`'json'` and the editor worker otherwise. No worker means no squiggles and no
completions — and no error telling you why.
- **`quickSuggestions: { strings: true }`.** Vega-Lite enum values (`"bar"`,
`"quantitative"`) live _inside JSON strings_, where Monaco disables autocomplete by
default. Without this, completions silently never appear.
- **Two layers, two tiers.** The Monaco worker gives inline squiggles; a separate
`ajv` pass can feed a richer error pane. Sort findings into _fatal_ (syntax / compile
/ runtime errors that suppress the chart) and _advisory_ (schema-validation warnings
that don't). Vega-Lite emits plenty of benign warnings; treating them as fatal hides
specs that render fine.
- **ajv has its own gotchas:** construct it with `strict: false`, add the draft-06
meta-schema (the VL schema is draft-06; ajv 8 defaults newer), register a no-op
`color-hex` format, and **compile the validator once at module load** — the schema
is multi-megabyte and compiling per keystroke is a real perf sink.
---
## The short version
If you skim one thing, skim this:
| Trap | Fix |
| --------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
| Re-embedding leaks the old view | `view.finalize()` before every re-embed and on unmount |
| `width:"container"` collapses to ~0 | Inner host + out-specify `.vega-embed { inline-block }` with a 2-class selector |
| Chart won't follow a resize | `ResizeObserver` → `window.dispatchEvent(new Event('resize'))`, not `view.resize()` |
| Custom font lays out wrong | `document.fonts.load(...)` (allSettled + timeout) before embed; metrics are measured regardless of renderer |
| Many-mark SVG freezes the tab | Switch to `renderer: 'canvas'`; probe size first — canvas fails silently past ~32k px |
| Export looks soft on Retina | Multiply scale by `devicePixelRatio` |
| Export is transparent / loses fonts | Composite a bg color; embed `@font-face` (CDATA) in the SVG |
| Bare scheme name blanks the chart | Use `range: { category: { scheme: "…" } }`, not a bare string |
| Dotted column names misread | Escape `.[]` in every data-derived `field:` |
| Stale async render clobbers a fresh one | Render-generation token; only the latest wins |
| Rendering mutates the user's spec | Transform on a `structuredClone` copy |
None of these are exotic. They're the gap between "it works in the demo" and "it
holds up under a 10k-row dataset, a custom font, a dragged pane, and a Retina
export." Vega-Lite is excellent; it just expects you to know where its runtime edges
are. Now you do.
-18
View File
@@ -1,18 +0,0 @@
# docs/exploration
Point-in-time records: research, tool reviews, and scope/planning memos written to think a
decision through. Unlike `docs/spec/` (the contract — the _what_) and `docs/architecture/`
(the maintained _how_), nothing here is kept current — each file reflects what was known
when it was written. Decisions that survived are folded into the spec and architecture;
these remain as the reasoning behind them.
Active follow-up work is tracked in [`../IMPLEMENTATION-PLAN.md`](../IMPLEMENTATION-PLAN.md),
not here. Exact figures in these files (timings, line counts, test counts) are frozen
snapshots, not live numbers.
- `chart-builder-research.md` — cross-source chart-choice research (Draco, Voyager, FT Visual Vocabulary, Datawrapper).
- `lyra-review.md` — review of vega/lyra for Chart Builder interaction ideas.
- `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.
@@ -1,53 +0,0 @@
# 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.
@@ -1,684 +0,0 @@
# Chart Builder — Enhancement Scope
> **Status:** scope consolidated 2026-06-10. This is the **single forward-looking home**
> for chart-builder enhancement work — it merges the research backlog from
> [`chart-builder-research.md`](./chart-builder-research.md) §8 (the M4 decision and its
> deferred items) with the interaction ideas from [`lyra-review.md`](./lyra-review.md) §5,
> read against the current spec ([`spec/06-chart-builder.md`](../spec/06-chart-builder.md))
> and the shipped code (`src/core/chart-builder.ts`).
>
> **Goal (the brief):** a **rapid, intuitive GUI for building Vega-Lite specs**, with
> **guidance and recommendations on the fly**, **moderately capable** — not a full
> visual-design IDE.
>
> **Decision (2026-06-10):** push the builder from its shipped **Tier B** ("smart +
> guarded") up to **Tier C** ("intent-first aid"). "Moderately capable" is the ceiling:
> we add the controls that are _both common and awkward in JSON_ and stop there; the long
> tail of styling/scale/axis breadth stays in Monaco. The two source docs remain the
> research record (the _why_ and the citations); this doc is the _plan_ (the _what next_
> and the _order_).
---
## Status log
Newest first. The at-a-glance build-order tracker is §4; per-item detail is §3. This log is
the quick "where are we" — read it first.
- **2026-06-18 (3C open-in-builder, edit-in-place)** — **the builder became create-and-revise.**
Core: `parseChartSpec` / `parseChartSpecText` (`chart-builder.ts`) — the strict inverse of
`buildChartSpec`, gated by **re-assemble + canonical deep-compare** (ignoring key order,
`$schema`, the injected `mark.tooltip`), so the gate widens for free as the dialect grows and
never lets the builder overwrite a richer spec. `unescapeVegaField` added beside its escape
twin (`rendering.ts`). Save semantics chosen with the user = **edit in place** (not
always-create): `SnippetStore.replaceSnippetSpec` republishes the built spec into both the
snippet's versions (auto names re-derive like publish; user names kept), and
`ChartBuilderStore` gained `editingSnippetId`/`editingSnippetName` + `openForEdit`/`saveEdits`.
Entry point: an **editor-toolbar** _Open in builder_ action (`SpecEditor`), beside _Extract to
Dataset_, shown only when the published spec round-trips _and_ its dataset exists — **hidden
otherwise** (content-gated, like Extract). The modal's primary becomes **Save changes** with an
"Editing _name_" banner; a dedicated `openChartBuilderForEdit` coordinator opener hydrates
before showing the modal so the registry's default `init` can't clobber it. **Data-model edge
(called out, not a bug):** the builder references a dataset by name, so only dataset-referencing
specs hydrate — inline-data snippets (incl. the onboarding gallery) stay Monaco-only. This
reshapes **3B**: builder-openable starters need a paired dataset, so 3B's shape is decided
_after_ this lands (see §3). Spec §06 gained an _Open in builder_ section. **Placement settled
by council:** an initial library metadata-panel placement (beside Duplicate/Delete) tested
undiscoverable; `/council` (NN/g #6 recognition / #4 consistency; Carbon button usage) put it in
the editor toolbar and settled the hidden-vs-disabled question via the toolbar's own precedent —
**content-gated** actions hide (Extract, Open-in-builder), **state-gated** ones disable (Revert,
Config). Recorded as a contract rule in `architecture/10` §5; the parked `ux-second-pass.md` row
is closed. Verified: typecheck + full tests (1089) + eslint + prettier. **Owed:** a visual/manual
pass on the live edit flow (the toolbar action, the "Editing" banner, Save changes).
- **2026-06-13 (guidance: reason over role, not raw type)** — closed a **false-positive class**
in `builderWarnings` (eng-council + council consult). A histogram (bar, binned-Q X, count Y)
tripped "two measures → scatter" because `isMeasureMapping`/`effectiveType` ignored `bin`.
Fix: `isMeasureMapping` is now **bin-aware** (a binned field is a discretized dimension —
mirrors Vega-Lite's own `isDiscrete(fieldDef)`); a new `isReorderableCategory` keeps Sort
from being offered on a histogram's binned axis (the council-flagged regression); the scatter
rule routes through the role predicate + a positive mark list (deletes the `mark !== 'rect'`
bolt-on); `stackMeasureChannel` excludes binned. Pinned with regression tests (no
scatter/Sort on a histogram; real bar still sorts; two raw measures still nudge). Promoted to
**/alignment check #15** ("reason over role, not raw type"). **Not done (deliberately):**
intent-gating the taste warnings — verified no current intent layout trips one, so the gate
would be dead code; recorded as a standing principle in `builderWarnings` instead. The
broader posture (curate good via the front door > enumerate bad via warnings) is the council's
recommended bottom for the combinatorial-warning worry. Verified: typecheck + test (913) +
eslint + build.
- **2026-06-13 (3A + heatmap mark)** — **the intent-first front door shipped (Tier C), and
the mark set gained `rect` (Heatmap).**
- **Heatmap mark** — `rect` added to `MARK_TYPES` (six marks; labelled **Heatmap** in the
picker, which now wraps to two rows in the 320360px pane). Guidance rewired: both-axes
rule covers heatmaps; a new hint nudges a Colour measure on a two-axis heatmap with a
one-click **Colour by count**; `rect` is exempt from the two-measures→scatter nudge (a
binned 2-D histogram is valid). Never the auto-default mark; names read "Heatmap of …".
Spec §06 mark-type + guidance updated.
- **3A intent front door** — a **persistent strip** at the top of the config pane (the
interaction model chosen with the user over a replace-on-open screen — "shows all
controls + a do-it-for-me", the Tableau _Show Me_ parallel). Core (`chart-builder.ts`):
`CHART_INTENTS` (Compare/Ranking/Change-over-time/Correlation/Distribution/Part-to-whole/
Heatmap), `intentLayout` (intent × column-roles → mark + channels), `intentApplicable`
(Show-Me gating), `applyIntent` (reshape, keep dataset/transforms/title), and
`activeIntent` (structural match → the live chart's intent highlights with **no stored
state**; lights the smart default on open). Store: `setIntent`. UI: an APG **toolbar** of
toggle chips (roving tabindex, arrows move / Enter applies, accent-ring selection,
`aria-disabled`+reason on inapplicable intents).
- **Council** run on the front-door copy/flow → toolbar (not radiogroup — select-on-arrow
would reshape the chart), disabled-focusable-with-reason, intent-framing (Heatmap kept as
the one chart-type label, mirroring the mark). Recorded in `architecture/10` §5; spec §06
gained an **Intent (the front door)** subsection.
- **Verified:** `typecheck` + `test` (909, +17: intent core/store, heatmap guidance/naming)
- `eslint` + `build`. **Owed:** a visual pass on the live strip (chips, disabled states,
keyboard) + a heatmap rendered from real data. **Open (user's call):** whether the
_Heatmap_ chip should read as an intent phrase instead (council's intent-framing point).
- **2026-06-12 (3D)** — **entry points & discoverability shipped. Up next: 3A (the
front door now has somewhere to be found).**
- **Library creation surface forked:** primary **Build Chart** (accent, takes the
slack) + ghost **New JSON snippet** (the old instant create) — two plain buttons,
no menu widget; labels collapse in two stages as the pane narrows (the long ghost
label first). New `chart` icon (rising columns, pane-icon rect style).
- **The builder opens un-targeted:** `init(null)` picks the most recently modified
dataset; a **Dataset picker** heads the config pane (replaces the "Building from"
line). Switching with an untouched config re-derives smart defaults; a built-on
config is **rebased** via the new core `rebaseBuilderConfig` (mark/title/size/
sort/stack/calculates/expression-filters kept; encodings + predicate filters bound
to columns the new dataset lacks shed; same-schema switch keeps everything).
- **No-datasets state** (Carbon no-data): what the builder does + primary "Add a
dataset" → Datasets create form. A defensive "Choose a dataset" chooser covers the
datasets-exist-but-none-loaded case. New `#build` hash serializes the
no-dataset builder (spec §01E table).
- **Onboarding gains the data-first door** ("Build a chart from your data", ghost
beside the primary; applies the workspace split up front since the builder can
create the first snippet). Dataset-row "Build Chart" unchanged (the contextual
shortcut).
- **Contract updated:** spec §02 (creation surface, onboarding), §06 (Opening
rewritten: doors, dataset picker, no-datasets state), §01E (`#build`); council
resolutions recorded in `architecture/10` §5 ("builder-forward, Monaco-intact").
- **Verified:** `typecheck` + `test` (873, +10: core rebase, store init(null)/
switchDataset, url-hash `#build`, modal empty states) + `eslint` + prettier.
**Owed:** a visual pass on the forked creation surface + builder picker/empty
states (user-driven, batched with the 2B visual debt).
- **2026-06-12 (polish batch)** — **the 2B visual-pass findings fixed; the parked council
batch resolved and applied; two capability gaps closed.**
- **Aggregation for any field type.** `validAggregateOps` replaces the quantitative-only
gate: **Count distinct** on anything (a nominal Colour/Y now measures unique values —
emitted as a quantitative `{ aggregate: 'distinct' }`), Min/Max on temporal/ordinal,
arithmetic still quantitative-only. Retyping keeps a still-valid aggregate.
- **Title/subtitle** (pulled forward from the placement discussion): top-level `title`
emission; a user title becomes the snippet name; subtitle gated on a title.
- **Council batch applied:** the type chip and every native `<select>` in the builder are
now **`SelectControl`** — a reusable value-picker disclosure (the SortControl primitive
generalized; replaces native selects app-wide where the control is part of a designed
surface). Field chips open an explicit **channel chooser** (occupied channels say what
they'd replace); an **armed** channel short-circuits it and is now visible (accent ring +
status line + Esc disarms). "or constant" → **"Use a constant"** ghost button. Chart
properties (Title · Subtitle · W · H) moved to a **strip under the preview**; Sort/Stack
stay by the encodings. Resolutions recorded in `architecture/10` §5; `ux-second-pass.md`
cleared (drag stays deferred).
- **Visual-pass fixes:** pill ✕ flush right (pills hug content), Swap X/Y beside the Axes
heading, field shelf scrolls (280px viewport, sticky group heads), per-channel transforms
inline beside the pill, preview honors explicit width/height (fit-mode only while auto).
- **Verified:** `typecheck` + `test` (774, +10) + `eslint` + `build`. **Owed:** a fresh
visual look at the reworked surfaces (user-driven).
- **2026-06-12 (scope)** — **3D added to Phase 3: builder entry points & discoverability**
(council-reviewed: NN/g #6 recognition + #7 expert accelerators, Carbon empty-states).
Today the builder's only door is Datasets → row "Build Chart" while the primary "Create
New Snippet" lands in blank Monaco — the data model's shape, not the user's intent.
Decided: fork the library's creation surface (primary **Build Chart**, ghost **New JSON
snippet**), give the builder an internal dataset picker + a no-datasets empty state, add
an onboarding tile. 3D lands **with or just before 3A** — the Build-Chart button opens
onto the intent front door, making 3A the app's guided creation flow. Detail in §3 · 3D.
- **2026-06-11 (scope)** — **3C added to Phase 3: open-in-builder (strict spec hydration).**
A snippet-list button, enabled exactly when the spec round-trips losslessly through the
builder dialect (checked by re-assemble + deep-compare, not feature enumeration). Strict
only — no lossy/residue modes (§5's round-trip trap stays closed). Detail in §3 · 3C.
- **2026-06-11 (Phase 2)** — **2A + 2B shipped: the field-first interaction substrate.** Up next: **3A intent-first front door**.
- **2A · Value-or-field channels (the Property model).** `ChannelMapping` gained a constant
`value` arm; the assembler emits `{ value }` and the measure/stack/area-split/prune logic
all treat a constant Colour as "no series." New pure helpers (`isValueMapping`,
`channelAcceptsValue`, `defaultChannelValue`, `coerceChannelValue`,
`isColumnAllowedOnChannel`). `type` stays required (a constant carries a preserved-but-
ignored type, so a field↔constant toggle round-trips). Tested.
- **2B · Field-first builder.** The channel-first dropdown rows are replaced by a **field
shelf** (columns as type-glyphed chips, auto Dimensions/Measures split past a column
threshold), **click-to-assign** (armed channel, else first empty that accepts it —
`assignField`/`focusChannel`), Tableau-style **pills** (a type chip that **cycles** the
field type in-place, transforms beneath), and the **on-chart Columns/Rows shelves** above
the preview (X/Y as a property of the chart) with a **reserved faceting placeholder** in
each. Colour/Size live in a left **Marks card**, each switchable to a **constant** (2A).
- **Decisions taken (overridable):** drag deferred (click/keyboard-first, fully tested);
field grouping auto; aggregate stays a control under the pill (no pill context-menu yet).
- **Spec §06** rewritten for the field-first model (Layout, Encoding channels, faceting
placeholder, constants, validation/output).
- **Verified:** `typecheck` + `test` (762, +20 core/store/modal) + `eslint` + `build`.
**Owed:** a manual/visual pass against the live builder; a **`/council`** look at the
type-cycle chip (a cycling button gives no direct type pick — explicit menu vs cycle is a
real a11y trade-off) and the constant affordance.
- **2026-06-11 (later)** — **Owed debts on the Data section closed. Up next: Phase 2 (2A value-or-field channels, then 2B field shelf).**
- **Council pass** on the new error/disclosure copy (the previously-deferred auto-fire
surface). Three a11y conformance gaps against `architecture/10` were fixed: the inline
expression feedback now carries a **status glyph** (round error / triangle warning), not
colour alone (§3, WCAG 1.4.1); the parse error is a **polite `role="status"`**, not a
per-keystroke assertive alert (APG Alert / WCAG 2.2.4); and the message is linked to its
input via **`aria-describedby`** (GOV.UK error-message). Unknown-field copy clarified to
"…— not a column in this dataset." Resolution recorded in `architecture/10` §5.
- **Manual/visual pass** run via a headless-Chrome (Playwright) walk-through of the live
builder — filter shelf (predicate + `is between`), field↔expression toggle, expression
error/unknown-field glyphs (filter _and_ calc inputs), calculated field, data-preview
table with type chips, smart-default chart. All surfaces render as intended.
- **Bug found + fixed (mid-edit preview resilience).** `filterTransformObject` /
`calculateTransformObject` emitted **any non-empty** expression — including a half-typed,
unparseable one — so the preview blanked with a raw render error while the user typed.
They now **drop a syntactically-invalid expression** like an empty/incomplete entry
(guarded by core `validateExpression`), matching `buildTransforms`' own "a config
mid-edit still renders" contract; the inline feedback still flags the typo. Pure core,
tested (+2 cases). Visually confirmed: an invalid filter/calc now keeps the last-good
chart instead of breaking the preview.
- **Verified:** `typecheck` + `test` (741 passing) + `eslint` clean.
- **Still open (copy judgment, user's call):** the preview's catch-all
`"Couldn't render this chart: {raw Vega message}"` puts a diagnostic in the headline
(arch 10 says diagnostics go in a disclosure) — correct in the editor, debatable in the
builder; and the builder's terse `"Dataset «X» not found."` drops the next-step the
contract mandates (near-unreachable in the builder). Both noted, not changed.
- **2026-06-11** — **1C + 1D + 1E shipped (the Data section).**
- A new **Data section** at the top of the builder's left pane — "here are your rows;
shape them, then encode them" — emits the spec's top-level `transform` array.
- **1C · Filters** — a guarded **field + operator + value** predicate shelf. Operators
narrow by field type (`validFilterOps`): a measure/temporal field offers ordering
(`< ≤ > ≥`) + `is between`; a category offers `is` / `is not` / `is one of`. Values
coerce by type (quantitative → number; others → string, so ISO dates sort right).
`notEqual` emits a `{ not: { …equal } }` wrapper. A reversible **expression** power-mode
takes a raw `datum.…` predicate. Incomplete filters are skipped so the preview keeps
rendering. Multiple filters AND together. Pure core (`buildTransforms`,
`validFilterOps`, `filterOpArity`), tested.
- **1C · Calculated fields** — `{ calculate, as }` derived columns. A named field appears
in the channel dropdowns via `effectiveColumns` (defaults Quantitative); emitted
**before** filters (a row-wise calculate is order-independent, so calc-first is
equivalent and lets filters reference derived fields). Removing/renaming a referenced
field clears the dangling channel (`pruneEncodings`, in the store on calc edit/remove).
- **1E · Expression validation** — new pure core `expr-validate.ts` using Vega's own
`parseExpression` (already in the `vega` chunk, so ~zero bundle cost): inline syntax
errors on both expression inputs, plus a soft **unknown-field** warning when a
`datum.<field>` reference doesn't match a column (`referencedFields` walks the AST).
Field discoverability is served by **dataset-derived placeholder examples** (e.g.
`datum.revenue * 2`); a full Monaco-style completion popup is **deferred** (a bare
`<input>` doesn't warrant it — noted, not built).
- **1D · Data preview** — a collapsible, read-only first-N-rows table with a per-column
**type chip** in each header, to sanity-check inferred types before building (reuses
core `tabularRows`). Default collapsed; the scroll region is keyboard-reachable
(`tabIndex=0` + labelled group — avoids the Datasets-manager a11y gap).
- **Spec §06** gained a "Data (filters, calculated fields, preview)" section; the Layout
and Output blocks cross-reference it.
- **Council not yet run** on the new error/disclosure copy (the soft auto-fire surface:
expression-error + unknown-field copy, the preview disclosure). Conventions were matched
to the existing warnings region (arch 10 §5) and `SettingsPopover` disclosure; flag for a
council pass on review if desired.
- **Expression reference:** a contextual link to the Vega expression-language docs is
shown when an expression input is in play (a calculated field, or a filter in
expression mode) — the place the user needs to know the available functions/operators.
- **Verified:** `typecheck` + `test` (full suite green; +103 new core/store cases, +4 modal
smoke tests) + `eslint` + `build` (PWA, 45 precache entries). **Owed:** a manual/visual
pass against the live builder (filter shelf, calc → channel, expr errors, preview
table) — tests don't cover what the surface looks/feels like.
- **Surfaced direction (since shipped):** the 1D preview shows **raw source** rows; a
transform-aware **data inspector** (input vs. resolved rows, à la vega-editor, in the
builder _and_ below the main Live Preview) was the wanted evolution — now shipped
(spec §04 → _Data Inspector_).
- **2026-06-10** — **Up next: 1C (filter + calculate transforms), paired with 1D (data preview).**
- **1B · Per-chart export** shipped: an **Export** disclosure in the Live Preview header
(distinct from the workspace Export) — **Copy spec** + **Download JSON** (`.vl.json`) of
the shown text, and **Download PNG/SVG** of the live chart. Image output rides a new
`RenderHandle.toImageURL(format, { scale, background })` (PNG via `view.toCanvas`
`blob:` URL; SVG via `view.toSVG``data:` URL), so no component touches the Vega
`view`. Filenames derive from the snippet name — filesystem-safe, script-preserving
(pure `core/chart-export.ts`, tested). Home: the **preview header**, not the plan's
"library row / editor toolbar" — image export needs the live view; the
disclosure-of-controls (not an ARIA menu) mirrors `SettingsPopover`. - **Export options (from first-round feedback):** PNG **Resolution** `1×/2×/3×` is a
multiplier of `devicePixelRatio`, so the default `1×` is Retina-crisp — the soft-1×
export was a dpr bug (raw `toImageURL` scaleFactor ignores dpr). **Background**
`Theme`(default)/`White`/`None` fixes transparent PNGs (the chart config is
transparent so the on-screen pane colour shows; export composites the chosen colour
under the PNG / adds an SVG `<rect>`). **Referenced data** `Inline`(default)/`Keep
refs` (shown only when the spec references saved datasets) inlines dataset values so
the exported spec renders standalone (`inlineReferencedDatasets`, tested). - Spec §08 gained a _Per-chart export_ section (with the options); §04 cross-references
it; `architecture/05` §2 records the handle's dpr-aware scale + background compositing.
- **1A · Actionable hints** shipped: one-click fixes on guidance warnings
(`BuilderWarning.fixes` + `applyWarningFix`), council-reviewed, with focus/announce a11y.
- **Builder UX/perf batch** (from dogfooding the Superstore dataset) shipped: near-fullscreen
`xlarge` modal tier; panes scroll internally (preview in its own viewport); backdrop-dismiss
guard (`dismissOnBackdrop: false`); render-timing diagnostics; **canvas** preview renderer
(SVG stays default elsewhere) + **canvas max-dimension guard** (`ChartTooLargeError` via a
headless probe); **data-aware default pre-population** (`smartDefaultEncodings`).
- Scope consolidated and **Tier-C target** set (this doc created); the research/Lyra forward
sequences are superseded by §4 here.
---
## 1. Where the builder is today (the floor — don't rebuild)
Tier B is shipped and tested (M4 done). The intelligence that decides _which chart and
why_ already exists in `src/core/chart-builder.ts`:
- **Smart default mark** from the (X, Y) field-type shape — not unconditionally Bar
(`defaultMark`).
- **Valid-type-only** field-type menus per column + **Size discipline** (Nominal /
Temporal / negative-extent columns are _blocked_ on Size, not merely warned)
(`validFieldTypes`, `isChannelTypeAllowed`).
- **Non-blocking guidance** — `builderWarnings` (6 rules: line/area needs both axes,
all-categorical, two-measures-want-scatter, area-split-many-series, crowded category
axis, negative-size guard).
- **Per-channel transforms** — Aggregate / Bin / `timeUnit`, chart-level Sort / Stack,
and a field-less "Count of records" measure.
- **Swap X/Y**, debounced live preview, validation gate (≥1 channel mapped).
Everything in §3 below is **backlog** — verified not yet built: `BuilderWarning` has no
`fix` field, there is no per-chart export, no top-level `transform`, no data-table
preview, no channels beyond X/Y/Color/Size, no styling/scale controls.
---
## 2. The target experience (Tier C, concretely)
Reading the brief's four words against the research:
| Brief word | What it means here | The levers (from §3) |
| ----------------------------------------- | ----------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
| **Rapid** | shortest path from "a dataset" to "a chart I'd keep" | smart defaults (have), intent front door, starter examples, field shelf |
| **Intuitive** | matches how people think ("I have fields; what shows my point?") | intent front door, field shelf, data preview, value-or-field channels |
| **Guidance & recommendations on the fly** | the app proposes and corrects, not just validates | **actionable hints**, **intent front door** (the defining Tier-C feature) |
| **Moderately capable** | covers the common data-shaping + a few high-value encodings; _not_ every knob | filter / calculate, a _small_ set of promoted controls — and a hard stop short of Lyra's everything-inspector |
The defining shift from B→C is the **intent-first front door**: a _"what do you want to
show?"_ entry (FT / Datawrapper intent categories) that recommends a mark + channel layout
from `intent × column-types`, instead of starting the user at a blank mark picker. It is
the feature the brief most directly asks for, and it is the one piece the M4 research
_deferred_. Choosing Tier C is choosing to pull it forward.
Tier C **extends** §06 — it does not replace the mark-first builder. The front door is an
on-ramp; the user can still ignore it and drive the channels directly, and can always drop
to Monaco. This preserves Astrolabe's core invariant: **the JSON spec is the source of
truth; the builder is a view that emits it** (the Lyra anti-lesson — never let the GUI
become the document).
---
## 3. The consolidated enhancement set
Organized into build phases by dependency and value. Each item carries its **source**,
**value/effort**, **code home**, and **spec impact**. Phases 13 are the committed Tier-C
scope; Phase 4 is explicitly _beyond_ "moderately capable" and gated on a later decision.
### Phase 1 — Guidance + I/O (no new interaction model) — _do first_
High value-to-effort, mostly pure-core + thin UI, no architectural change. These make the
_current_ builder dramatically better and de-risk the bigger phases.
**1A · Actionable hints**_done (2026-06-10)_
Turn advisory warnings into one-click fixes. Source: Lyra §3.1 (`Hints` carries an
`action`). Several existing warnings have an obvious remedy:
- _"draws one mark per row → "_ **[Aggregate as Sum]** (set the measure's `aggregate`)
- _"long labels → "_ **[Swap X/Y]** (the action already exists — just wire it)
- _"two measures usually read as a scatter → "_ **[Switch to Point]**
- _"area split into many series → "_ **[Stack]** or **[Remove colour]**
Implementation: extended `BuilderWarning` with `fixes?: BuilderWarningFix[]` (`{ label, apply }`, pure,
unit-tested in `chart-builder.test.ts`); the modal renders each as a ghost button wired to a
new `applyWarningFix` store action. Wired fixes: **[Aggregate as Sum]** + **[Swap X/Y]**
(one-mark-per-row), **[Swap X/Y]** (high-cardinality axis), **[Switch to Point]** (two
measures), **[Stack]** + **[Remove colour]** (area split — and a stacked area is no longer
flagged, so [Stack] resolves it). Council run (Carbon Actionable notification + APG Alert):
ghost buttons, remedy-in-button, polite "Applied: …" announcement, focus moved off the
removed button — resolution recorded in `architecture/10` §5. §06 "Guidance" amended to
document the one-click fixes.
**1B · Per-chart export**_done (2026-06-10)_
Today export was workspace-backup only (§08); there was **no way to get one chart out**.
Source: Lyra §3.8. Shipped as an **Export disclosure in the Live Preview header**:
- **Copy spec** (clipboard) + **Download `.vl.json`** of the currently-shown text.
- **Download PNG / SVG** of the live chart via a new `RenderHandle.toImageURL` wrapping
`view.toImageURL` (PNG at 2×`blob:` URL, revoked after download; SVG → `data:` URL),
so the embedding boundary holds — no component touches the raw view.
- Filenames from the snippet name, filesystem-safe and script-preserving (pure
`core/chart-export.ts`, tested). Standalone HTML left out (the deferred optional).
Home decision: the **preview header**, not the plan's original "library row / editor
toolbar" suggestion — the image formats need the live rendered view, and "export this
chart" reads best beside the chart. The widget is a disclosure-of-action-buttons (not an
ARIA menu), mirroring `SettingsPopover` and sharing its single-open registry. _Spec impact:
new §08 "Per-chart export" section; §04 cross-reference; `architecture/05` §2 handle note._
**1C · Filter (+ Calculate) dataset transforms**_done (2026-06-11; see status log)_
The transform layer the builder doesn't touch: top-level `transform: []`. Source: Lyra
§3.12. The builder _already tells users to filter_ in three warnings
(`chart-builder.ts:475,476,511`) while offering no way to do it.
- **Filter** _(highest)_ — a guarded **field + operator + value** predicate shelf
(Voyager-style, no expression needed for the common case); power form is a raw `datum.…`
expression validated with Vega's `parseExpr` (see 1E). VL applies top-level transforms
_before_ encoding aggregation, so "filter raw rows, then aggregate" is the natural
default; filtering on an aggregated value (HAVING) is the advanced case — defer.
- **Calculate / derived field** _(second)_`transform: [{calculate, as}]`; the new field
then appears in column dropdowns like any other.
- **Lookup / join a second dataset** — larger data-model change (one dataset per snippet
today) → **defer to Phase 4**.
Home: a new **"Data" section in the builder's left pane, above the channels** (`here are
your rows [+ Filter] [+ Calculate] → now encode them`), paired with 1D. _Spec impact: new
§06 "Data / transforms" subsection — this is genuinely new behaviour, write it._
**1D · Data-table preview**_done (2026-06-11; see status log)_
Neither the builder nor the Datasets manager ever shows the **actual rows**. Source: Lyra
§3.2. A compact, **read-only** first-N-rows grid with a **per-column type chip** in each
header lets users sanity-check inferred types _before_ building — exactly when inference is
most likely to surprise. Type + cardinality + extent already come from `profile.ts`; we
only need the row sample. Homes: a collapsible "Data" strip in the builder's left pane
(under the dataset name) and/or the Datasets manager. Keep it read-only (editing data is
out of scope). _Spec impact: §06 + §05 (Datasets) additions._
**1E · Inline expression validation + field autocomplete**_done (2026-06-11; completion popup deferred)_
When 1C's expression mode lands, validate the Vega/VL expression string with the library's
own `parseExpr` (Lyra §3.6) and surface errors inline; autocomplete the dataset's own
column names (we have the schema from `profile.ts`) (Lyra §3.10). Record the `parseExpr`
technique in `architecture/08`. _Spec impact: folded into 1C._
### Phase 2 — Interaction substrate (enables Tier C)
Two changes to _how the user touches fields_. They have standalone value but their main job
is to be the substrate Phase 3 (and any future added channels) stands on — sequence them
here so Tier C lands cleanly.
**2A · Value-or-field channels (the Property model)**_capability gain; medium_
Source: Lyra §3.3 (`Property.tsx` — one droppable control that is _either_ a literal value
_or_ a bound field). Today a channel is field-only. Let a channel also hold a **constant
`value`** (fixed colour / size) with one consistent control and a chip showing the binding
kind. VL encodes exactly this (`field` vs `value` vs `datum`). Generalizes cleanly to any
channel we add later. _Spec impact: §06 "Encoding channels" — a channel may carry a
constant._
**2B · Field shelf + in-place type cycling**_the largest interaction shift; the Tier-C/facet substrate_
Source: Lyra §3.4 (drop-zones) + Voyager (field list with type chips) + Lyra §3.5
(`FieldType` — the type icon _is_ the control, click cycles N→O→Q→T within the valid set).
Flip from **channel-first** ("pick a channel, then its column") to **field-first** ("here
are your columns — drag/click onto channels"), matching how people think. This is the
natural way to assign many fields across many channels, so it is the **interaction
substrate for Tier C and faceting**, not a standalone task. _Spec impact: §06 layout
revision (field shelf alongside the channel rows)._
### Phase 3 — Tier C (the intent-first front door) — _the defining feature of this push_
The grand idea, stated once: **the builder is the app's rapid, intuitive on-ramp; Monaco
is the expert surface; the JSON spec stays the document.** Phase 3 is where that becomes
true — 3A gives the builder an intent-first opening screen, 3D makes that screen the
app's guided creation flow (not a feature hidden in a modal), and 3B/3C seed and re-enter
it.
**3A · Intent-first front door**_the B→C step_
Source: research §5/§8 Tier C (FT Visual Vocabulary + Datawrapper intent taxonomy). A
_"what do you want to show?"_ entry mapping **intent × column types → recommended mark +
channel layout**:
| Intent (FT / Datawrapper) | Our expression (within 5 marks / our channels) |
| -------------------------- | ------------------------------------------------------------------- |
| **Magnitude / Comparison** | Bar (x=N, y=Q; horizontal for long labels) |
| **Ranking** | Bar, sorted by value |
| **Change over time** | Line (x=T, y=Q; color=N for series) |
| **Correlation** | Point (x=Q, y=Q); Circle + size=Q for a 3rd measure |
| **Distribution** | Bar of binned counts (histogram) — uses Bin |
| **Part-to-whole** | Stacked / 100% bar (uses Stack); _true pie needs `theta` → Phase 4_ |
| **Deviation** | diverging signed Bar |
Honest coverage gaps stay honest (Spatial / Flow excluded; Part-to-whole partial until
`theta`). The front door is an **on-ramp, not a gate** — it pre-populates the mark-first
builder, which the user can then adjust or ignore. Munzner's typology and Wilke's directory
become seatable council sources at this point (research §2). Built **on the 2B field
shelf**. **Run the front-door copy + flow through `/council`.** _Spec impact: substantial
§06 amendment — a new "Intent" front-door subsection; the M4 spec note explicitly flagged
this as the deferred tier, so this is the planned amendment, not drift._
**3B · Starter examples gallery**_cheap; pairs with 3A_
Source: Lyra §3.7. A small set of **curated starter snippets**, one per covered FT intent
(Magnitude/Bar, Change-over-time/Line, Correlation/Point, Distribution/histogram,
Part-to-whole/stacked). Improves first-run, doubles as living documentation of what the app
does well. Natural home: the snippet library. **Author them in the builder dialect so they
hydrate via 3C.** _Spec impact: §02 (library seed content)._
**3C · Open in builder (strict spec hydration)**_decided 2026-06-11; pairs with 3B_
Reverse the assembler: a pure `parseChartSpec(spec) → BuilderConfig | null` so an existing
snippet can re-enter the builder populated. **Strict policy only** — a snippet-list button
("Open in builder") enabled exactly when the spec is **losslessly** representable in the
builder's dialect; anything richer stays Monaco-only. Losslessness is checked not by
enumerating unsupported features but by **re-assembling the parsed config and
deep-comparing against the original** (ignoring key order, `$schema`, and the injected
`tooltip: true`) — exact, ~20 lines, and it stays correct automatically as the dialect
grows (every Phase-4 addition widens what hydrates for free). No lossy or
residue-preserving mode: the moment the builder can overwrite a richer spec, the GUI starts
competing with the JSON as the document (the Lyra round-trip trap — §5).
Value: converts the builder from **create-only to create-and-revise** for its own output,
and makes the 3B starters openable as builder seeds. Known fiddly bits (all mechanical):
inverse of `escapeVegaField`, filter-value un-coercion back to the `BuilderFilter` string
shape, `sort: "-y"``{sort: 'descending'}`. Round-trip property tests
(`parse(build(config))``config`) are the natural core coverage. Per-row check in the
library is cheap but memoize it. **Decisions at build time:** save semantics on an opened
snippet (update-in-place via a builder "edit" mode vs. always-create-new — today the
builder only creates) and the disabled-button affordance (disabled-with-reason vs. hidden —
park in `ux-second-pass.md` if non-obvious). _Spec impact: §02 (library row action) + §06
(hydration + the strict gate)._
**3D · Entry points & discoverability**_done 2026-06-12 (council-reviewed; see status log); 3A opens onto it next_
Today the builder has **one entry, two levels deep**: Datasets modal → per-dataset "Build
Chart" (`DatasetsModal.tsx`) — the data model's shape ("charts come from datasets"), not
the user's intent ("I want to make a chart"). Meanwhile the library's pinned primary
action, "Create New Snippet", drops straight into blank Monaco — the **expert** path is
the default, and the audience the builder serves may never find it. Council ruling
(recorded here; contract updates at build time):
- **NN/g #6 (recognition over recall):** the build-a-chart action must be visible where
the intent forms — the library's creation surface, not recalled as a dataset-row action.
- **NN/g #7 (flexibility/efficiency):** the guided path is what novices see; raw JSON is
the expert accelerator — visible, one click, never hidden. Builder-forward, Monaco
intact.
- **Carbon empty-states:** one primary action per surface ("pick the most important"; a
tertiary button for the secondary CTA); a no-data state explains what the space will
hold, offers one primary next step, and never dead-ends.
The decided shape:
1. **Fork the library's creation surface** — primary **Build Chart** (opens the builder),
ghost/tertiary **New JSON snippet** (today's instant create, unchanged). Two plain
buttons with clear hierarchy — no split/menu-button widget (two static choices don't
earn an ARIA menu).
2. **Dataset picker inside the builder** (header select, default = most recently
modified), so the builder opens without a preselected dataset. Build-time decision
taken: switching with an **untouched** opening config re-derives fresh smart defaults;
switching a **built-on** config rebases it (`rebaseBuilderConfig` — chart-level intent
kept, bindings to missing columns shed; a same-schema dataset keeps everything).
Useful beyond 3D (switch data without leaving the builder).
3. **No-datasets empty state** in the builder, per the Carbon no-data pattern: what the
builder does + one primary action ("Add a dataset" → Datasets modal). No dead end.
4. **Onboarding tile** — a data-first path ("import your data → build a chart") beside the
existing examples gallery.
5. **Keep the dataset-row "Build Chart"** — contextual shortcut, pre-picks the dataset; it
just stops being the only door.
**Composition with 3A (the point of the sequencing):** the Build-Chart button lands on the
intent front door — "what do you want to show, with which data?" _is_ the builder's
opening screen. 3D without 3A opens onto the mark-first builder (fine, interim); 3A
without 3D is a front door nobody finds. Discoverability is a prerequisite for 3A's value,
so: **3D with or just before 3A.** _Spec impact: §02 (library creation actions +
onboarding) + §06 (dataset picker, empty state)._
### Builder UX & perf — in-flight fixes (2026-06-10, from dogfooding the Superstore dataset)
Pre-existing builder rough edges surfaced while testing on a 10k-row / ~24-col dataset.
Fixed in this batch (not part of 1A3B, but the same surface):
- **Modal is a near-fullscreen work surface** — new `xlarge` shell tier (`ModalShell`,
`min(1800px, 96vw) × min(1100px, 92vh)`); the Chart Builder no longer wastes screen.
- **Panes scroll internally, modal keeps its shape** — the `.builder` grid fills the body
(`grid-template-rows: minmax(0,1fr)`), the config pane and the **preview** each scroll in
their own viewport, so a tall one-mark-per-row chart scrolls inside the preview instead of
pushing Create/Cancel below the fold.
- **Backdrop click no longer discards in-progress work** — `dismissOnBackdrop: false` on the
builder (registry flag); Escape and × still close.
- **Data-aware default pre-population** — the builder no longer blindly takes the first two
columns (which opened Superstore on a 9994-bar degenerate chart). When the dataset is
profiled, `defaultBuilderConfig` picks a "safest bet": a low-cardinality category vs a
count of records (tidy bar), else a time series of the first measure, else a scatter — each
guaranteed to render. Falls back to positional when there are no stats. Pure + tested. This
composes with (isn't replaced by) the future intent-first front door — the builder always
needs a sane opening state.
- **Render-timing diagnostics** — `BuilderPreview` logs `parse · prepare · destroy · embed ·
paint · total` (+ mark, row count) to the console (dev always; prod only when slow). The
**paint** phase (a double-rAF after `embed()`) captures the real freeze.
**Perf finding — confirmed and fixed.** Diagnostics on the Superstore dataset:
`embed 237ms · paint 6458ms` for the default one-bar-per-row chart (9994 rows), vs
`paint 6ms` once grouped to a few categories. The freeze was entirely **SVG layout/paint**
(one DOM node per mark), not chart compilation. **Fix shipped:** the builder preview now
renders with **canvas** (`renderSpec(…, { renderer: 'canvas' })`); SVG stays the default for
the editor's LivePreview and for image export. Contract divergence recorded in
`architecture/05` §2.
**Canvas max-dimension guard (measured, not guessed).** Canvas (unlike SVG) has a hard
max side (~32k px), so a chart that resolves taller than that fails to allocate (the
broken-image icon). The cause is **physical render size, not cardinality** — a vertical bar
with thousands of _X_ bands renders fine (width is container-bounded); only an unbounded
band axis (e.g. a horizontal bar's _Y_) overflows. So `renderSpec` now runs a **headless
(`'none'`) layout probe** for canvas charts, reads the chart's resolved **height**, and
throws `ChartTooLargeError(heightPx, limitPx)` when it exceeds `MAX_CANVAS_PX ÷ dpr`. The
builder catches it and shows the real numbers ("would be ~200,000px tall — larger than the
browser can draw on a canvas (~16,383px max here); aggregate or filter"). The earlier
band-count proxy (`previewBandCount`/`MAX_PREVIEW_BANDS`) was removed — readability
(cardinality) stays a `builderWarnings` concern; the render-size limit is now measured at
its true cause.
### Phase 4 — Beyond "moderately capable" (gated — decide later)
These exceed the stated ceiling. List them so they have a home, but **do not commit them in
this push** — revisit once Phases 13 land and we see real usage. Each must clear the
guardrail: _promote a control only when it is **both common AND awkward in JSON**._
- **More channels** — `theta` (unlocks pie/donut → _true_ part-to-whole), `opacity`,
`shape`. (`theta` is the most defensible — it closes a real coverage gap.) Ride on 2A/2B.
- **Faceting (Row / Column → small multiples)** — research §8 B8. The clean way to compare
many categories. Design crux: VL facets default to **shared scales** (keep that default);
expose an "independent axes" toggle (`resolve.scale`) only as advanced. **Verify against
the preview's `"container"` fit modes** (per-cell sizing on facets is finicky).
- **Light styling / scale controls** — colour-scheme picker (categorical / sequential /
diverging), measure-axis `zero` / `log` toggle, custom axis title, legend title / hide.
Implement as **auto-derived override panels** that start empty (inheriting VL defaults)
and emit JSON **only when touched**; clearing a channel **drops its overrides** (Lyra
§3.9 `cleanupUnused` — no orphaned `scale`/`axis` in the output spec).
- **Builder undo/redo / "reset to smart defaults"** — Lyra §3.11. Low priority (the modal
is short-lived; the smart default already gives a sane start).
- **Lookup / join a second dataset** — Lyra §3.12; data-model change (multi-dataset
snippets). Larger, separate effort.
- ~~**Transform-aware data inspector**~~ ✅ shipped — an Input | Resolved data panel below
the **main** Live Preview and the builder preview (vega-editor's "Data Viewer", a
debugging aid for any snippet), reading runtime rows through the `RenderHandle`. Spec §04
→ _Data Inspector_; arch 05 → "the data inspector rides the boundary".
---
## 4. Recommended build order
```
Phase 1 1A actionable hints ✓ done
1B per-chart export ✓ done
1C filter (+ calculate) ✓ done
1D data preview ✓ done
1E expr-validate ✓ done (syntax + unknown-field; completion popup deferred)
Phase 2 2A value-or-field channels (Property model) ✓ done
2B field shelf + in-place type cycling ✓ done (field-first + on-chart shelves)
Phase 3 3D entry points & discoverability ✓ done (2026-06-12)
3A intent-first front door (Tier C) ✓ done (2026-06-13); persistent strip
3C open in builder (strict hydration) ✓ done (2026-06-18); edit-in-place
3B starter examples ← next; now hydrates via 3C
Marks +rect (Heatmap) ✓ done (2026-06-13)
Phase 4 (gated) theta/facets/styling-overrides/undo/lookup — decide after Phase 3
Also shipped (builder UX/perf, from dogfooding): near-fullscreen modal, internal-scroll
panes, backdrop-dismiss guard, render diagnostics, canvas preview + canvas-size guard,
data-aware default pre-population. See "Builder UX & perf" above.
```
Rationale for the order: Phase 1 is the cheapest large quality jump and needs no new
interaction model, so it ships value while the bigger design settles. Phase 2 is pure
substrate — low _user-visible_ payoff alone, but Phase 3 is much cleaner on top of it than
bolted onto the channel-first UI. Phase 3 delivers the brief's headline ("recommendations
on the fly") — 3D first, because a front door nobody finds delivers nothing: the entry
points make the builder the app's guided creation flow, then 3A gives that flow its
intent-first opening. Phase 4 is deliberately deferred to protect the "moderately capable"
ceiling.
---
## 5. Constraints & anti-scope (the ceiling)
What "moderately capable" rules **out** — load-bearing, from `lyra-review.md` §2.1/§4:
- **No everything-inspector.** Lyra surfaces ~50 direct controls per primitive because the
GUI _was_ its document. We split the work on purpose: a small **guarded** builder + a
first-class **Monaco** editor for the long tail. Styling/scale/axis breadth for its own
sake belongs in Monaco, not the builder.
- **The promotion test:** a control enters the builder only when it is **both common AND
awkward in JSON**. Otherwise it stays in Monaco.
- **JSON stays the source of truth.** The builder _emits_ spec; it is never the document
(the Lyra one-way-export trap that forbids round-trips).
- **No interaction-by-demonstration, no direct-manipulation canvas, no general
data-pipeline editor.** If we ever add interactivity, expose VL `params`/selections as a
small guarded action — never port Lyra's signal generator.
- **Take ideas from the reference clones, read no code into the repo** (`AGENTS.md`: no
shared lib; patterns adapted, not imported).
---
## 6. Cross-cutting notes
- **Spec deltas:** 1A (minor §06), 1B (§08 + §02/§03), 1C (new §06 transforms subsection),
1D (§06 + §05), 2A/2B (§06 layout), 3A (substantial §06 intent front-door amendment), 3B
(§02). Per _"spec follows code now"_: build the decision, then amend the spec to match —
don't let code and §06 drift.
- **Council:** auto-fires on guidance copy and new interactive-widget keyboard/focus work —
so 1A (hint affordance + copy) and 3A (front-door flow + copy) both go through `/council`
before committing. It advises; architecture 09/10 decide.
- **Verification:** pure rules get `chart-builder.test.ts` cases; every UI/affordance change
gets a manual pass against the _live_ builder — a green build proves nothing about what
the user sees (`AGENTS.md`; `docs/manual-verification.md`). Items 1A and 1C are mostly
`src/core/`, squarely the "core-first, tested hardest" rule.
- **Source of truth going forward:** this doc. `chart-builder-research.md` §8 and
`lyra-review.md` §5 remain the _research record_; their forward sequences are superseded
by §4 here.
-278
View File
@@ -1,278 +0,0 @@
# Chart Builder — Design Research (M4)
> **Status:** research complete; informs the M4 build (spec §06).
> **Decision:** build **Tier B — "smart + guarded"** (mark-first, still §06-shaped).
> **Why this doc exists:** the Chart Builder is the point where Astrolabe stops being
> a pass-through JSON editor and starts making chart-shaped suggestions/defaults.
> "Which chart, and why" becomes a decision the app owns, so we researched it
> deliberately before building. This is the record of what we studied and what we
> took from each source — the citations behind every default and guardrail in
> `src/core/chart-builder.ts`.
---
## 1. Scope of the builder (the constraint everything maps into)
Spec §06: compose a Vega-Lite chart from a dataset with **one mark**
{Bar, Line, Point, Area, Circle}, mapping columns to **four channels** (X, Y, Color,
Size), each carrying a **field type** ∈ {Quantitative, Nominal, Ordinal, Temporal},
plus optional pixel width/height → a complete spec saved as a snippet that
references the dataset by name. Column types are inferred upstream as
`number | string | date | boolean` (`src/core/type-inference.ts`).
No transforms (no binning, aggregation, stacking, regression), no second axis, no
geo. That narrow surface is the lens through which every source below was read:
"what does this canon tell us to do **within Bar/Line/Point/Area/Circle and
X/Y/Color/Size?**"
## 2. The sources
Two kinds: **formal CS** (how recommendation engines actually rank charts) and
**chart-choice canon** (how practitioners pick). They were chosen for being
**cloneable/grep-able offline** (the council's working model) and authoritative for
"which chart," which our other seats (Carbon/GOV.UK/APG/NN/g) don't cover.
| Source | What it is | Local path |
| ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------- |
| **Draco** (uwdata) | Visualization design knowledge as ASP constraints — the formal "what makes a good chart," with hard (validity) + soft (preference) rules and weights, some learned from human perception experiments (Kim 2018, Saket 2018). | `reference/draco` |
| **Voyager** (vega) | UW IDL's recommendation/exploration tool on CompassQL — the _interaction_ model (field shelves, auto-add, type chips) and effectiveness-ranked encoding suggestions. | `reference/voyager` |
| **FT Visual Vocabulary** (Financial Times) | A poster/taxonomy mapping _what you want to show_ (9 data-relationship categories) → chart types. **Seated** in the council. | `reference/chart-doctor/visual-vocabulary/` |
| **Datawrapper** | Practitioner chart-choice in plain language; intent-first ("the chart's main statement becomes a compass"). **Seated** (distilled). | `reference/principles/datawrapper.md` |
**Theoretical basis, not seated (deliberately):** **Munzner**, _Visualization Analysis
and Design_ (marks & channels; the channel-effectiveness rankings — magnitude:
position → length → angle → area …; identity: spatial region → hue → shape;
expressiveness & effectiveness principles) and **Wilke**, _Fundamentals of Data
Visualization_ (`clauswilke/dataviz`; example directory by intent + "ugly/bad/wrong"
pedagogy). They are the _why_ beneath Draco and Voyager — Draco's soft weights are an
operationalization of exactly these Mackinlay/APT/Munzner effectiveness rankings — but
they restate the same rules the seated sources already give us, so seating them would
add overlap, not coverage. Cited here as grounding; revisit if we ever build the
intent-first "Tier C" front door, where Munzner's typology and Wilke's directory
would earn their place.
## 3. What we take from each source
### From Draco — validity guardrails + a preference ranking (the rigorous core)
Draco models a chart as ASP facts and rejects/ranks them with **hard** (∞ cost) and
**soft** (weighted) constraints (`asp/optimize.lp`). We can't ship an ASP solver in a
browser, but the rules are a lookup table. The portable subset:
- **Hard validity (block in the UI):** `reference/draco/asp/hard.lp`
- Quantitative on a string/boolean column — illegal (`:6`). Temporal only on a
datetime column (`:7`).
- **Size encoding a Nominal field — illegal** ("size implies order; nominal is
misleading", `:53`). Size cannot encode **negative** values (`:56`). Size only on
point/text marks (`:110`).
- Bar/Area must include a **zero baseline** on the measure axis (`:103-104`).
- Bar needs a categorical axis — both x and y continuous on a bar is malformed
(`:97`); Line/Area need **both** x and y, and not both discrete (`:91,:94`).
- Same field on x and y — illegal (`:122`). >20 categorical colors — illegal (`:172`).
- **Soft preference (the weights, `asp/weights.lp` + `asp/soft.lp`):**
- Channel-by-type appropriateness (lower = better): continuous data is free on x/y,
costs to put on color (10) or size (1); nominal cheapest on y then x then color;
ordered data expensive on size. → **fill X/Y before Color/Size.**
- Mark by data shape: continuous×continuous → **point** (line/area heavily
penalized); continuous×discrete aggregated → **bar**; discrete×discrete → point/rect.
- Prefer time on x (`temporal_y`, `:147`); never type a number as nominal
(`number_nominal`, weight 10); the loudest nudge is an all-discrete chart with no
measure (`only_discrete`, weight 30).
The hand-tuned `weights.lp` is the portable "common-sense" set; the learned
`weights_learned.lp` corroborates direction, not magnitude.
### From Voyager — the interaction model + the valid-type table
- **`getValidTypes` (`src/components/data-pane/field-list.tsx:140-155`) — adopted
almost verbatim:** number→{quantitative, nominal}, integer→{quantitative, nominal},
datetime→{temporal}, string→{nominal}, boolean→{nominal}. The type toggle shows only
when ≥2 valid types exist. (We extend slightly — see §4 — to also offer Ordinal,
which Voyager deliberately omits, `encoding.ts:131-134`.)
- **Auto-add / "auto" mark (`models/shelf/index.ts:72-81`):** Voyager lets a field be
added with `channel:'?'` and asks CompassQL to place it by `effectiveness`. The small
builder analogue is a **non-empty smart default** (`defaultBuilderConfig`) so the
preview is never blank.
- **Type chips + swap:** per-field type indicator with a click-to-change popover, and a
cheap x↔y swap (Voyager's `SPEC_FIELD_MOVE` is remove-both + re-add).
- **Out of scope (Voyager scope creep we reject):** wildcard shelves, the full Related
Views gallery, faceting (row/column), and embedding CompassQL/`compassql@0.20.2`
itself. We hand-roll a small decision table in `src/core/` instead of pulling the
engine.
### From FT Visual Vocabulary — the intent→chart taxonomy (and our coverage gaps)
`reference/chart-doctor/visual-vocabulary/README.md` (taxonomy is prose). Nine
categories; mapped to **our five marks**:
| FT category | What it shows | Our expression |
| -------------------- | --------------------------- | ----------------------------------------------------------------------------- |
| **Magnitude** | size comparisons | **Bar** (x=N, y=Q; horizontal x=Q, y=N for long labels) — primary |
| **Ranking** | position in an ordered list | **Bar, sorted** by value (the sort _is_ the feature) |
| **Change over Time** | trends | **Line** (x=T, y=Q; color=N for series); Bar/Area alternatives, single series |
| **Correlation** | relationship of 2+ measures | **Point** (x=Q, y=Q); **Circle/bubble** + size=Q for a third measure |
| **Deviation** | +/ from a reference | **Bar** with signed Q (diverging bar only) |
| **Distribution** | spread/frequency | weak: raw **Point** strip, or **Bar** of pre-binned counts (no bin transform) |
| **Part-to-whole** | component shares | **none well** — redirect to Magnitude/Bar; we can't show true proportions |
| **Spatial** | geography | **none** — exclude |
| **Flow** | movement between states | **none** — exclude |
**Coverage:** strong on Magnitude, Ranking, Change-over-Time, Correlation; partial on
Deviation/Distribution; none on Part-to-whole/Spatial/Flow. Honest gaps, not silent
degradation.
### From Datawrapper — plain-language rules + intent labels
`reference/principles/datawrapper.md`. Corroborates the same default-mark-by-intent
table (comparison→Bar, time→Line, correlation→Point/bubble) and supplies friendlier
intent words (Developments over time / Shares / Comparison / Correlation). Bindable
rules: bar is the safe default; bar over column on small screens; line for continuous
time, columns for a few points; circles are hard to compare precisely; size encodes a
quantity; area = single total (warn on multi-series).
## 4. The convergent rules — what all four agree on (high-confidence)
These are not a judgment call; the formal engines and the practitioner canon land on
the same place. They are the spec for `src/core/chart-builder.ts`:
1. **Column type → valid field types** (Voyager `getValidTypes`; Draco `hard.lp:6-7`):
`number`→{Quantitative (default), Ordinal, Nominal}; `date`→{Temporal only};
`string`→{Nominal (default), Ordinal}; `boolean`→{Nominal}. Never offer Q for
string/boolean, never Temporal for a non-date. (We add Ordinal where it's a defensible
user assertion of order; Voyager omits it for UX simplicity — our deliberate superset.)
2. **Default mark from the (X, Y) shape** (Draco mark-by-shape; Voyager effectiveness;
FT; Datawrapper): temporal × quantitative → **Line**; quantitative × quantitative →
**Point**; (nominal/ordinal) × quantitative → **Bar**; both-discrete → **Point**
(Bar/Line/Area are invalid with no continuous axis); single axis or unknown → Bar.
3. **Channel priority + Size discipline** (Draco `hard.lp:53,56,110` + non-positional
pref): fill X/Y before Color/Size; Color before Size. **Size is only valid for
Quantitative/Ordinal positive measures on Point/Circle marks** — disabled for Nominal,
Temporal, and negative data (not merely discouraged).
4. **Bar/Area zero-baseline; Line exempt** (Draco `hard.lp:103-104`; FT; ONS/Vox sources
FT links). We expose no axis-truncation control, so Vega-Lite's own defaults already
give zero-baseline bars and free-baseline lines — the rule is satisfied by _not adding_
an override, nothing to emit.
5. **Chart-choice polish** (FT; Datawrapper): sort bars when ranking; horizontal bar for
long category labels; Size encodes a quantity, Color a category; Area is for a single
series (warn against color-splitting into many).
## 5. The decision: Tier B — "smart + guarded"
Three tiers were on the table. **Tier B** was chosen (2026-06-05).
- **Tier A — spec-literal:** Bar default, four channel dropdowns, type override, smart
pre-population. Matches §06 verbatim but uses almost none of the research; stays a
"dumb" composer.
- **Tier B — smart + guarded (chosen):** Tier A **+** default _mark_ from the (X, Y) type
shape (not always Bar) **+** valid-type-only menus **+** inline non-blocking warnings
from the Draco rules **+** swap-X/Y **+** Size disabled for Nominal/Temporal/negative.
Still mark-first and §06-shaped, but genuinely intelligent. Requires a small §06
amendment (documented in the spec).
- **Tier C — intent-first aid:** Tier B **+** a "what do you want to show?" front door
(FT/Datawrapper intents → recommended mark + channel layout from intent × column
types). Highest "which chart & why" value; biggest UI; clearly extends §06. Deferred —
if revisited, this is where Munzner's typology and Wilke's directory would be seated.
## 6. How it maps to implementation
The convergent rules become pure functions in `src/core/chart-builder.ts`
(tested in `chart-builder.test.ts`), consumed by the builder store/modal:
- `validFieldTypes(columnType)` → the type menu (rule 1); `defaultFieldType` = its head.
- `defaultMark(xType, yType)` → smart default mark (rule 2); used by
`defaultBuilderConfig`.
- `isChannelTypeAllowed(channel, type)` → Size discipline gate (rule 3).
- `builderWarnings(config)` → inline non-blocking hints (rules 35: line/area need both
axes, area + many series, two measures better as a scatter, etc.).
- `buildChartSpec` / `buildSnippetSpecText` → assemble the final spec; zero-baseline is
Vega-Lite-default (rule 4), so nothing is emitted for it.
## 7. Anti-recommendations (what a naive builder would happily produce, and we don't)
The highest-value guardrails — encodings a naive UI emits that the canon rejects:
- A categorical column on **Size** (Draco hard `:53`) — blocked, not warned.
- A **truncated-axis bar** — prevented by never exposing an axis override (Draco `:103`).
- A high-cardinality category on **Color** → unreadable legend (soft w=10; >20 hard).
- A **Line between two raw measures** instead of a scatter (Draco soft w=20) — warned.
- An **all-categorical chart with no measure** (Draco soft w=30, the loudest) — warned.
- A **number typed Nominal** (Draco soft w=10) — discouraged via default = Quantitative.
## 8. Future enhancements (backlog)
The Tier-B build is the floor, not the ceiling. The enhancements below were surfaced by
the research. Status as of 2026-06-06.
> **Forward plan moved (2026-06-10):** the _prioritized, sequenced_ enhancement plan now
> lives in [`chart-builder-enhancement-scope.md`](./chart-builder-enhancement-scope.md),
> which merges this backlog with the [`lyra-review.md`](./lyra-review.md) §5 ideas and sets
> the **Tier-C** target. This section remains the research _record_ (the citations behind
> each item); consult the scope doc for _what to build next and in what order_.
**A · Cheap wins inside the current 5-mark / 4-channel scope**
- **A1 · Sort-on-ranking** _(done)_ — chart-level Sort control (Asc/Desc/None) sorts the
categorical axis by the measure (FT: "bars display ranks much more easily when sorted").
Appears only for a category-vs-measure pair.
- **A2 · Bar orientation** _(partly done)_ — the **Swap X/Y** control is the manual path to
a horizontal bar, and the crowded-axis hint (A3, below) now auto-suggests it for the
un-aggregated case. A general "long labels → go horizontal" suggestion on _any_ vertical
bar is still deferred (needs a label-length / cardinality signal); a blanket warning was
rejected — it would fire on every ordinary vertical bar.
- **A3 · Crowded-axis & high-cardinality warnings** _(done)_
- _Done:_ the **un-aggregated crowded axis** — a bar/line/area with a category axis and a
**raw** measure draws one mark (and one label) per row, so over `CROWDED_CATEGORY_ROWS`
(30) rows it warns and points to aggregating, or a horizontal bar. Row-count-based:
detects exactly the mark-count == row-count case (URL/non-tabular → `rowCount` null → skipped).
- _Done (via the profiling extension below):_ an **aggregated** axis that still has many
distinct **categories** (`CROWDED_CATEGORY_DISTINCT` = 30) and an unreadable **Color
legend** (`CROWDED_LEGEND_DISTINCT` = 12, discrete colour only) now warn from per-column
cardinality. (Number-typed-Nominal remains a possible future nudge; not yet flagged.)
- **A4 · Data-aware Size guard** _(done)_ — Size mapped to a field whose profiled numeric
**extent** goes negative warns (Draco `hard.lp:56`; size implies positive magnitude),
on top of the type-level Size discipline.
> **Done — profiling extension (the A3 / A4 enabler).** `profile.ts` now derives, in the
> **same sample pass** that feeds `inferColumnType`, a per-column **capped distinct count**
> (`DISTINCT_CAP` = 50, with a `distinctCapped` overflow flag) and a **numeric extent**
> (min/max, numeric columns only), surfaced on `DatasetProfile.columnStats` and the `Dataset`
> record. `builderWarnings(config, rowCount, columns)` consumes them for the legend/axis
> crowding hints and the negative-value Size guard. **Caveats handled:** URL / non-tabular
> data has no rows → `columnStats` is `[]` and the dependent hints skip; datasets stored
> before the field default to `[]` via `dataset-migrations` / import normalization (no forced
> re-profile on read — they pick up stats on next save). Thresholds live in `chart-builder.ts`
> constants alongside `CROWDED_CATEGORY_ROWS`.
**B · Transform-enabled coverage (new core capability + §06 extension)** _(done)_
- **B5 · Aggregation** _(done)_ — per-channel `sum` / `mean` / `median` / `min` / `max`, plus
a field-less "Count of records" measure (Voyager's `count(*)`). The priority item.
- **B6 · Binning** _(done)_`bin` on a quantitative field → true histograms (closes the
Distribution gap); mutually exclusive with aggregate on the same field.
- **B7 · Stacking** _(done)_`stack` (`zero` / `normalize`) for bar/area + a Color series →
part-to-whole (closes that gap; enables 100%-stacked).
- **Temporal granularity** _(done)_ — Vega-Lite `timeUnit` (Year / Quarter / Month / Week /
Day / Hour, plus combined units) on a Temporal field; defaults to None (raw).
- **B8 · Faceting (Row / Column → small multiples)** _(next increment, after A+B + UI land)_
— two more channels that multiply the chart into a trellis, the clean way to compare many
categories (Voyager has it; FT/Datawrapper recommend small multiples; we currently can't
express them). Still mark-first, so a Tier-B extension. **Axis alignment** is the design
crux: Vega-Lite facets default to **shared scales** (aligned axes) — keep that as the
default; expose an "independent axes" toggle (`resolve.scale`) only as an advanced option.
**Verify** faceting against the preview's `"container"` fit modes before trusting it
(per-cell sizing on facets is finicky). Sequenced as additive after the current build.
**C · Intent-first front door (Tier C)** _(deferred)_ — see §5. "What do you want to
show?" → recommend mark + channels from the FT/Datawrapper taxonomy × column types; where
Munzner + Wilke would be seated.
**D · Plumbing****D9** URL hash routing for the open builder (owned by **M6**, spec
§01E); **D10** a GOV.UK/NN-g copy pass over the guidance-hint wording (the M4 council
seating's residual one-off debt).
---
_Citations are to files under `/Users/oleh/code/reference/`. The seated chart-choice
canon (FT clone + Datawrapper distill) lives in the council roster
(`.claude/skills/council/SKILL.md`); Draco/Voyager are reference clones, not council
seats — they're engineering sources, not user-facing design authorities._
-391
View File
@@ -1,391 +0,0 @@
# Chart Theming — Enhancement Scope
> **Status:** scope consolidated 2026-06-12. Single forward-looking home for chart-theme
> work: separating the opinionated house style from the legibility minimum, a preview
> theme selector, config merge/extract, custom named themes, and fonts (shipped roster +
> user-loaded). Read against `src/core/vega-themes.ts`,
> `src/app/services/chart-renderer.ts`, and `docs/architecture/05` §3.
>
> **Goal (the brief):** "here's how you can easily transform your Vega-Lite charts to not
> look like stock Vega-Lite charts" — make the house style one option among several,
> let a user apply custom branding (colors **and fonts**) quickly, and keep every byte
> self-hosted and offline-capable.
---
## 1. Where we stand (the audit)
Everything opinionated lives in **one file**`src/core/vega-themes.ts` — injected as
vega-embed's `config` option at embed time (`chart-renderer.ts`). It is never baked into
the stored spec; pasting a snippet into the Vega editor renders stock. No CSS reaches
into the chart DOM. Exports (PNG/SVG) render through the same view, so they **carry the
theme**.
Merge precedence (verified in `vega-lite/src/compile/compile.ts`):
`mergeConfig(opt.config, spec.config)`**the spec's own `config` wins** over our
injected theme, property by property. A snippet can already opt out of any of it.
Exact diff vs. stock Vega-Lite (defaults read from `vega-parser/src/config.js`):
| Property | Astrolabe (light / dark) | Stock Vega-Lite |
| -------------------------- | --------------------------- | -------------------- |
| `background` | transparent | `white` |
| `font` | IBM Plex Sans stack | `sans-serif` |
| `title` | 16px / 600 / app text color | 13px / bold / black |
| `axis.domainColor` | `#c6c6c6` / `#525252` | `#888` |
| `axis.gridColor` | `#e0e0e0` / `#393939` | `#ddd` |
| `axis.gridDash` | `[2,2]` | solid |
| `axis.labelColor` | `#525252` / `#a8a8a8` | black |
| `axis.titleColor` | `#161616` / `#f4f4f4` | black |
| `axis.label/titleFontSize` | 11 / 12 | 10 / 11 |
| `axis.titleFontWeight` | 600 | bold (700) |
| `range.category` | Carbon data-viz 14-color | tableau10 (10-color) |
| `view.stroke` | transparent | `#ddd` plot border |
Untouched: everything else — notably the **default mark color stays Vega blue
`#4c78a8`**; the Carbon palette only kicks in once a color encoding exists.
The config splits into two layers with different standing:
- **Base (legibility/integration)** — required for charts to be readable on our panes at
all, dark mode especially: `background: transparent` + the guide _colors_ (stock black
text on a dark pane is illegible). Structurally the same job `[data-theme]` does for
the rest of the app.
- **Expressive (house style)** — genuinely opinionated: Plex, the Carbon categorical
palette, dotted grid, bumped guide sizes/weights, 16px title, no plot border. Strip it
and charts still work in both UI themes; they just look like Vega-Lite.
## 2. What vega-editor does (and what we take)
Read from the local clone (`reference/vega-editor`, `components/config-editor/`):
- **Theme dropdown = the `vega-themes` npm package** (~14 preset configs: excel,
ggplot2, fivethirtyeight, latimes, powerbi, googlecharts, urbaninstitute, dark, four
Carbon themes) + a `custom` sentinel. Already in our tree — vega-embed depends on it.
- **Picking a theme is a one-shot copy** of the preset JSON into a config editor pane;
any hand-edit flips back to `custom`. No live binding.
- **The config pane** feeds `opt.config` at compile — the slot we already use. Two
Monaco context-menu commands bridge pane ↔ spec: **Merge Config Into Spec** (pane →
`spec.config`, spec's existing keys win, pane empties) and **Extract Config From
Spec** (the inverse).
- **No custom-theme saving.** One global localStorage state blob; `custom` is "whatever
is in the pane". Nothing to borrow for named themes — that part is our own design.
The structural mismatch: vega-editor is a scratchpad for one transient document;
Astrolabe is a library. **Decided 2026-06-12:** theme choice is **not per-snippet**
`spec.config` _is_ the per-snippet mechanism, and merge/extract makes it ergonomic. The
app-level selector is a global preference.
## 3. Fonts (researched 2026-06-12)
**The hard constraint:** `vega-scenegraph/src/util/text.js` measures every label via
canvas `measureText` **regardless of renderer**. A font that finishes loading after
embed leaves the whole layout measured with fallback metrics. Any custom-font path must
`await document.fonts.load('<weight> 11px "Family"')` per used face **before**
`renderSpec`. Once loaded, SVG view, canvas view, and PNG export all work for free.
**Known limitation:** SVG _export_ carries only the family name — a viewer without the
font sees fallback (industry standard; data-URI `@font-face` embedding is a heavy
maybe-later).
**Shipped roster (self-hosted, no CDN — same `@fontsource` mechanism as Plex).**
Measured latin woff2 sizes (jsdelivr, 2026-06-12): regular text faces run **1325KB per
weight**; handwriting (Caveat) ~50KB. A ~9-family roster at ~2 weights ≈ **400450KB
latin**. All-subsets multiplier ≈ 34× (Inter: 87KB all-subsets vs 23KB latin, one
weight). Current dist is 7.8MB with 412KB of Plex — the roster roughly doubles font
payload; acceptable.
Candidate roster (final pick deserves a visual specimen pass, not a chat decision):
| Role | Faces (weights) |
| --------------------- | ----------------------------------------------------------------------------------------------------- |
| Already shipped, free | IBM Plex Sans, IBM Plex Mono |
| Dataviz sans | Inter (400/600), Roboto Condensed (400/600), Libre Franklin (400/600) |
| Brand-coherent | IBM Plex Serif (400/600), IBM Plex Sans Condensed (400/600) |
| Editorial serif | Source Serif 4 _or_ Spectral (400/600) |
| Exotic / display | Space Grotesk (400/600), Playfair Display (400/700), Caveat (400/600, "sketch"), Space Mono (400/700) |
**Subset/precache strategy:** chart fonts are decoration with automatic per-glyph
fallback (`unicode-range`), not app capability — non-latin data labels falling back to
the system font is degraded styling, not a broken app (contrast the Plex Cyrillic
lesson, which was UI capability). Plan: **ship all subsets in dist** (~1.21.5MB dist
growth), **precache latin only** (~+400KB), runtime-cache the remaining subsets
same-origin (CacheFirst) so a used subset persists offline after first render.
**User-loaded fonts (the branding case — primary).** Real brand fonts are licensed and
usually _not_ on Google Fonts. Path: upload woff2/ttf → bytes in IndexedDB (the
datasets persistence pattern) → `new FontFace(family, bytes)` + `document.fonts.add()`
at startup and before render. Fully local, offline-native, no privacy question.
**Google Fonts CDN tier — deferred, opt-in only.** Verified: keyless catalog at
`fonts.google.com/metadata/fonts` (1,936 families; a names-only list is ~30KB raw, so
the _picker_ can ship static and offline), CSS2 endpoint live, Workbox CacheFirst on
`fonts.gstatic.com` makes a chosen font offline after first use. Tension: `base.css`
says fonts are "never a CDN", and font requests expose the user's IP to Google. If this
ships, it is an explicit per-font user action, never automatic.
## 4. Build order
1. **Layer split** ✅ (refactor, no visible change) — `vega-themes.ts` is base +
expressive per UI theme, merged into the existing exports via `mergeChartLayers`.
2. **Preview theme selector** ✅ — global pref `ui.chartTheme`: **Astrolabe** (follows
UI theme, default) · **Stock Vega-Lite** (empty config) · all 14 vega-themes presets ·
(later) custom themes. Governs LivePreview **and export** (same view). Resolved
design points: the control is a `SelectControl` in the **preview header** (a select
nested inside PreviewSettings would close its own parent — SelectControl and
SettingsPopover share the one-open-popover registry); Onboarding/Chart-Builder
previews stay house-styled; preset/stock backgrounds render verbatim (a white chart
card on the dark pane is an honest destination preview).
3. **Merge/extract config** ✅ — `core/spec-config.ts` (`mergeConfigIntoSpec`,
`extractConfigFromSpec`), surfaced as the editor toolbar's **Config** menu
(SelectControl action picker — council: NN/g #6, Carbon overflow; arch 10 §5 records
the rule) with Monaco context-menu/palette as accelerators on the same functions
(spec §03G): bake the active theme into `spec.config` (existing keys win,
render-identical), or lift `spec.config` out to the clipboard (copy before remove —
a failed copy aborts).
4. **Custom named themes** ✅ (2026-06-12) — IndexedDB entity
`{ id, name, config }` (`core/custom-theme.ts`, themes store @ DB v2) + the **Theme
Builder** modal: theme list, JSON config editor, a font control that populates one
family across every font slot (`applyFontToConfig`), and a live multi-chart gallery
(`core/theme-preview-specs.ts`) so one edit is previewed across titles, axes,
legends, headers, and the major marks. Created by duplicating the currently-selected
theme (house/preset/custom) or via the editor's **Extract Config to New Theme**
action (spec §03G); appears in the selector as `custom:<id>` (the "Edit themes…"
action row sits right after the customs, before the preset roster); deleting the
active one falls back to Astrolabe. Custom themes travel in the §08 workspace
export/import envelope (additive `themes` array, name auto-suffix on clash, ids
reassigned by the store, rolled back with datasets on a failed import).
5. **Shipped font roster** ✅ (2026-06-14) — 11 self-hosted families via @fontsource
(`styles/chart-fonts.css`, full subsets bundled) extending `THEME_FONT_OPTIONS` to 17
entries; `collectFontFamilies` (core) + a `document.fonts.load` gate at the top of
`renderSpec` (before the layout/probe pass, which measures text regardless of
renderer); Workbox precaches the `latin` subset of the roster (~520KB) plus every
subset of the UI Plex Sans/Mono, and runtime-caches the rest (latin-ext + non-latin)
CacheFirst so a script works offline after first use. Roster picked from a visual
specimen. Not done here: theme↔font pairing metadata (a suggestion nicety, deferred).
6. **User font upload** ✅ (2026-06-16) — `FontAsset` (`core/font-asset.ts`) + the `fonts`
store @ DB v3, registered as a `FontFace` at startup so the render gate resolves user
faces like the roster (full entity-store stack: adapter/migration, `FontStore`,
`font-persistence`, `services/fonts`; arch 05 → User-uploaded fonts). The Type panel
offers uploads ahead of the roster. **Variable fonts** are supported: `parseFontAxes`
reads `fvar` (uncompressed ttf/otf) and the face registers with `wght`/`wdth` ranges, so
one file drives the whole weight range — only weight/width survive Vega's text rendering
(no `font-variation-settings` hook). No `CustomTheme.fonts` field: a used face is derived
by scanning configs/specs (`collectFontFamilies`) — the config is the
source of truth, and snippets use fonts with no theme to carry a field.
7. **Font export round-trip** ✅ (2026-06-16) — uploaded faces travel base64-encoded in the
§08 envelope (additive `fonts` array, decoded on import, **skipped** on family clash so a
self-backup doesn't pile up copies, rolled back with the rest on a failed import, registered
live so they render without reload), and the per-chart **SVG export embeds** the referenced
uploaded faces as `@font-face` data-URIs so an exported vector renders the right type off-app.
Shared base64 + `fontDataUri`/`primaryFamilyName`/`serializeFontAsset` machinery in
`core/font-asset.ts`; SVG embed + family-matching in `core/chart-export.ts`. Roster and
system stacks are never embedded (decoration with their own fallbacks; their bytes aren't in
the font library).
8. **Deferred** — Google Fonts opt-in tier; built-in expressive preset gallery ("Editorial",
"Terminal", "Sketch") showcasing the roster.
**Rejected:** per-snippet theme field (2026-06-12 — `spec.config` + merge/extract covers
it without a second mechanism).
## 5. Structured controls (slice 4b)
The builder today is a raw JSON textarea + one font dropdown + the live gallery. Slice 4b
adds a strip of structured controls above the editor — accelerators that write into the
JSON, never replacing it. The JSON stays the source of truth and the full-power escape
hatch; controls cover the common ~80% (color, type, spacing, grid), not all 72 config
properties (that is the trap vega-editor deliberately avoids by staying JSON).
**Hard constraint — the builder must preserve unknown keys.** Verified by compiling: the
vega-themes presets carry Vega-_layer_ keys (`symbol`, `shape`, `path`, `group`) that are
not in the Vega-Lite `Config` schema, and Vega-Lite forwards the whole config to Vega
unchanged — they take effect. So a structured control must **merge into** the existing
config (immutable path-set that spreads siblings), never rebuild it from a closed
schema-typed model, or it silently drops those keys on a round-trip. Same shape as
`applyFontToConfig`, which walks and rewrites rather than reconstructing.
**Resolved design points:**
- **Surfacing — vertical tab list + per-panel accordion** (not popovers, not sub-modals),
all in the one xlarge modal. One panel per config domain, switched by a vertical tab rail
(L1); within a panel, related properties group into a single-expand accordion (L2). No
nested overlays/focus traps, no contention with the one-open-popover registry; panel + JSON
- gallery stay visible together. The two-level grouping rule is recorded in arch 10
(_Organizing a large control surface_).
- **Color model — scheme picker that materializes to swatches.** A `range` family takes
either an explicit color array or a named scheme written as Vega's range-scheme object
`{ scheme: name }`. (A bare scheme-name _string_ passes vega-lite compile but Vega
rejects it at render — "Unrecognized scale range value" — so the controls write the
object form, read either, and `normalizeRangeSchemes` heals the bare form at render.)
Pick a named scheme for the quick path; "materialize" expands it to an editable swatch
array for brand tuning. Catalog ships 15 categorical + 24 sequential + 10 diverging
schemes; categorical schemes resolve to arrays, continuous ones to interpolators sampled
into stops for the gradient preview and the materialize action.
- Structured controls are gated on valid JSON (same as the font control): a parse error
disables them and the textarea is the fix.
- Controls write **minimal** config — clearing a value deletes the key rather than writing
a default, so a theme stays a diff against stock, not a full dump.
**Panels** (each an accordion of the groups below): Color (`range.category`, `mark.color`,
sequential `range.heatmap`/`ramp`, `range.diverging`) · Marks (per type — bars, lines &
areas, points, arc — plus generic opacity/fill/tooltips) · Type (base `font`, axis
title/label size+weight) · Title (anchor, colour, size/weight/style, offset, subtitle block)
· Layout (`background`, `view` fill/border/radius, `padding`, default size) · Axes & grid
(grid, ticks, domain, labels — base `axis` only; the per-channel variants stay JSON) · Legend
(placement/direction, title, labels, symbols, gradient, box) · Headers (facet title/label
colour/size/weight) · Formats (number/date/normalized formats, count title).
**Build order:** (a) core foundation — scheme catalog + immutable config path get/set +
`schemeColors` materialize, with tests; (b) Color panel (highest payoff); (c) the remaining
per-domain panels; (d) wire the navigation (vertical tabs + per-panel accordion) into the
modal.
## 6. Status log
- **2026-06-21** — **structured-control surface expanded + accordion everywhere.** New panels
— Marks (per mark type), Title & subtitle, Headers (facets), Formats — and deepened
Axes/Legend/Layout, covering the brand-tuning bulk of the config; the raw JSON stays the
escape hatch for the long tail. Every panel is a single-expand accordion with a per-section
set-count badge, switched by a vertical tab list, and the modal holds one
`id → label → Panel` registry. Shared primitives extracted: `WeightRow` (UI),
`enumValue`/`countSet` (core). The gallery is now fully reflective — sample specs use bare
marks so no control is shadowed (guarded by a test), and a normalized area + a temporal
facet were added so `normalizedNumberFormat` and `timeFormat` have mirrors; default chart
size and tooltips are the only non-previewable controls. Recorded in arch 10 (_Organizing a
large control surface_), arch 05 (_Structured controls_), and spec §04. Remaining in §4:
Google Fonts opt-in tier; built-in preset gallery; Color-panel swatch reorder.
- **2026-06-16 (slice 7)** — **font export round-trip + SVG embed.** Uploaded faces now
survive a workspace transfer and travel inside an exported SVG. Core: `serializeFontAsset`/
`deserializeFontAsset` (+ base64 helpers), `primaryFamilyName`, and `fontDataUri` in
`font-asset.ts`; the §08 envelope grew an additive `fonts` array (`export-envelope.ts`),
`normalizeImport` decodes it, and a new `dropClashingFonts` enforces the skip-on-clash merge
rule (`import-normalize.ts`). Service: `transfer.ts` exports all library fonts, and import
dedupes by family, commits fonts in the pre-snippet phase (rolled back with datasets/themes on
a failed snippet write), and registers the new faces on success. SVG: `referencedUploadedFonts`
- `embedFontsInSvg` in `chart-export.ts`, wired through the renderer's `toImageURL('svg')` with
the referenced faces resolved in LivePreview's `getImageUrl` (config captured per render).
Decision (font clash): **skip, not rename** — a font is identified by its family (the key in
config slots), so an existing same-named face satisfies the reference, and a self-backup
doesn't accrete "Font 2" copies; recorded in spec §08 → Name conflicts. Verified: typecheck,
lint, full tests. Remaining in §4: Google Fonts opt-in tier; preset gallery.
- **2026-06-16 (slice 6)** — **user font upload + variable-font weight support.** New
`FontAsset` entity (instance #4 of the entity-store kind, confirmed by an eng-council
pre-build consult): `fonts` store @ DB v3, adapter/migration, `FontStore`,
`font-persistence`, `services/fonts`, and the `font-faces` `FontFace`-registration seam
wired into `startup`. Type panel gains an upload control + a managed list; user fonts lead
the dropdown. Variable fonts parse `fvar` and register with `wght`/`wdth` ranges (weight
is the only axis Vega's text rendering can drive). Font dependencies are derived from the
config at export, not stored on the theme. Deferred: font bytes in the §08/SVG exports.
- **2026-06-14 (Color panel bugfix)** — **scheme picks rendered blank.** A named scheme
was written into `config.range.*` as a bare string, which vega-lite compiles but Vega
rejects at render ("Unrecognized scale range value") — silently caught by the gallery's
per-card try/catch, so the categorical/sequential/diverging charts blanked the moment a
scheme was picked. Predates this session's panels/fonts (shipped with the Color panel).
Fix: the controls write Vega's range-scheme object `{ scheme: name }` and read either
form; `normalizeRangeSchemes` (core) heals a bare-form config at the render-resolution
points (`chartConfigForSelection` for the live preview/export, and the builder gallery),
so themes saved/imported with the old form self-heal. The gallery's catch now surfaces the
error message in the card (fail-loud, arch 02) so a render failure on valid JSON isn't
invisible again. Regression cover: a real
vega-lite→vega compile/parse/run asserting `{ scheme }` renders and the bare string
throws, plus `normalizeRangeSchemes` unit tests. Verified: typecheck, lint, tests (983).
- **2026-06-14 (slice 5)** — **shipped font roster.** 11 self-hosted families
(`styles/chart-fonts.css`, imported in main.tsx, separate from the UI Plex in base.css):
Inter · Libre Franklin · Roboto Condensed · IBM Plex Sans Condensed · IBM Plex Serif ·
Source Serif 4 · Spectral · Space Grotesk · Playfair Display · Caveat · Space Mono, at
400 + 600 (Space Mono 400 + 700). `THEME_FONT_OPTIONS` grew to 17 (roster grouped by
role, then the system stacks); each roster stack carries a category fallback. The render
path now gates on fonts: `collectFontFamilies` (core, the read-counterpart of
`applyFontToConfig`; skips `data`/`datasets`) gathers the families a spec+config use and
`renderSpec` awaits `document.fonts.load` for them before the first layout pass — Vega
measures text via canvas `measureText` regardless of renderer, so a face loading after
embed would lay out with fallback metrics. Best-effort + 3s-capped so a slow first fetch
never freezes the preview. Precache strategy (vite.config Workbox): the `latin` subset
of every family (~520KB for the roster) + all Plex Sans/Mono subsets (UI capability) are
precached; latin-ext and non-latin scripts are runtime-cached CacheFirst (`*-latin-[0-9]*`
excludes latin-ext; the Plex Sans brace-list avoids matching the condensed roster font).
Verified: typecheck, lint, full tests (976), production build + precache-manifest
inspection. Note: @fontsource ships legacy `.woff` beside `.woff2`; modern browsers use
woff2, so the `.woff` sit unused in dist (pre-existing for Plex — neither precached nor
runtime-cached).
- **2026-06-14 (slice 4b complete)** — **Layout / Axes & grid / Legend panels + Type
size/weight.** The remaining structured-control panels, built on a small shared
primitives module `ThemeFields.tsx` (`ControlSection`, `ColorRow`, `NumberRow`,
`SelectRow`) so the panels read declaratively and match the Color panel's look. Each
control writes one config path through the same inline `mutateDraftConfig` +
`setConfigValue` the Color panel uses, with the minimal-diff delete (clearing a value
removes the key, pruning emptied objects). Leaf coercion (`asString`/`asNumber`/
`asBoolean`) moved into core `theme-controls.ts` beside the path get/set, tested there.
Panels: **Layout** (background and `view` fill/border as tri-state default·transparent/
none·custom, corner radius, scalar padding with a JSON hint when it's a per-side object);
**Axes & grid** (grid visibility/color/dash-preset, domain/label/title color, label
angle — base `axis` only); **Legend** (orient, title/label color+size, symbol size);
**Type** rounded out with title and axis title/label size+weight (font family relocated
into the extracted `TypeControls`). Resolved while building: each generic row label
("Size", "Color", "Weight") repeats across sections, so `ControlSection` is a
`role="group"` labelled by its heading and rows take an accessible-name override — the
visible label stays short, the control's announced name is qualified ("Title size"). The
font-roster decision (slice 5) was teed up with a throwaway visual specimen. Verified:
typecheck, lint, full tests (969). Remaining in 4b: swatch reorder (Color panel).
- **2026-06-14 (slice 4b, first increment)** — **structured-control foundation + Color
panel.** Core `theme-controls.ts`: immutable config path get/set (preserves siblings —
the Vega-layer-key guarantee — and prunes on delete) + the named-scheme catalog (15
categorical / 24 sequential / 10 diverging) + `schemeColors` resolution (categorical
arrays passthrough, continuous interpolators sampled to hex), all tested. `vega-scale`
added as a declared dep (focused sub-package, like `vega-expression`) with a typings
shim in `vite-env.d.ts` (its package.json `exports` omits `types`). Store gains the
generic `mutateDraftConfig(fn)` write path; `applyDraftFont` refactored onto it. Modal
gains an APG tab strip — **Color** (categorical scheme/swatches + materialize, default
`mark.color`, sequential/diverging gradient pickers) and **Type** (the relocated font
control). Tabpanel gated on valid JSON. From first-use feedback, same day: the modal
body is now **controls + JSON on the left, gallery as a full-height right rail** (the
previews were starved before); `SelectControl` gained an optional per-option `preview`
so the scheme dropdowns show swatch strips (categorical) / gradient bars (continuous);
every swatch is a reusable `SwatchRow` (color picker + copyable/editable hex field); and
sequential/diverging gained **Materialize → editable stops**, so custom gradient colors
are possible, not just named schemes. Second feedback pass: the raw JSON is now a
**collapsed disclosure** at the bottom of the controls column (it was eating half the
first screen), forced open only on a parse error; the structured controls fill the
column. `SelectControl` options gained a `labelStyle`, so the **font dropdown renders
each name in its own family** (the type analogue of the color swatches) and its trigger
shows the current font in-face. Verified: typecheck, lint, full tests (950).
Remaining: Layout / Axes & grid / Legend panels; swatch reorder.
- **2026-06-12 (slice 4 close-out)** — **custom themes in the §08 envelope.** The
workspace export now writes a `themes` array (additive — no format bump; importers
treat it as optional, so pre-theme envelopes stay valid). Import normalizes each
record (`normalizeCustomTheme`), auto-suffixes name clashes via the generalized
`dedupeIncomingNames` (the dataset dedupe, now shared), reassigns ids through
`CustomThemeStore.addThemes` (selection untouched), and rolls themes back together
with datasets when the atomic snippet write fails. Toast counts gain a theme
clause. Spec §08 updated ("Dataset conflicts" → "Name conflicts"). Slice 4 is now
fully done; next is slice 5 (shipped font roster).
- **2026-06-12 (slice 4)** — **custom named themes + Theme Builder shipped.**
`CustomTheme` entity through the full stack (core → theme-store @ DB v2 →
CustomThemeStore → theme-persistence → startup hydrate); selection model extended to
`custom:<id>` with missing-record fallback to the house style; Theme Builder modal
(xlarge, list + name + config JSON + font-apply control + 7-card live gallery, canvas
renderer, per-card chain-lock); picker gains custom entries + an "Edit themes…"
action row. The font control ships with render-safe faces only (Plex + web-safe
stacks) — the roster slice (5) extends `THEME_FONT_OPTIONS` and adds the
`document.fonts.load` gate. Spec updated (§01C, §04 Chart theme + Theme Builder,
§09C/E/G) + architecture 05 §3. Same-day follow-ups from first use: `openDB` now
verifies the store layout and self-heals an interrupted upgrade (arch 02 §2.1,
`db.test.ts` on fake-indexeddb); "Edit themes…" moved before the preset roster
(discoverability); **Extract Config to New Theme** added as the third Config-menu
action (spec §03G) — config block → saved theme, selected, removed from the spec.
Not yet done: themes in the §08 export/import envelope.
- **2026-06-12 (slices 23)** — **theme selector + merge/extract shipped.**
`ChartThemeId`/`chartConfigForSelection` in core; `ui.chartTheme` persisted via the
`previewFitMode` orchestration pattern; `SelectControl` picker in the preview header;
LivePreview renders (and therefore exports) with the selection; `core/spec-config.ts`
merge/extract behind two Monaco editor actions. Spec updated (§03G, §04 Chart theme,
§07, §09C) + architecture 05 §3 rewritten to the layered/selectable model. Verified:
typecheck, eslint, full tests (802), build. Custom named themes (slice 4) and fonts
(56) remain.
- **2026-06-12** — scope written; audit, vega-editor read, font research done (numbers
above). Slice 1 (layer split) implemented.
@@ -1,411 +0,0 @@
# Engineering Review — 2026-07-02
> Point-in-time record. Full-project engineering review requested by the maintainer, with a
> specific question attached: _"as we work session to session, we may forget to look back
> and see the bigger picture — the end-of-session skill checks aim to mitigate it, but I'm
> not sure to what extent it is successful."_
>
> **Method:** five independent clean-context reviewers (code quality, test suite,
> documentation, build/tooling/delivery, cross-session coherence), each required to cite
> file/line/commit evidence, followed by an adversarial verification wave that reproduced
> every load-bearing claim (rebuilt the bundle, re-ran greps and git archaeology, hand-walked
> the flagged logic, re-checked the live site). Findings below are only those that survived
> verification; where a reviewer's number was wrong, the corrected number is used.
> Reviewed state: working tree at `c5e4c4c` plus the uncommitted spec-params/editor WIP.
## Verdict
The codebase itself is in excellent shape — the architecture contract holds under grep, not
just in prose, and the per-session review machinery demonstrably works at the session scale.
The problems are concentrated at two horizons the per-session view cannot see: **delivery**
(one measurable production defect: the marketing landing executes 1.3 MB gzipped of Monaco +
Vega it never uses) and **look-back**. The maintainer's fear is confirmed, mechanically: no
instrument — on-demand or scheduled — owns the whole-project view, so coherence work happens
only when a session happens to collide with it. (Maintainer clarification after the first
draft: the eng-council sweep and the ux-second-pass batch were designed as on-demand
session guardrails, not periodic instruments — which sharpens the finding rather than
softening it: the on-demand instruments have each fired once, and nothing at all runs on a
cadence.)
## Scorecard
| Dimension | Grade | One-line summary |
| ----------------------- | ----- | ------------------------------------------------------------------------------------- |
| Code quality | A | Zero `any`/suppressions, layering verifies by grep, error discipline is real |
| Test suite | A | 1,366 tests, uniform harness, deterministic; one committed coverage hole that matters |
| Documentation | A | Symbol-level accuracy at scale; drift is localized fossils plus one governance gap |
| Build / delivery | B | Strong local gates; landing bundle defect shipped because no gate measures output |
| Cross-session coherence | B | A for per-session machinery, D for look-back machinery |
## Critical findings (all independently verified)
### C1. The landing and /learn/ eagerly load and execute Monaco (940 kB gz) and Vega (285 kB gz)
Verified on the live site and reproduced from a clean build. `dist/index.html` modulepreloads
both chunks and the landing entry's static import graph executes them
(`main-*.js` ends with bare `import"./vega-*.js";import"./monaco-*.js"`). `/learn/` is worse:
both arrive as direct `<script type="module">` tags. Landing eager JS today: ~4.7 MB raw /
~1.30 MB gzip; the intended payload is ~255 kB raw / ~81 kB gzip — a 16× reduction available.
The source-level lazy-loading discipline is correct (`LandingChart.tsx` dynamic-imports
chart-renderer; nothing in `src/landing`/`src/learn` mentions Monaco). Two bundling-level
causes defeat it:
- **Preload-helper placement.** The object-form `manualChunks` in `vite.config.ts` leads
Rollup to emit Vite's `__vitePreload` helper _inside_ the `monaco` chunk;
`LandingChart-*.js` begins `import{_ as h}from"./monaco-*.js"`, so every chunk that uses
`import()` statically depends on all of Monaco. (Attribution to the object form
specifically is plausible-but-untested; the observed mechanism is fully reproduced.)
- **`vega-scale` edge.** `Landing.tsx``@core/vega-themes``theme-controls.ts:27`
`import { scheme } from 'vega-scale'` merges into the `vega` manual chunk, dragging all of
Vega into the landing's static graph. The comment at `theme-controls.ts:21-22` ("keeps the
umbrella vega out of core") is true at source level and defeated by chunking. The
`examples` chunk has a second, independent edge into the vega chunk.
**Fix:** break the `vega-scale` edge (lazy-import or move scheme resolution off the
landing-reachable path); switch to function-form `manualChunks` and confirm the helper lands
in a shared micro-chunk; and — the durable part — add a ~10-line post-build assertion that
`dist/index.html` and `dist/learn/index.html` reference neither `monaco-*` nor `vega-*`.
That assertion is the missing gate for this entire regression class.
### C2. The project's operating regime is recorded nowhere the machinery reads
The 2026-06-10 phase shift — spec follows code; docs/spec is no longer the authoritative
contract — appears in exactly one repo location: `docs/exploration/chart-builder-enhancement-scope.md:673`,
inside the directory whose charter says nothing there is kept current. Meanwhile CLAUDE.md
("authoritative behavioral specification… This is the contract"), AGENTS.md ("Spec is the
contract"), SOUL.md, arch 00 ("the spec wins"), and `.claude/skills/alignment/SKILL.md`
rules #3/#17 all still assert the old regime. Two reviewers independently converged on this,
and the verification wave reproduced the greps.
This is the worst coherence defect because it corrupts the corrective machinery itself: the
wrap-up protocol's whole design is clean-context subagents judging against the recorded
contract — and the recorded contract is wrong. **Fix:** one paragraph in CLAUDE.md/AGENTS.md
stating the regime (spec is descriptive, kept current with code; on conflict fix the spec),
softened phrasing in `docs/spec/README.md` and arch 00, and an update to the alignment skill.
### C3. The spec stopped absorbing new behavior at a verifiable cutover (2026-06-21 → 06-25)
Adjudicated timeline, commit-dated: through `d76a7a5` (06-21) every feature landed with its
spec section in the same commit (Theme Builder, FontAsset, data inspector, onboarding, URL
datasets — all specified at full fidelity). From `c19857b` (06-25) onward the discipline
inverted: 12 of 13 feature commits through 06-30 updated `docs/architecture/` in-commit and
**zero** touched `docs/spec/`. Unspecified user-facing surfaces at HEAD:
- the entire **/learn/** section (`grep -ril learn docs/spec/` → nothing);
- the **composition wireframe** (drag reorder / pull-out / stack / Simplify — zero matches);
- **editor transform actions and CodeLens scaffolds** (wrap/simplify/add-view, add-transform);
- the **multi-view Data Inspector view picker** (`docs/spec/04-live-preview.md:84` references
"the chosen view's table" — the chooser it refers to is specified nowhere);
- the current uncommitted params-scaffolding work continues the pattern (arch 08 only).
Even under the spec-follows-code regime this is debt: the "then amend the spec to match"
half of the bargain has not happened for ten days of features. Root cause is structural, not
negligence: `/doc-update` flushes what a session remembers and `/alignment` reviews diffs —
no instrument owns "the spec describes the product." Alignment rule #17 covers removed/moved
surfaces only, not never-specified additions.
## Significant findings
### S1. No instrument owns the big picture on any cadence
- **Eng-council sweep:** once, 2026-06-12 (`0e225d7`). Since then non-test source grew
~**+76%** (16.8k → 29.7k ts/tsx LOC; ~21.3k → ~37k all-source). `docs/codebase-metrics.md`,
whose stated purpose is the trend, holds two same-day rows. Specific unswept accretion: the
six-module `spec-*` service family plus `editor-snippet`/`editor-cursor-lens` (~1,900 LOC,
mostly post-sweep) — which alignment rule #16 already notes "has grown by copy-paste twice."
- **Batched council pass over `docs/ux-second-pass.md`:** once, 2026-06-13 (`92bfe88`,
76→25 lines — the design worked). The file has regrown 25→48; oldest open item is from
06-16.
- **TODO gardening:** never. Lifecycle is bimodal — 56 added / 43 removed over history
(~7177% resolution, usually within 03 days, at least 10 by dedicated commits), but every
TODO that survived its first week is still alive. Oldest two are from 06-12:
`src/app/modals/ModalCoordinator.ts:36` (a **latent init-ordering bug**, not polish) and
`ChartBuilderModal.tsx:524`. The open set is increasingly the hard/ambiguous residue no
session claims.
**Fix direction (maintainer to ratify):** give each instrument a trigger — re-sweep when
source LOC grows ~25% past the last metrics row; batch council pass when ux-second-pass open
items exceed a count or age past two weeks; a spec-reconciliation clause in `/doc-update`
("did this session add user-facing behavior? name the spec section or write it"); TODO
gardening as a standing sweep agenda item.
### S2. The scaffold insertion math is untested, duplicated, and validity-critical
Verified: `runAddTransform` (`spec-transform-scaffold.ts:130-146`, committed `c5e4c4c`) and
`runAddParam` (`spec-param-scaffold.ts:109-142`, WIP) compute insert offsets, indentation,
and the trailing-comma decision; a bug here writes **invalid JSON into the user's editor**.
No test exercises the composition — no `SpecEditor.test.tsx` exists, so there is no indirect
path either. The create-property block is a verbatim 7-line duplicate between the two files;
the completion providers share ~35 structurally identical lines. Hand-walking the current
logic found **no live bug** — this is a coverage hole, not a defect — but a future inversion
of the ternary or offset drift ships silently. The repo's own TODOs
(`spec-param-scaffold.ts:33`, `spec-params.test.ts:6`) already point at the fix: extract a
neutral core module (working name `spec-snippet`) computing
`(text, host offset, existing-array?) → {insertOffset, snippetText}` and table-test it over
empty-host / host-with-following-key / compact-one-line / host-as-last-property.
Related: `spec-transform-actions.ts` (663 lines, largest service, no test file) carries pure
unexported helpers with real branching — `reindent`, `defaultFacet`, `defaultRepeat` (:106,
:123, :136) — one layer short of tested core, mitigated by `buildNext` being a thin
dispatcher over tested core wrappers.
### S3. Documentation drift cluster (6/6 claims verified)
All in otherwise highly accurate docs; each is a fossil a maintainer would act on:
1. **5 MB budget fossil.** `docs/spec/09-data-model.md` §E and `08-import-export.md` describe
a budget-with-fill-warnings storage monitor that spec 02/10, arch 02 §6, and the code all
contradict (the monitor is a composition breakdown; the only 5 MB logic is the import
pre-check in `transfer.ts:42`). Arch 02:445's "80% threshold" Do-line contradicts the same
doc's line 425 and matches nothing in code.
2. **"Not installable" is false.** Arch 10:625 claims the manifest ships no icons; four icons
ship (`vite.config.ts:85-89`, files in `public/`), and manual-verification actively tests
install.
3. **The ajv layer was never built.** Arch 08 presents it as planned-M2 and claims "we
deliberately do better: map ajv errors to editor positions" — no such module exists; ajv
isn't even in `package.json`. The current WIP on arch 08 does not touch this.
4. **Wrong store name in three docs.** Arch 01/03/04 name `useSettingsPopoverStore` /
`openSettingsPopover`; the code is `usePopoverStore` / `openPopover`
(`PopoverStore.ts:22,30`, called from `EventRouter.ts:96`). Grep by the documented name
finds nothing.
5. **Arch 03's "closed union" is stale.** Five modals listed; `modals/types.ts` has six
(`themeBuilder`). Same doc's add-a-modal checklist instructs `hasError`/`getError` on
`ModalConfig` — fields the real type doesn't have; following it fails typecheck.
6. **Spec 09's UserSettings omits `ui.dataInspectorOpen`/`ui.dataInspectorHeight`**, which
spec 04 mandates and `settings-store.ts:158-183` persists (they're also absent from
`core/settings.ts`'s type — the code half of the same gap).
### S4. Delivery gates never observe build output, and caching is misconfigured
- All quality gates live in the (excellent) pre-commit hook — lint-staged + full typecheck +
the whole test suite — but nothing anywhere runs or inspects `vite build`. Cloudflare's
build fails safe on compile errors (previous deploy stays live) but silently; the escape
class is green-but-wrong output, which is exactly how C1 shipped. Cheapest durable fix: the
C1 post-build assertion in the `build` script; optionally a minimal CI workflow
(typecheck + lint + test + build + assertion) to cover `--no-verify` and web edits.
- Hashed `/assets/*` are served `cache-control: max-age=14400, must-revalidate` (Cloudflare
Pages default; no `public/_headers` exists). Content-hashed files are the textbook
`max-age=31536000, immutable` case; today every returning visitor revalidates a 3.6 MB
chunk every 4 hours. HTML cache headers are correct.
### S5. Module-size outliers in the chart builder
`src/core/chart-builder.ts` (1,851 lines: catalog + rules + smart defaults + assembly/parse)
and `ChartBuilderModal.tsx` (1,653 lines, ~25 private subcomponents; 2.1× the next-largest
component). Internally cohesive, well-documented — maintainability risk, not defect. Split on
next substantive touch (`chart-builder/{catalog,rules,defaults,assemble}.ts`; hoist the
modal's sections into sibling files).
### S6. Smaller verified items
- `noUncheckedIndexedAccess` is off in a parser-heavy codebase (jsonc-parser node walking in
`spec-params.ts`, `spec-cursor.ts`, `spec-insert.ts`) that is exactly its target class.
Enable and sweep; if parser noise proves disproportionate, record the rejection.
- `ajv` is imported by `spec-params.test.ts:1` but undeclared — a phantom transitive
dependency; add it to devDependencies (found during verification).
- `npm audit`: monaco 0.54.0 bundles a vulnerable DOMPurify (moderate; fix is 0.55.1, flagged
breaking — plan deliberately); esbuild advisory is dev-only/Windows-only.
- Landing/learn light-dark toggle duplicated since 06-25, TODO'd in both copies and reworded
06-28 instead of extracted — a breadcrumb treated as the deliverable.
## Minor findings
- Per-editor CodeLens providers register on the global `'json'` language
(`editor-cursor-lens.ts:83`) while closing over one editor's cursor — safe with today's
single editor (verified: one `monaco.editor.create`), latent cross-wiring if a second JSON
editor appears. One guard line: `if (model !== editor.getModel()) return`.
- One avoidable non-null assertion in the WIP: `spec-transform-scaffold.ts:176`.
- `navigator.platform` (deprecated) in `EventRouter.ts:38`.
- Hooks are untested as a class; `useFocusTrap` (a11y-load-bearing, shared by all overlays)
is the one worth a cheap happy-dom test. `ModalCoordinator`'s open/close/replace sequencing
likewise (~5 tests).
- No coverage reporting exists; for a philosophy that is explicitly proportional ("core
hardest"), nothing measures the proportion. `@vitest/coverage-v8` scoped to
`src/core` + `src/app/{stores,services}`, no thresholds, visibility only.
- localStorage write failures are console-only (`settings-store.ts:80`, `ux-prefs.ts:80`) —
judged an acceptable, explicit low-stakes swallow; noted so the asymmetry with the
IndexedDB path stays a decision.
- `/learn/` is reachable only from the landing; nothing in the app links it. Deliberate or
forgotten is unrecorded — which is itself the gap.
- Doc/housekeeping nits: pre-commit hook is stronger than AGENTS.md documents (says eslint;
runs typecheck + tests too — drift in the good direction); PWA precache carries ~150200 kB
of landing/learn assets the `/app/`-scoped SW can never serve; spec 00 still calls settings
a modal; spec 02 overstates the "imported" tag (only foreign/older shapes are tagged — spec
08 has it right); `docs/embedding-vega-lite.md` (447 lines) is orphaned — index it with a
charter line or move it; `core/settings.ts:64` and `HeaderControls.tsx:2` cite the old
pre-move path of the theming scope doc; IMPLEMENTATION-PLAN files a completed item under
"Next"; empty `ops/` directory; `vega-themes` pinned exact with no recorded rationale;
manual-verification.md lacks checks for the shipped theming/export surface (Theme Builder
gallery, font upload incl. variable fonts, PNG/SVG export, clipboard).
## Strengths (verified, and worth keeping deliberate)
1. **The architecture contract verifies by grep, both directions.** No browser APIs, React,
or Monaco in `src/core/`; storage APIs confined to `infrastructure/`; landing/learn import
no stores/orchestration/components.
2. **TypeScript rigor at the top of the distribution.** Zero `any`, zero
`@ts-ignore`/`@ts-expect-error`, exactly one non-null assertion across ~30k lines;
type-aware ESLint (incl. `no-floating-promises`) passes clean; every intentionally
unawaited promise is an explicit `void` with a comment.
3. **Error-handling discipline is real.** Every swallowed catch inspected carries a rationale
naming the sanctioned fallback; real failures route to notifications with actionable copy;
quota errors are normalized and surfaced.
4. **Concurrency/resource engineering above typical app code.** `LivePreview`'s generation
token + render mutex (with reasoning written down), full Monaco disposable cleanup, the
finalize-before-unsubscribe edge handled in chart-renderer.
5. **The test suite is engineered, not accumulated.** 1,366 tests / 8.5s, zero snowflake
harnesses across all 17 component test files, injected clocks and fake timers keyed to
exported constants, fresh `IDBFactory` per test (including interrupted-upgrade
self-healing), tests that assert contracts with the why inline
(`spec-refs.test.ts`, `chart-renderer.test.ts`).
6. **Session-scale coherence is solved.** All 12 stores share one canonical shape and cite
their canonical sibling in doc comments; modals go through one registry/coordinator/shell;
subtraction happens (`PreviewStore` deleted when obsoleted). The "written by strangers"
failure mode is absent.
7. **The breadcrumb→fix pipeline works.** At least 10 TODOs resolved by dedicated commits
(quota rollback, hash routing, splitter ARIA, field-name escaping…); the eng-council→law
loop is real (sweep findings became alignment rules #15#17; skills keep evolving).
8. **Docs are accurate at symbol level at scale** — of ~35 spot-checked claims (constants,
record shapes, hash grammar, keyboard maps, API surfaces), nearly all exact — and the
"shipped divergence" fencing discipline keeps pedagogical sketches from lying.
9. **PWA configuration is best-practice**: `registerType: 'prompt'` properly consumed with a
durable update toast, SW scope narrowed to `/app/`, sophisticated font precache strategy
with reasoning in the config.
## Recommended sequence
1. **Record the regime** (C2): CLAUDE.md/AGENTS.md paragraph + alignment-skill update +
soften spec README/arch 00. Smallest fix, unblocks every future clean-context review.
_Done same day: CLAUDE.md, AGENTS.md, SOUL.md, spec README, and alignment rules #3/#17
now state the spec-follows-code regime; #17 extended to cover never-specified additions._
2. **Fix the landing bundle and add the post-build assertion** (C1): the only
production-visible defect, and the assertion closes the gate gap (S4) for free. Add
`public/_headers` for immutable assets while in there.
_Done same day: `schemeColors` split into `core/scheme-colors.ts`; function-form
`manualChunks` with explicit homes for the preload helper and the shared light packages
(vega-themes, vega-expression, vega-util, json-stringify-pretty-compact — Rollup was
absorbing each into the nearest vendor chunk); `scripts/check-light-entries.mjs` gates
every build; `public/_headers` ships immutable caching. Landing eager JS measured
~98 kB gzipped, down from ~1,300 kB; all three entries smoke-tested on the production
build (charts + Monaco render, zero console errors)._
3. **Spec back-fill sprint** (C3): specify /learn/, the wireframe, editor actions/scaffolds,
and the view picker; add the spec-reconciliation clause to `/doc-update`.
4. **Give the periodic instruments triggers** (S1) and run the overdue ones: an eng-council
re-sweep (which naturally absorbs S5's splits and the `spec-*` family consolidation), a
ux-second-pass batch pass, and TODO gardening — starting with `ModalCoordinator.ts:36`.
5. **Extract and test the scaffold insertion core** (S2) before the params WIP is committed —
the TODOs already name the module.
6. **Doc drift batch** (S3 + minors): one session, mostly deletions of fossils.
---
## Addendum: subtraction audit (same day)
Follow-up requested by the maintainer: how much of the codebase is duplication, boilerplate,
or unnecessary wrapping — with the explicit instruction that **deliberate architecture layers
are not exempt**; "less LoC = less surface for bugs" outranks ceremony. Two auditors (a
jscpd-quantified duplication pass and a wrapper/indirection pass with the contract layers on
trial); the largest verbatim-copy claims were independently re-diffed before inclusion.
### The numbers
- **Production duplication is low: ~0.9%** by jscpd at min-tokens 50 (268 duplicated lines
over ~29.5k production TS/TSX; roughly double counting renamed semantic twins). Test-file
duplication is ~5.4% but is the documented harness convention — benign. CSS Modules are
the highest-duplication format at 5.4% and deserve one deliberate pass.
- **Pure ceremony is ~1.5% of non-test LoC.** The plumbing layers (infrastructure +
orchestration + modals + hooks, ~2.8k lines, ~9% of src) are overwhelmingly load-bearing:
`db.ts`'s promise wrapping/self-healing/quota normalization, `url-hash.ts`'s total-parse
grammar, `remote-data.ts`'s size-cap and error classification, the modal coordinator's
snapshot/confirm/URL lifecycle all earn their lines on inspection. No generic machinery
with a single instantiation was found — `editor-cursor-lens` has three real consumers,
`wireEntityWriteThrough` four, and every modal-registry field varies across entries.
- **Confidently deletable today, behavior identical: ~450500 LoC**, plus ~100150 more
behind design decisions.
The diagnosis, verbatim from the audit because it generalizes: **the abstractions are
right; the residue is photocopied instantiation files and hand-unrolled adapter pairs that
the abstractions should have absorbed.** Nothing calls for architectural change — only
tidying inside the architecture. Notably, one suspicion from the main review was stale: the
orchestration wirers were already consolidated behind `wireEntityWriteThrough`; the leftover
per-entity files are the residue that consolidation should have deleted.
### The cut list (verified; ranked by value)
Do right away — all mechanical, most protected by existing tests:
1. **Per-preference persistence chain** (~90110 LoC; the compounding win). Five
near-identical load/save pairs in `settings-store.ts:116-184`, four identical 10-line
init/wire pairs in `orchestration/preferences.ts`, same shape again in
`orchestration/settings.ts`/`snippet-sort.ts`, ten sequential calls in `main.tsx`.
Collapse: a `uiSlicePref(key, validate, fallback)` adapter factory + a
`wireStorePref(store, selector, save)` orchestration helper. Every future preference then
costs ~8 lines instead of ~30 across four files. `theme.ts` (FOUC ordering) and
`panes.ts` (debounce) stay bespoke — they carry real variation.
2. **Entity-adapter photocopies** (~5560 LoC). `snippet-store.ts` / `dataset-store.ts` /
`theme-store.ts` / `font-store.ts` are the identical 28-line load/save/delete triple
differing only in store name, migrate fn, and version constant (re-diffed: confirmed).
Collapse: `makeEntityAdapter<T>(storeName, version, migrate)` in `db.ts` + four 3-line
instantiations. Migration files stay — real per-entity logic.
3. **Wirer residue files** (~55 LoC). `dataset-persistence.ts` / `theme-persistence.ts` /
`font-persistence.ts` are 25-line modules whose body is one 6-line
`wireEntityWriteThrough` call. Move the calls into `entity-persistence.ts` or
`startup.ts` (their only caller). `snippet-persistence.ts` stays — the debounced
autosave is real logic.
4. **`editor.addAction` ceremony** (~55 LoC). Thirteen copies of the same 6-line
registration object across `spec-transform-actions.ts:524-585` and
`spec-config-actions.ts:210-235` → one table + `.map(addAction)`.
5. **Scaffold-twin merge** (~4050 LoC; overlaps main-review S2 and shares its fix). The
completion prologue, suggestion mapping, and create-property block are verbatim between
`spec-transform-scaffold.ts` and `spec-param-scaffold.ts`. Collapse into
`editor-snippet.ts` helpers; the pure indent math moves to the TODO'd core module, where
it also becomes testable.
6. **Core jsonc-helper copies** (~2530 LoC). `objectKeys` byte-identical between
`spec-params.ts:85-93` and `spec-data-transforms.ts:103-111` (re-diffed: confirmed);
`paramsArrayNode`/`transformArrayNode` identical modulo key string; two more shared
shapes. Extract/share — both sides have strong tests.
7. **localStorage adapter shell** (~28 LoC). `readRaw`/`writeRaw`/`available` identical
between `settings-store.ts` and `ux-prefs.ts` modulo type name and log tag (re-diffed:
confirmed) → `jsonLocalRecord<T>(key, tag)` in the same layer.
8. **Batch of micro-cuts** (~90 LoC): `UrlStateSync`'s argument-ignoring
`syncModalToUrl`/`clearModalFromUrl` wrappers (delete when fixing the
`ModalCoordinator.ts:36` ordering TODO they paper over); `DatasetStore`'s
thrice-repeated save epilogue; `readTextFile`/`readBinaryFile` 1:1 renames of `File`
methods; `initPersistentStorage` pass-through; `modals/types.ts` folded into the
registry; `SpecEditor`'s seven subscribe/dispose pairs → array; the landing's four-times
repeated `shot` block + TODO'd `UiTheme` redeclaration; `Icon.tsx`'s four-times copied
panel frame; one dead CSS rule (`ChartBuilderModal.module.css:213`).
Needs design thought (~100150 LoC more): the splitter triple (`ResizeHandle` /
`PaneSplitHandle` / `InspectorSplitHandle` share a near-verbatim `role="separator"` block —
worthwhile but untested a11y surface, do with manual keyboard verification); the theme-panel
Color+Size+Weight row groups (60100 LoC vs greppability of accessible names); the modal
master-detail CSS block; the landing/learn theme-toggle hook (blocked on where a shared
React hook may live under the entry-isolation rule — needs a maintainer call).
Adjudicated and acquitted (challenged per the widened mandate, earn their lines): the modal
registry/coordinator/shell trio (every registry field varies; flattest honest version saves
only ~35 lines); the Zustand store shapes (a generic collection-slice helper would cost more
in typing than the ~60 lines it saves and obscure real per-store invariants); core purity
shims (minimal — the altitude problem runs the other way, app services holding pure logic);
the non-photocopy infrastructure adapters (real normalization, migration, error mapping).
### Wrap-up follow-ups (recorded from the params-feature review passes, same day)
The params WIP was closed through the full wrap-up protocol (alignment + eng-council, both
clean-context). Fixed in that pass: an `isUnitNode` bug (nested layer containers offered
schema-invalid selection params), the S2 extraction (`core/spec-snippet.ts`, insertion math
now table-tested applied-and-reparsed), spec §03 scaffolding coverage, the transforms
catalog upgraded to real-schema validation, `ajv` declared. Deferred with recorded homes:
- `src/core/lesson-parse.ts` exports five unused types (knip) — verify and unexport. Same
check for `LensFactory` in `editor-cursor-lens.ts:24` (exported, consumed only in-module).
- `spec-transform-actions.ts` has an internal ~8-line clone (~:269-276 vs ~:346-353) —
fold into the existing 663-LOC consolidation item.
- The Ajv compile boilerplate now lives in two test files; a third schema-validating
catalog test should extract a shared helper — and at that point promote the rule
"seeded editor catalogs validate against the bundled VL schema, not just JSON.parse"
to an `/alignment` check.
- A small knip config (ignore `@fontsource/*`, `marked` false positives) would make
future dead-export sweeps one command.
@@ -1,177 +0,0 @@
# Landing & Onboarding Scope
_Point-in-time scope memo, 2026-07-04. Consolidates the onboarding/landing review: audience
model, claims audit, objection map, target landing architecture, and the implementation
phases. Supersedes nothing; feeds the next landing/onboarding sessions._
_Status (2026-07-04, same session): **Phases 1 and 2 shipped** — deep links, paste door,
learn links, brushed-scatter example, and the full landing overhaul (showcase block,
editor proof, objection beats, reweighted blocks, trust creed, voice pass). Phase 3
(learn growth) remains. Gotcha recorded at the code site: faceted/concat specs need
`fitMode: 'default'` in `LandingChart` — the width-fit contract can't size their
children._
## Audience & thesis
The goal of the public surfaces is to **popularize Vega-Lite's capabilities**, not to serve
a niche. Two audiences, one page, two pitches:
- **Beginners / the declarative-curious** — sold on **Vega-Lite itself**: charts written as
text, interactive by declaration, beautiful out of the box (something users rarely have
time to achieve themselves). Their doors: the Chart Builder, examples, `/learn/`.
- **Practitioners** (already write specs, often via wrappers) — sold on **the editor**: a
home for specs (vs. the Vega editor's scratchpad), schema-aware Monaco, dataset library,
themes/fonts, export parameters the vega-embed kebab menu never offers.
The surface stays **general-purpose**: learners are welcome underneath, but nothing reads
as a teaching tool or classroom product. Blocks alternate between the two pitches; the
strongest moves serve both at once (a themed, interactive chart sells VL capability to the
beginner and the theming machinery to the practitioner in the same pixels).
Hero-copy consequence: "A home for your Vega-Lite charts" addresses only people who
already have Vega-Lite charts. The headline must admit the beginner too — positive case
first (charts as text: interactive, themeable, durable), "home for them" as the second
beat.
## Claims audit (2026-07-04)
Every claim on the current landing verifies against the code — no overclaims. The page
**underclaims**: shipped capabilities absent from it, in order of missed leverage:
1. **Interactivity** — the page contains zero interactive charts (live-rendered, yes;
interactive, no), while interactivity is VL's headline capability for popularization.
2. **Composition wireframe** — drag-editable multi-view editing; unique in the Vega
ecosystem; unmentioned.
3. **Data inspector** — input vs. resolved rows per view; the answer to "why is my chart
empty"; unmentioned.
4. **CodeLens scaffolds** — one-click working params/transforms/view blocks; the bridge
feature (beginners get working code to study, practitioners get speed); unmentioned.
5. **Theme Builder breadth** — landing lists colour/type/axes/legend/layout; it also does
marks, titles, number formats.
6. **`/learn/`** — a capability, currently only a nav link.
Nitpick: "Sixteen presets" counts "Stock Vega-Lite (no theme)" as a preset.
Page-wide visual finding: nearly every chart renders in default blue. The page's imagery
_is_ its charts; they must carry themed variety — the page itself is the proof of
"out-of-the-box beauty without the time investment".
Pacing findings (desktop 1440, light): theme block ≈ a quarter of total scroll (three
tall charts stacked); builder demo's default state is the most boring chart it can produce
(count-by-channel, plain blue); datasets — the core "home, not scratchpad" argument — gets
the weakest visual (small static mock); export gets a full peer block for what is partly
table-stakes; the hero app window reads as a screenshot (nothing signals it is live).
## Objection map
Objections cluster two ways; each gets **one compact moment** on the page, not a FAQ
sprawl. The best answers are either **on-ramps** or **stances stated before suspicion
forms**.
**Habit cluster** — "I already have a way" — one positioning block near the editor
section:
- _Vega editor?_ A scratchpad, not a home. (One-slot, no library, no fonts/themes.)
- _Altair / wrappers?_ Their output **is** a Vega-Lite spec — paste it in, polish, keep.
Most real-world VL usage is via Altair; this is the largest single objection. Nobody
hand-writes specs from a blank buffer, and Astrolabe never asks them to (examples,
builder, paste + autocomplete/scaffolds/inspector). Last-mile polish (label exprs, axis
formats) is often faster in the spec than translated back through a wrapper API.
- _LLMs write specs?_ Yes — paste it here; this is where an almost-right spec gets
diagnosed (preview, validation, inspector). AI-free is a privacy feature: the model
never sees the real data.
**Trust cluster** — generated by the local-only stance itself — lives at/near the creed:
- _Local = fragile?_ The library exports as one JSON file; back it up like any file you
own. Copy must stay on export/import — never imply sync (none exists).
- _Closed source, so why believe "no data leaves"?_ Falsifiable claim instead: static
site, no backend, works fully offline once installed — airplane mode is the audit.
(Never claim open source.)
- _Solo project longevity?_ Lock-in-free is the honest answer: everything is ordinary
Vega-Lite JSON; specs outlive the tool; the PWA keeps working offline.
- _Sharing?_ Exports are the sharing story; spec-with-data-inlined is quietly the share
feature and should be framed as one.
Beginner-adjacent beat (one line, no comparison table): vs. Datawrapper/Flourish — no
account, no hosting dependency, real interactivity, a growing library you own.
**Deliberately not addressed on the landing**: storage limits / huge datasets (real
constraint, edge concern; the in-app storage monitor is the right surface — raising it in
marketing plants a worry most visitors never had).
## Target landing architecture
1. **Hero + app window** — dual-audience headline; default example themed and
interactive; explicit "this is the real app — try it" affordance; window's snippets
deep-link into the app.
2. **"What Vega-Lite can do"** — NEW, the popularization centerpiece: 23 interactive
charts (tooltip; brush → linked filter; a facet) in distinct themes, each beside its
short spec. Argument: _this is a text file._
3. **"A serious editor"** — practitioner proof: autocomplete/validation/inline docs
shown (staged, NOT real Monaco — the post-build gate keeps Monaco off the landing),
CodeLens scaffold shown, data inspector named. Positioning block (habit cluster)
attaches here.
4. **"Don't want to start from JSON?"** — builder, explicitly the second door; demo
defaults to a colourful non-trivial state (an intent applied, colour encoding on).
5. **"One library"** — datasets promoted and made vivid (extract-inline-data
before/after is the honest demo).
6. **"Make it yours"** — half current height: one chart + theme switcher, gallery as a
compact grid, fonts in the first sentence.
7. **"Get it out the way you need it"** — export reframed as practitioner pain relief
(parameters the vega-embed kebab menu lacks), compact; inline-data export framed as
sharing.
8. **Creed** — plus the trust cluster (backup/export, offline-as-proof, portability-as-
longevity) → learn pointer → close.
Verify on mobile and dark before committing layout.
## App-side changes
- **Deep links in**: `#build` already exists in the hash grammar — the landing builder
demo can link today. Add `#example-<id>` (consumed once at startup: create that
example as a snippet, open it, clear the param) so landing demos hand off momentum
instead of resetting at the onboarding canvas. Spec §01 hash table updates with it.
- **Onboarding canvas third door — "Paste a spec"**: serves Altair users, LLM users,
Vega-editor migrants; today they must Create → select-all → delete → paste. Also a
quiet "Restoring from an export? Import your workspace" line (Import is header-icon-only
during onboarding). Spec §02 updates.
- **Example gallery showpieces**: add 12 interactive/composed examples (brushable
scatter + linked histogram; ties to the existing linked-views lesson). Single source of
truth pays twice: the landing hero window shows them automatically.
- **Learn wiring**: the app currently has zero links to `/learn/`. Add: About modal, and
a low-key onboarding-canvas line.
## Learn direction
- Lessons gain "Open in Astrolabe" (mechanism: example ids where possible; arbitrary
stage specs need a spec-payload deep link — decide at implementation, watch hash size).
- Content growth (later): lessons keyed to examples ("what to try next with the
scatter"), draft/publish workflow, theming walkthrough. Until grown, keep nav billing
consistent with a two-lesson section.
## Implementation phases
**Phase 1 — connective tissue (app-side, one session):**
`#example-<id>` deep link (core parse + startup consumption + tests) · paste-a-spec door
(+ import line) on the onboarding canvas · learn links (About + canvas) · showpiece
example(s) in `CHART_EXAMPLES` with compile test · spec §01/§02 updates in-session.
**Phase 2 — landing overhaul (design-involved):**
New showcase block (#2) first — it is the thesis · editor-proof block (#3, staged
visuals, no Monaco import) · resequence/reweight (#4#7) · hero copy + live-affordance ·
objection + trust beats · themed-chart pass across every chart on the page · mobile/dark
pass.
**Phase 3 — learn growth (ongoing content):**
"Open in Astrolabe" from lessons · new lessons per the direction above.
## Parked / deferred
- Post-first-snippet discoverability (draft/publish, extract, theming are invisible until
stumbled on; no tours — against the app's grain) → parked in `docs/ux-second-pass.md`
for the batched council pass.
- Multi-device sync → separate exploration (see monetization/sync memo); landing copy
must not imply it.
- URL-encoded spec sharing (à la Vega editor) → plausible future feature if the sharing
objection keeps surfacing; product, not copy.
-272
View File
@@ -1,272 +0,0 @@
# Learn Section — Lesson Roadmap
_Point-in-time planning memo, 2026-07-04. Detailed briefs for the next lessons, written to
be authorable one at a time (draft → maintainer edit, like the why-astrolabe workflow).
Format for every lesson: the existing progression machinery — hook, `:::data`, staged
specs with diff highlighting, `:::sharp-edge`, a "take it further" closer that uses the
per-stage "Open in Astrolabe" links._
## Positioning
The Vega-Lite docs are an example gallery plus a property reference — hundreds of
_finished_ specs. Lessons that showcase charts compete with that and lose. What the docs
don't have, and where middle+ users plateau, is the **invisible machinery and its failure
modes**: where data actually flows, how selections resolve, why scales unify or don't.
Every lesson below is organized around a _misconception_, not a chart type. The
progression format (the path from almost-right to right) and the sharp edges (documented
failure modes) are the moat; keep both in every lesson.
## Tracks & ordering
Two informal tracks once the roster grows past ~4 lessons (add a `level` field to lesson
frontmatter; the index groups by it — "foundations" and "deeper", not course-like
numbering):
- **Foundations**: binning, labels-on-bars, long-vs-wide, data-flow.
- **Deeper**: linked-views, highlight-vs-filter, faceting, resolution, time,
interactive-binning.
Cross-link map (a lesson references another only once it exists): linked-views' "gap
math" stage → long-vs-wide; long-vs-wide → data-flow; highlight-vs-filter ← linked-views'
closer; interactive-binning ← binning's sharp edge; resolution ← faceting's scale caveats.
The sharp-edge blocks are accumulating into a corpus nothing else on the Vega-Lite
internet has; once there are ~8, an "edges" index page (auto-collected from lesson
frontmatter or the parsed callouts) becomes a destination of its own.
---
## 1. `highlight-vs-filter` — the two answers to a selection
**Thesis / misconception**: a selection can drive a view two fundamentally different ways
_highlight_ (conditional encoding: context preserved, scales still) or _filter_ (rows
removed: everything recomputes, scales included). Most people know one and reach for it
everywhere; the choice is the actual design decision.
**Scenario**: six product lines' weekly revenue — a spaghetti chart where the reader
cares about one line at a time.
**Stages**:
1. _spaghetti_ — six lines, one color scale; unreadable but honest baseline.
2. _+ point selection on the legend_ — `params: [{select: {type: "point", fields:
["product"], on: legend-binding}}]`; nothing reads it yet.
3. _+ highlight_ — `opacity: {condition: {param, value: 1}, value: 0.2}`: the chosen line
pops, the rest stay as context. Note: the y-scale did not move.
4. _+ the filter variant_ — same selection, second view (or swapped response) with
`transform: [{filter: {param}}]`: the y-axis re-fits the chosen line. Note the trade
explicitly: filtering _loses the comparison_ but gains resolution.
5. _polished_ — both responses side by side over one selection; titles that name the
difference ("in context" / "re-scaled").
**Sharp edges**: `empty` default makes condition + filter behave differently before any
click (highlight: everything full-opacity; filter: everything shown) — set `empty:
"none"` deliberately. Point selections toggle on re-click (shift-click accumulates);
that's `toggle` and it surprises people.
**Take it further**: change the condition channel from opacity to color/size; try
`select: {type: "point", on: "pointerover"}` for hover-highlight.
## 2. `long-vs-wide` — reshaping rows for the chart you want
**Thesis / misconception**: "my data is in columns" is a data-_shape_ problem, not a
chart problem. Vega-Lite wants long rows for encoding channels (`fold` gets you there);
row-wise arithmetic wants columns (`pivot` gets you back). SQL framing for the
analyst audience: fold ≈ UNPIVOT, pivot ≈ crosstab/GROUP BY columns.
**Scenario**: a spreadsheet-shaped budget — one row per team, columns `jan feb mar` —
then a two-step signup funnel where the metric is a _ratio between rows_.
**Stages**:
1. _the spreadsheet wall_ — wide data charted naively: one bar per team, months
inaccessible to color/facet. The failure is the hook.
2. _+ fold_ — `fold: ["jan","feb","mar"]` → key/value rows; suddenly month is an
encoding channel like any other.
3. _tidy names_ — fold's `as: ["month","spend"]`; real field names, temporal parse.
4. _+ pivot for row math_ — the funnel: long rows (step, count) pivoted to columns so
`calculate: datum.purchase / datum.visit` can produce a conversion rate per cohort.
5. _polished_ — both charts labelled; the note states the rule of thumb: _encode long,
compute wide_.
**Sharp edges**: pivot drops rows with missing keys silently — the `isValid` patching
dance (exactly what linked-views' gap stage does; link back). Fold keeps _other_ columns
duplicated per folded row — aggregate afterwards or double-count.
**Retro-link**: linked-views' "+ the gap math" note gains a pointer here once shipped.
## 3. `data-flow` — where your data actually flows
**Thesis / misconception**: transforms run in _array order_, and encoding-level
`aggregate`/`bin` run _after_ the transform array — so "why is my filter not working"
is usually "your filter runs at a different point in the pipeline than you think".
**Scenario**: percent-of-total by category — the one chart that needs the pipeline
understood, because it needs a total _alongside_ rows, not instead of them.
**Stages**:
1. _encoding aggregate_ — plain `sum` bar chart; fine, but a dead end for percent-of.
2. _transform aggregate_ — the same chart via `transform: [{aggregate}]`; identical
pixels, different pipeline position — now downstream transforms can read the result.
3. _+ joinaggregate_ — the star of the lesson: totals attached to every row (rows kept,
unlike `aggregate`), then `calculate` percent.
4. _order matters_ — move a `filter` before vs after the joinaggregate; percentages of
the filtered subset vs of the whole. Same transforms, opposite meanings.
5. _polished_ — percent-of-total bars with a `window` rank ordering.
**Sharp edges**: `window` without `sort` is row-order-dependent (works in the example,
breaks on real data); `frame: [null, 0]` means "start through current row" — the
cumulative default everyone copies without reading.
## 4. `labels-on-bars` — layering, taught by the most-searched task
**Thesis**: putting values on bars is the internet's most-asked Vega-Lite question, and
the answer is layer mechanics: a `text` mark sharing the bar's encodings, plus the
handful of properties that make labels sit right.
**Scenario**: a ranked horizontal bar chart (top categories by value) — the chart people
actually want labels on.
**Stages**:
1. _bars, sorted_ — includes the `sort: "-x"` idiom in passing.
2. _+ a text layer_ — same data, `mark: "text"`, x/y duplicated; labels land ON the bar
ends, ugly but working. Note how shared encodings could be hoisted to the layer root.
3. _+ placement_ — `align`, `dx`, `baseline`: labels just past the bar end.
4. _+ formatted_ — `format`/`formatType`, and a `calculate` for compact units (e.g.
"1.2k").
5. _polished_ — de-emphasized axis (the labels now carry the values; drop the x-axis
grid/ticks — say why: double-encoding).
**Sharp edges**: labels don't avoid each other or the bar end — there is no collision
avoidance in Vega-Lite; for inside-vs-outside placement use a `condition` on bar length.
Dual-axis via `resolve: {scale: {y: "independent"}}` looks adjacent but is a trap —
mention, defer detail to `resolution`.
## 5. `time` — the sharpest axis
**Thesis / misconception**: temporal data has two independent honesty problems — _when
is a date_ (timezone parsing: the off-by-one-day bug) and _what is a time bucket_
(`timeUnit` vs binning vs exact timestamps).
**Scenario**: daily signups spanning a year, authored as date-only strings — the exact
shape that triggers the UTC/local trap.
**Stages**:
1. _the off-by-one_ — date-only strings (`"2026-03-01"`) charted naively; for viewers
west of Greenwich every point sits on the previous day. Explain: JS parses date-only
strings as UTC midnight, then Vega-Lite renders in _local_ time.
2. _+ honest parsing_ — the fixes shown together: `utcyearmonthdate` timeUnits (stay in
UTC end-to-end) vs. explicit local parsing; pick one side and stay on it.
3. _+ timeUnit bucketing_ — `yearmonth` collapses days to months _in the chart_, no
transform needed; contrast with `timeUnit` as a transform when the bucket must feed
later steps.
4. _+ axis formatting_ — `axis.format` vs `axis.formatType`, and why tick labels lie
when format and timeUnit disagree.
5. _polished_ — a monthly chart that renders identically in Kyiv and California.
**Sharp edges**: the date-only/datetime parsing asymmetry (date-only → UTC, with-time →
local) is the single most-reported "bug" that isn't one; `timeUnit` without `utc` prefix
re-buckets per-viewer-timezone — dashboards that disagree between offices.
## 6. `resolution` — one legend or two
**Thesis / misconception**: who shares scales by default differs by composition kind —
**layer: shared; facet: shared; concat/repeat: independent** — and `resolve` is the knob.
Most "my colors don't match between panels" bugs are this table, unknown.
**Scenario**: the same two-metric dashboard composed three ways (layer, concat, facet),
watching the color scale and legends merge or split.
**Stages**:
1. _concat, two legends_ — the bug as the baseline: same field, two views, two legends,
possibly two color assignments.
2. _+ resolve shared_ — `resolve: {scale: {color: "shared"}}`: one legend, one truth.
3. _manual pinning_ — `scale: {domain: [...], range: [...]}` per view as the other
route (what linked-views does — call it out); when explicit domains beat resolve.
4. _layer's inverse problem_ — layered dual-metric where _sharing_ is the bug;
`resolve: {scale: {y: "independent"}}`, and the honest warning about dual axes.
5. _polished_ — the corrected dashboard with a note naming the default-by-kind table.
**Sharp edges**: axis resolution is separate from scale resolution (shared scale can
still draw two axes); legends for `condition`-driven encodings don't exist (conditions
have no legend — a recurring surprise after the highlight lesson).
## 7. `interactive-binning` — the promised binning sequel
**Thesis**: which knobs of a spec are _live_ (parameterizable) and which are
compile-time. Binning is the teaching case: `step`/`maxbins` are frozen; `extent` is
live.
**Scenario**: the delivery-times histogram from the binning lesson, upgraded to an
overview-detail pair.
**Stages**:
1. _the slider that does nothing_ — **show the failure live**: a `param` bound to a
slider, referenced where `step` wants a number; compiles, renders, slider moves,
bars don't. The strongest inoculation the format can deliver.
2. _the working knob_ — overview histogram with an interval brush.
3. _+ extent_ — detail histogram whose `bin: {extent: {param: "brush"}, maxbins: 20}`
re-bins inside the brushed range: coarse overview, fine detail.
4. _polished_ — labeled pair, brush styling, tooltips.
**Sharp edges**: recap step/maxbins immutability (now demonstrated, not asserted);
`extent` re-bins but does not _filter_ — pair it with a `filter: {param}` or the detail
view still draws out-of-range rows at the edges.
**Retro-link**: binning's sharp edge gains "see the sequel" once this ships.
## 8. `faceting` — the goldmine: small multiples and their caveats
**Thesis / misconception**: there are _three_ ways to repeat a chart — the `facet`
channel/operator (split by a field's values), `repeat` (split by _different fields_),
and hand-built `concat` — and most frustration comes from using one where another is
meant, then fighting its constraints.
**Scenario**: sales by region — first split by region (facet), then the same chart
repeated across _metrics_ (revenue, units, margin — repeat), showing why facet can't do
the latter.
**Stages**:
1. _the encoding facet_ — `encoding.facet` / `row` / `column`: one extra line, small
multiples with shared scales ("honest comparison for free" — link the landing's
showcase claim).
2. _wrapped + sorted_ — `columns: 3`, and sorting facets by a data value (`sort` on the
facet field def) — the "my panels are alphabetical but I want by total" fix.
3. _the operator form_ — `facet: {...}, spec: {...}`: same output, but now the child can
be a _layered_ spec — the form you need the moment panels contain more than one mark.
4. _repeat, not facet_ — the metrics case: `repeat: {column: [fields]}` +
`{repeat: "column"}` field references; facet cannot do this (it splits by values, not
by fields).
5. _polished_ — headers styled (`header` vs axis titles), spacing, independent y where
metrics differ in unit (`resolve` callback to lesson 6).
**Sharp edges** (the caveat goldmine — candidates, pick 23 for the block and push the
rest into stage notes):
- Facet children can't take `width/height: "container"` — faceted charts size by
per-panel `width`/`height` and ignore fit-to-container (the app's own fit modes fall
back to `pad`; arch 05 records the engine-side truth).
- A facet spec can't be layered _over_ (facet must be outermost; layer inside the child,
never outside).
- Selections in facets resolve per-panel by default — a brush in one panel doesn't
select in the others until `resolve: "union"`/`"global"` on the param.
- `header` vs `axis`: facet labels live on headers; styling them via axis config
silently does nothing.
---
## Authoring workflow
One lesson per session-slice: I draft the full markdown (scenario data included, specs
verified rendering via the dev server before review), the maintainer edits voice and
pedagogy, then it ships with its retro-links applied to earlier lessons. Order proposed:
**highlight-vs-filter → long-vs-wide → faceting → time → labels → data-flow → resolution
→ interactive-binning** (interest-first: interactivity and faceting are the section's
strongest differentiation; data-flow and resolution are load-bearing but drier, better
once the section has gravity).
-391
View File
@@ -1,391 +0,0 @@
# Lyra — Repository Review & Improvement Ideas
> **Status:** review complete (2026-06-10). Source: `vega/lyra` cloned to
> `reference/lyra` (branch `lyra2019`, last commit `fb284bf`, 2021-05-14 — unmaintained).
> **Purpose:** mine Lyra, UW IDL's _direct-manipulation_ Vega design environment, for
> interaction patterns that could improve Astrolabe's chart-building experience. This is a
> companion to [`chart-builder-research.md`](./chart-builder-research.md) (which already
> seats Voyager, also from UW IDL); Lyra is the _no-code authoring_ sibling we hadn't read.
> **Bottom line:** take Lyra's **interaction primitives and data-context UI**, not its
> architecture. Astrolabe's chart-choice intelligence (Tier B) is already _ahead_ of Lyra;
> the gaps Lyra exposes are about **how the user touches fields and sees their data**, plus
> **making our guidance actionable**.
---
## 1. What Lyra is, and the one fact that decides what transfers
Lyra lets you build a custom visualization **without writing code** — drag data fields onto
graphical mark properties, position marks with connectors, resize with handles, and define
interactions by demonstration. It targets **raw Vega** (not Vega-Lite), is a React + \*\*Redux
- Immutable.js** app on a **d3 v3\*\* runtime, and is a 2019-era research prototype that was
never finished ("does not contain all functionality" — README) and has been dormant since
2021.
The single most important architectural fact:
> **Lyra's source of truth is a _decomposed GUI model_; the Vega spec is a one-way _export_.**
Lyra keeps the design as a Redux store of typed primitives — `Mark`, `Scale`, `Guide`
(axis/legend), `Pipeline`/`Dataset`, `Signal`, `Interaction`, `Widget`
(`src/js/store/factory/*`) — and `ctrl/export.ts` (`exporter()`, 546 lines) serializes that
store **into** a Vega spec on demand. There is no inverse: Lyra **cannot import and edit an
arbitrary Vega spec**. The GUI _is_ the document.
**Astrolabe is the exact inverse.** Our source of truth is the **JSON spec** (a snippet);
the builder is a _view that emits_ JSON (`buildChartSpec`/`buildSnippetSpecText` in
`src/core/chart-builder.ts`), and the user can always drop to Monaco and hand-edit. For a
**snippet manager** this is the right call — Lyra's model would forbid the round-trip our
whole product depends on. So we take **none of Lyra's architecture** and **all of the
transferable lessons are at the interaction layer**, where the GUI-vs-JSON question doesn't
matter.
This also reframes the comparison: Lyra is not a "better chart builder" to catch up to. On
**chart-choice intelligence** Astrolabe is already further along — Lyra has no
recommendation logic at all (that was Voyager's job, which we already mined). Lyra's value to
us is the parts of the _authoring experience_ we haven't built: field-first interaction, live
data context, and one-click assistance.
---
## 2. Where Astrolabe already leads Lyra (so we don't chase the wrong things)
| Dimension | Lyra | Astrolabe (today) |
| ------------------------- | ---------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
| Chart-choice guidance | none — manual mark + manual encoding | Tier B: smart default mark, valid-type locking, Size discipline, non-blocking warnings (`builderWarnings`) |
| Transforms in the builder | data-pipeline only (filter/formula/aggregate/lookup), not per-encoding | per-channel Aggregate / Bin / `timeUnit`, chart-level Sort / Stack |
| Target language | raw Vega (verbose, low-level) | Vega-Lite (the right altitude for "pick mark + channels") |
| Import / round-trip | impossible (GUI is the doc) | native — JSON is the doc, builder emits it |
| Stack | d3 v3 / Redux / Immutable / class components (stale) | React + Zustand + CSS Modules (current) |
The takeaways below deliberately **avoid** re-building anything in the right column.
### 2.1 — Counterpoint: Lyra exposes a far larger _control surface_
On chart-choice _intelligence_ we lead, but on **raw number of direct controls** Lyra is well
ahead. Selecting a primitive (mark / scale / axis / legend) opens an inspector of literal
knobs — and the inventory dwarfs our builder's:
| Primitive (`components/inspectors/*`) | Lyra's direct controls |
| ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Mark — **Point** (`Symbol.tsx`) | x, y, **shape**, size, fill **color**, fill **opacity**, stroke color, stroke width |
| Mark — **Bar/Rect** (`Rect.tsx`) | X extent (start/center/end _spatial preset_), Y extent, fill color/opacity, stroke color/width |
| Mark — **Line** (`Line.tsx`) | x, y, stroke color/width, **interpolate (curve)**, tension |
| Mark — **Area** (`Area.tsx`) | baseline extent, fill color/opacity, stroke, interpolate, tension, orient |
| Mark — **Text** (`Text.tsx`) | text (expr/template), **font face/size/weight/style**, color, opacity, x/y, dx/dy offset, **align**, baseline, **rotation** |
| **Scale** (`Scale.tsx`) | **type** (linear/log/time/ordinal/band/point), **zero/nice/clamp/reverse/round**, **domain** (auto fields or manual min/max / value list), **range / colour scheme** (tableau, category10/20, sequential, diverging), padding(Inner/Outer) |
| **Axis** (`Axis.tsx`) | orient, axis-line colour/width, **title text + font/size/colour/offset**, **label size + angle + colour**, **grid on/off** + colour/opacity/width, **tick count/size/colour** |
| **Legend** (`Legend.tsx`) | orient, border, title text/size/colour, label font, symbol shape/size/fill/opacity, gradient |
And the structural multiplier: **every mark property is _droppable_** — each is _either_ a
literal value _or_ a bound field. Lyra therefore has **no fixed channel set**; you can bind
data to strokeWidth, opacity, shape, fontSize, dx… Our builder caps at **X / Y / Color /
Size** and exposes **zero** styling, scale, axis, or legend controls.
**Why this is not simply "Lyra is more capable":**
1. **It's a _builder-surface_ gap, not a capability gap.** Everything above, Vega-Lite
expresses too (mark config, `scale`/`axis`/`legend`, channels like `opacity`/`shape`/
`theta`/`row`/`column`). In Astrolabe you reach it by **hand-editing the JSON in Monaco**.
Lyra had **no editor and no round-trip** — the GUI was the _only_ way to touch the spec, so
it was forced to surface every knob. We split the work on purpose: a small **guarded**
encoding builder + a first-class JSON editor for the long tail.
2. **Lyra's breadth carries zero intelligence and high cost** — no defaults, no valid-type
locking, no guardrails, on raw low-level Vega, in an unfinished inspector.
3. So the question is not "match Lyra's control count" but **"which slices of that surface are
common _and_ awkward enough in JSON to deserve promotion into the guarded builder?"**
**Slices worth promoting (ranked):**
- **Constant-value styling** via the value-or-field channel (§3.3): fixed fill colour /
opacity / point shape / line curve without touching JSON — the most common "tweak the look."
- **Colour-scheme picker** on the Color channel (categorical / sequential / diverging) + a
measure-axis **`zero` / `log`** toggle. Palette changes are common and fiddly in JSON.
- **A few more channels** — `opacity`, `shape`, `tooltip` (already auto-enabled), **`theta`**
(unlocks pie/donut → a true part-to-whole), **`row`/`column`** facets (backlog **B8**). These
_are_ the field-shelf model (§3.4): adding channels and adding the shelf are one project.
- **Light axis/legend text** — custom **axis title**, **legend title / hide**. Common, low-risk.
**Guardrail:** promote a control only when it is _both_ common _and_ awkward in JSON.
Otherwise the clean guarded builder drifts toward Lyra's fiddly everything-inspector — the
very complexity that helped leave Lyra unfinished. Styling/scale/axis breadth for its own sake
belongs in Monaco, not the builder.
---
## 3. High-value ideas to adopt (ranked, in-scope, mapped to our code)
### 3.1 — Make `builderWarnings` **actionable** (Lyra's `Hints`) — _highest value, smallest lift_
Lyra's hint system (`components/hints/Hints.tsx`) is a contextual nudge that carries an
**action**: `{title, text, action, action_text}` — a one-click button that _applies the fix_
and clears the hint. Our guidance is strictly advisory text today: `BuilderWarning`
(`chart-builder.ts:333`) renders as a passive hint with no affordance.
Several of our existing warnings have an obvious one-click remedy and should become
**suggested actions** (Carbon "inline notification with action" / NN-g "make the system do the
work"):
- _"…draws one mark per row; aggregate the measure"_ → **[Aggregate as Sum]** sets the
measure channel's `aggregate`.
- _"…long labels; flip to a horizontal bar"_ → **[Swap X/Y]** (we already have the action —
just wire it to the hint).
- _"Two measures usually read better as a scatter"_ → **[Switch to Point]**.
- _"Area split into many colour series…"_ → **[Stack]** or **[Remove colour]**.
This keeps the Tier-B "guarded, non-blocking" philosophy — the action is an _offer_, never a
forced change — while turning a wall of advice into a guided improvement loop. Concretely:
extend `BuilderWarning` with an optional `fix?: { label: string; apply: (c) => BuilderConfig }`
(pure, lives in `chart-builder.ts`, fully unit-testable), and render the button in
`ChartBuilderModal`. **Run the copy + affordance through `/council`** (it auto-fires on
guidance copy and new interactive affordances).
### 3.2 — A live **data-table preview** in the builder (Lyra's `DataTable`) — _closes a real gap_
Confirmed gap: neither `ChartBuilderModal` nor `DatasetsModal` ever shows the **actual data
rows**. The builder picks columns from a dropdown and renders a _chart_ preview, but the user
never sees the values they're encoding. Lyra always shows the data — `DataTable.tsx` is a
paged, scrollable grid with a **per-field type icon** in each column header and hover
inspection.
A compact, read-only **data preview** (first N rows, per-column type chip in the header)
would let the user sanity-check "is this column really a date / really numeric" _before_
building — exactly when type inference is most likely to surprise them. Good homes:
- a collapsible "Data" strip in the builder's left pane (under the dataset name), and/or
- the Datasets manager, where a dataset's shape is otherwise invisible.
We already compute per-column type + cardinality + extent in `profile.ts` (the A3/A4
profiling extension), so the header chips are free; we only need the row sample. Keep it
**read-only** — editing data is out of scope (and is where Lyra's pipeline complexity lives).
### 3.3 — The unified **Property** primitive: one control = value · field · scale (Lyra's signature UI)
Lyra's best idea is `components/inspectors/Property.tsx` — a **single droppable control** that
is, depending on binding state, _either_:
- a **literal value** editor (number / color / range / select / text — `FormInputProperty`), **or**
- a **field chip** (drag a column onto it; chip shows source-vs-derived, click to unbind), **or**
- a **scale chip** (the scale the field flows through; click to unbind).
Every mark inspector is then just a **declarative list** of these — e.g. `Symbol.tsx`
declares Position/Geometry/Fill/Stroke groups as ~10 `<Property>` lines. One primitive, one
drop-to-bind gesture, one unbind gesture, reused for _everything_ including the transform
expression fields (`Filter.tsx`, `Formula.tsx` reuse the same `Property` in autocomplete
mode).
For Astrolabe this is the conceptual model for a **richer channel row**: a channel is "a field
**or** a constant **or** (future) a datum," and Vega-Lite encodes exactly that distinction
(`field` vs `value` vs `datum`). Today our channel row is field-only (column dropdown +
type segmented control). Adopting the Property model would let a channel also hold a **constant
value** (e.g. a fixed color/size) with one consistent control and a chip showing the binding
kind. This is a medium lift and a genuine capability gain, and it generalizes cleanly if we
ever add channels beyond X/Y/Color/Size.
### 3.4 — Field-first interaction: a **field shelf** you drag onto channels (Lyra drop-zones + Voyager)
Both UW tools converge on **fields as the primary objects**: Lyra drags fields onto mark
properties; Voyager has a field list with type chips you add to channel shelves. Astrolabe is
**channel-first** (pick a channel, then choose its column). A **field shelf** — the dataset's
columns listed with type chips, dragged or clicked onto channels — inverts that to match how
people actually think ("I have these fields; where do they go?").
This is the largest interaction shift here and overlaps our **deferred Tier-C / faceting**
work (`chart-builder-research.md` §8 B8/C). Recommend treating it as the **interaction
substrate for Tier C**, not a standalone task: when we build the intent-first front door and
add Row/Column facet channels, a field shelf is the natural way to assign many fields across
many channels. Note now; sequence with Tier C.
### 3.5 — One-click **type cycling** on the field chip (Lyra's `FieldType`)
Lyra's `FieldType.tsx` is a tiny, nice touch: the field's **type icon is the control**
click it and it cycles N→O→Q→T (within the field's valid set). We already show a type
indicator next to each column option and a full `N|O|Q|T` segmented control per channel. The
cheap win is making the **per-option type chip itself interactive** (and, in a future field
shelf, the chip on each field), so type is a property of the _field_ the user can toggle in
place — fewer controls, the same guard (only valid types, via our `validFieldTypes`).
### 3.6 — Inline **expression validation** via Vega's own parser (Lyra's transform technique)
Lyra validates a filter/calculate expression with `parseExpr` from `vega-parser` **before
committing** it to the model (`Filter.tsx`, `Formula.tsx`), catching malformed expressions at
the keystroke. We don't expose raw expressions in the builder, so this isn't an immediate
builder feature — but the **technique** (validate Vega/VL expression strings with the
library's parser and surface the error inline) is directly reusable for our Monaco editor's
diagnostics and for any future calculate/filter affordance. Worth recording in
`architecture/08` (vega-editor techniques) as a known approach; Lyra even left the
"_indicate error in parsing_" TODO unfinished, so we'd be completing the idea, not copying it.
### 3.7 — **Starter examples** gallery (Lyra's `ExampleGallery`) — _cheap, optional_
Lyra's toolbar has an `ExampleGallery` modal that `hydrate()`s a curated example into the
editor — a "start from something" affordance. Astrolabe's snippet library is the natural home
for a small set of **curated starter snippets** (one per FT intent category we cover —
Magnitude/Bar, Change-over-Time/Line, Correlation/Point…). Low effort, improves first-run and
doubles as living documentation of what the app does well. Lower priority than 3.13.2.
### 3.8 — **Per-chart export: image + standalone spec** (Lyra's `Export` menu) — _biggest miss; cheap_
The one squarely-useful idea I almost overlooked. Lyra's toolbar `Export` (`components/toolbar/Export.tsx`)
offers **PNG**, **SVG**, **JSON spec**, and a **standalone HTML** scaffold — using
`view.toImageURL(type)` on the live Vega view.
Astrolabe's export today is **workspace-JSON backup only** (`docs/spec/08`) — there is **no way
to get a single chart out** as an image, as its own spec file, or onto the clipboard. Yet our
renderer already holds exactly the Vega `view` Lyra exports from (`services/chart-renderer.ts`,
`renderer: 'svg'`), so this is a handful of lines:
- **Copy spec** (clipboard) and **Download `.vl.json`** for the active snippet — the most-asked
"get my chart out" actions, both trivial given the snippet _is_ the spec.
- **Download PNG / SVG** of the rendered chart via `view.toImageURL('png'|'svg')` — drop a chart
into a doc/slide without a screenshot.
- (Optional) **standalone HTML** — a self-contained vega-embed page; ties to the
share/"private-move" direction in `monetization-and-sync-exploration.md`.
Home: a snippet-level "Export / Share" affordance (library row action or editor toolbar),
distinct from the workspace Export. High value-to-effort; should jump near the top of the queue.
### 3.9 — Scale / axis / legend as **auto-derived _override_ panels** (Lyra's `bindChannel` pipeline)
If we ever promote scale/axis/legend controls (§2.1), Lyra shows the right _editing model_.
Binding a field in Lyra runs a pipeline (`actions/bindChannel/`): `parseScales` + `parseGuides`
**auto-materialize** the scale and axis/legend, `aggregateDependencies` re-points them when the
data is aggregated, and **`cleanupUnused`** garbage-collects scales/datasets no longer
referenced. The user edits a scale/axis only to _override_ an auto-derived default.
Vega-Lite already auto-derives scales/axes/legends from encodings, so we get the inference for
free — the borrow is purely UX: surface scale/axis/legend as **optional override panels that
start empty (inheriting VL defaults) and only emit JSON when touched**, and **clearing a channel
drops its overrides** (the `cleanupUnused` lesson — no orphaned `scale`/`axis` config left behind).
This is the discipline that keeps §2.1's promoted controls from bloating the output spec.
### 3.10 — Expression **autocomplete with field/signal chips** (Lyra's `AutoComplete`)
`components/inspectors/AutoComplete.tsx` is a `contenteditable` expression field that
autocompletes **dataset field names** (and signals), rendering each `datum.field` reference as a
styled, non-editable **chip**. For any future filter/calculate affordance — and for Monaco
completions in expression positions — autocompleting the dataset's own column names (we already
have the schema from `profile.ts`) is the high-value half; the chip rendering is polish. Niche
until we expose expressions, but cheap to remember.
### 3.11 — **Builder-state undo/redo** (Lyra's `redux-undo`) — _low priority_
Lyra makes the whole design undoable (`redux-undo`, global `vis.present`/`past`). Our builder
modal has **no undo** (Monaco covers the _editor_, not builder config). A session-scoped
undo/redo — or even a lighter **"reset to smart defaults"** — would soften experimentation in
the builder. Low priority: the builder is a short-lived modal and the smart default already
gives a sane starting point. Noted for completeness, not urgency.
### 3.12 — **Dataset-level transforms: Filter / Calculate** (Lyra's data pipeline) — _the cleanest gap_
The transform dimension where Astrolabe and Lyra differ most. The key fact is that **Vega-Lite
has two transform layers**:
1. **Inline encoding transforms**`aggregate` / `bin` / `timeUnit` / `sort` / `stack` written
straight onto a channel. **This is the layer Astrolabe's builder uses**, and uses _well_
per-channel and guarded, arguably cleaner than Lyra (whose Aggregate is a separate pipeline step).
2. **Top-level `transform: []`** — dataset-level operations applied in sequence before encoding:
**`filter`**, **`calculate`**, **`lookup`**, window, fold, pivot, regression… **Astrolabe's
builder exposes _none_ of these.** Lyra's data pipeline is built almost entirely _around_ them
(`components/pipelines/transforms/`: Filter, Formula, Lookup, Sort — each an ordered card in a
`TransformList` on `datasets.<id>.transform`).
The operations in layer 2 have **no encoding-level shorthand** — there is no way to express them
except by hand-editing the JSON. The two worth borrowing:
- **Filter _(highest value)_** — row filtering (`transform: [{filter: …}]`). The single most
common data-shaping need, and the builder is _already telling users to do it_ — three existing
`builderWarnings` literally say "filter to fewer categories / to non-negative values"
(`chart-builder.ts:475,476,511`) while offering no way to. Guarded form: a **field + operator +
value** predicate shelf (Voyager-style), no expression required for the common case; power form:
a raw `datum.…` expression validated with Lyra's `parseExpr` technique (§3.6) + field
autocomplete (§3.10). VL applies top-level transforms _before_ encoding aggregation, so "filter
raw rows, then the chart aggregates" is the natural — and usually wanted — default; filtering on
an _aggregated_ value (HAVING) is the advanced case, defer it.
- **Calculate / derived field** — `transform: [{calculate: "datum.a / datum.b", as: "ratio"}]`,
exactly Lyra's `Formula`. The new field then appears in the column dropdowns like any other.
High value, second to Filter.
- **Lookup (join a second dataset)** — Lyra's `Lookup` joins one pipeline's fields into another by
key. Astrolabe references **one dataset per snippet**, so this is a larger data-model change —
note and **defer**.
**Where it lives:** Lyra keeps these in a separate _dataset_ pane, distinct from the encoding
inspector — the right instinct. In Astrolabe they belong in a **"Data" section in the builder's
left pane, above the channels**, paired with the §3.2 data-table preview: _here are your rows
[+ Filter] [+ Calculate] → now encode them_, with the preview table updating live as filters
apply. This composes cleanly and keeps the channel rows about encoding, not data prep.
**Scope fit:** a field+operator+value filter shelf is squarely Tier-B "smart + guarded," closes
the loop on warnings the builder already emits, and is arguably **higher value than faceting (B8)**
— it should enter the §8 backlog as a new first-class item (it is absent today).
---
## 4. Anti-recommendations — what to deliberately **not** take from Lyra
These are as load-bearing as the adopt list; Lyra is a research prototype and several of its
defining features are traps for us.
- **The decomposed GUI-as-document architecture** (`store/factory/*` + one-way `export.ts`).
It forbids the spec→builder round-trip our product is built on. Our JSON-source-of-truth +
emit model is correct; keep it.
- **Interaction-by-demonstration** (`ctrl/demonstrations.ts`**1454 lines** that synthesize
raw Vega signals/selections from brush/click/hover, plus `signals.ts`/`listeners.ts`).
Impressive research, wrong layer for us: **Vega-Lite's `params`/selections** are the right
abstraction. _If_ we ever add interactivity, expose VL selections as a small guarded builder
action — never port Lyra's signal generator.
- **The direct-manipulation canvas** (handles, connectors, `manipulators.ts`,
`transforms/manipulators/*`). Enormous surface tied to the Vega-runtime model, and it
competes with — rather than complements — our JSON-first-with-guardrails value proposition.
- **The stack**: d3 v3, Redux, Immutable.js, `react-modal`, class components, `datalib`. We're
React + Zustand + CSS Modules with our own modal registry (`architecture/03`). **Take ideas,
read no code into the repo.** (Per `AGENTS.md`: no shared lib, patterns adapted not imported.)
- **The full data-pipeline editor** (lookup/aggregate/formula transform _chains_ as a
first-class pipeline UI). Our per-channel transforms cover the in-scope need; a general
pipeline editor is a different, much larger product.
---
## 5. Recommended sequence (mapped to the existing backlog)
> **Consolidated (2026-06-10):** this sequence is now merged with
> `chart-builder-research.md` §8 into the forward plan at
> [`chart-builder-enhancement-scope.md`](./chart-builder-enhancement-scope.md) (Tier-C
> target). The list below is the original Lyra-side reasoning; the scope doc §4 is the
> authoritative build order.
Slot these into `chart-builder-research.md` §8 rather than inventing a new track:
1. **Actionable hints** (§3.1) — extend `BuilderWarning` with an optional pure `fix`; wire the
four obvious ones; `/council` the copy. _Small, high-value, pure-core + thin UI._ **Do first.**
2. **Per-chart export** (§3.8) — Copy spec / Download `.vl.json` / PNG / SVG for a snippet, from
the `view` the renderer already holds. _Highest value-to-effort; near the top._
3. **Filter (+ Calculate) dataset transforms** (§3.12) — a guarded field/operator/value filter
shelf (then a derived-field calculate) in a "Data" section above the channels. _Closes the gap
the builder's own warnings point at; not in the backlog today; arguably > faceting._
4. **Data preview** (§3.2) — read-only row sample + header type chips in the builder (and
Datasets manager); reuse `profile.ts` stats. _Closes a confirmed gap; mostly UI; pairs with #3._
5. **Value-or-field channels via the Property model** (§3.3) — let a channel hold a constant
`value`, not just a `field`; one consistent control. _Capability gain; medium._ Pairs with the
§2.1 control-promotion shortlist (colour scheme, `zero`/`log`, axis/legend titles).
6. **Inline expression validation + field autocomplete** (§3.6, §3.10) — record the `parseExpr`
technique in `architecture/08`; apply when the Filter/Calculate expression mode (#3) lands. _Build with the feature._
7. **Field shelf + in-place type chip** (§3.4, §3.5) — design as the **Tier-C / faceting +
added-channels substrate** (§2.1: shape/opacity/theta/facets), not standalone. _Largest; with Tier C._
8. **Starter examples** (§3.7) — curated seed snippets, one per covered FT intent. _Cheap, optional._
9. **Scale/axis override panels + builder undo** (§3.9, §3.11) — only alongside control promotion;
override-panels-that-emit-only-when-touched, with orphan cleanup. _Deferred polish._
Verification for each lands the usual way: pure rules get `chart-builder.test.ts` cases;
UI/affordance changes get a manual pass against the live builder (a green build proves nothing
about what the user sees — `AGENTS.md`). Items 1 and 3 are mostly `src/core/` and squarely fit
our "core-first, tested hardest" rule.
---
_Citations are to `reference/lyra/src/js/…` (cloned, branch `lyra2019`). Lyra and Voyager are
both UW IDL; Voyager is already seated in `chart-builder-research.md`, so this review focuses
on the no-code-authoring ideas Voyager doesn't cover. Lyra is an engineering/interaction
source, not a council seat._
@@ -1,186 +0,0 @@
# Monetization & Sync — Exploration
> **Status:** Exploration, not a commitment. Captured 2026-06-10 from a strategy
> conversation. Nothing here is scheduled or in the plan; this is a memo to return to
> later so the _reasoning_ — not just the conclusion — survives.
>
> **Question:** If Astrolabe were ever monetized — e.g. a login that stores encrypted
> snippets in a database — what would that look like, technically and as a product?
>
> **Short answer:** The naive version (mandatory account, cloud-stored library) betrays
> the project's stated identity and isn't worth building. The version that survives
> scrutiny is the _opposite_ of a database: **private "bring-your-own-cloud" sync** that
> moves the user's own library between their own machines through storage they already
> control, with no server, no account, and no data custody. It's the smallest price and the
> smallest severity — and it barely dents the SOUL. Charging for it, if at all, is a
> one-time fee or donations, not a subscription.
---
## 1. The tension this collides with
A login + encrypted-DB system isn't a neutral feature add. [`SOUL.md`](../../SOUL.md) makes
"no account" a **named value**, and lists the opposite under _What We're Not_:
- Value #5, _Own Your Data_: "Your library is a file you control, **not a row in someone's
database**."
- _Not a collaboration platform_: "No multi-user, **no sync**, no comments."
- _Not a server app_: "**No account system.** No backend, no rendering service."
So there are really two different proposals hiding in the question:
1. **Mandatory account, cloud-stored library** — monetizes by making the cloud the _home_
of the data. This turns "a file you control" into "a row in our database," the exact
thing the product is defined against. **Rejected** regardless of revenue.
2. **Optional, opt-in sync layered on a local-first app that still works fully offline with
no account** — monetizes _without_ touching the soul. This is the only branch worth
exploring, and it's well-trodden (Obsidian: free local app, paid Sync/Publish; the free
local app _is_ the marketing for the paid layer).
Everything below lives in branch 2. If branch 1 is ever wanted, the honest move is to
change `SOUL.md` **first**, deliberately — not to let the product drift into it.
---
## 2. The options, and why most were set aside
| Option | What it is | Verdict |
| ------------------------------- | ----------------------------------------------------------------- | ---------------------------------------------------------------- |
| Mandatory account + cloud DB | Cloud is the canonical home of the library | **Rejected** — betrays the SOUL outright |
| Hosted E2EE sync (subscription) | We run a zero-knowledge server holding ciphertext blobs | **Too heavy** — see §4; disproportionate at our scale |
| Publish / embed | A snippet (or library) opens at a shareable URL | **Deferred** — bigger soul-violation + contested market; see §5 |
| **Private-move BYO-cloud sync** | Library rides the user's _own_ cloud between their _own_ machines | **Chosen direction** — smallest price, smallest severity; see §6 |
---
## 3. Build vs. "buy" vs. BYO — the real axis
"Buy the sync engine" was a confusing phrase: it never meant _pay money_. The recommended
engines (Evolu, Jazz) are themselves open source. The axis that matters is three rungs:
1. **Build it yourself** — write the crypto, the sync protocol, and the server. A sync
engine is doing six jobs, and each is a place to silently lose or leak data: change
tracking, push/pull, conflict resolution, encryption + key recovery, auth, and the
server. **Building the crypto/sync core yourself is one of the most bug-prone things in
software** — failures are catastrophic _and_ silent ("a library quietly corrupts and the
user notices when it's gone"). Wrong rung for a first foray.
2. **Open-source engine, you host it** — the hard logic is the library's (Evolu = local-
first + E2EE + SQLite, the closest fit; Jazz = adds auth/permissions; PouchDB+CouchDB =
battle-tested replication but _not_ E2EE by default). You run a server (Cloudflare
Workers keeps fixed cost near zero).
3. **Open-source engine, someone else hosts it** — you pay them to run the server; you
still write the client. The _only_ rung that costs money, and all it buys is "not
running a server."
So open source absolutely does it. Money only ever buys away the operational burden — and
the chosen direction (§6) removes the server entirely, so it doesn't even arise.
---
## 4. Why charging money is the heavy part (not the code)
The engine is the easy 20%. Taking payment changes the _category_ of the project, inheriting
obligations that have nothing to do with code:
- **Data custody, forever.** You hold the canonical copy. A server loss or a sync-corruption
bug is _their work gone, and they paid you to keep it safe._ E2EE makes this **worse**:
you can't read the ciphertext, so you can't inspect or repair damage either.
- **The "can never walk away" tax.** A free local app is static files — stop touching it and
it keeps working. A paid server-backed service breaks for every paying customer the day
you stop paying the hosting bill. You've converted "ship it and move on" into "responsible
indefinitely."
- **Uptime, support, and the forgotten-passphrase trap.** With E2EE, "recover my data" has
the answer "I can't," and users will be angry.
- **Billing + tax + legal.** International VAT/sales-tax (offload to a merchant-of-record
like Paddle/Lemon Squeezy), plus privacy policy, ToS, account deletion.
**The proportionality insight — and it cuts against scale.** Almost every obligation above
is _fixed, not per-user_. So the economics are **worst** at small scale: maximum fixed
responsibility, minimum revenue to justify it. 50 users × $5/mo = $250/mo against a forever
guarantee of their data's survival, support, on-call, and never abandoning it. For a small,
passionate audience and a builder who isn't chasing profit, full paid-custody sync is
plausibly a _bad trade even when well-intentioned._ The danger was never "money grab" — it's
that the responsibility dwarfs both the money and the community it serves.
---
## 5. The Vega-editor insight (and how it defuses "publish")
The Vega editor's **share** feature is _itself_ bring-your-own-cloud: "share" doesn't write
to a Vega server — it writes to _your_ GitHub Gist and hands you a URL that renders that Gist
**client-side** in anyone's browser. The editor is a pure client; GitHub is the storage.
Two consequences:
1. "Like the Vega editor, but for a whole library" and "BYO-cloud" are the **same idea**
and the tool Astrolabe descends from already proves it works with zero server and zero
custody.
2. It defuses the earlier worry that _publish_ violates the SOUL's "no rendering service."
It only does if _we_ run the renderer. If a snippet lives in the user's own storage and
renders **client-side in the viewer's browser**, there's no backend we operate and the
data still belongs to the user. The SOUL survives.
Publish was still **deferred** (not killed): bigger soul departure than private sync, and it
walks into Datawrapper/Flourish's contested, free-tier territory. Private-move is the cleaner,
less-contested wedge.
---
## 6. The decision: private-move BYO-cloud sync
The chosen direction. The point is **me getting my own library onto my other machine**
not showing charts to others. Storage is private; there is no "viewer," just me.
Why it's the smallest price: it's barely a new system. Private-move sync is essentially
**"continuous export/import to a file the user holds, plus a merge rule."** The export
format already exists, so this adds a live file handle and a conflict rule on top of
something shipped — no infrastructure, no account, no encryption-key-recovery burden. The
user's own cloud supplies the auth and the durability for free.
Three decisions define the whole feature:
1. **Transport.** The user points Astrolabe at a file (via the File System Access API) that
lives inside their Dropbox/iCloud/Drive folder. Astrolabe reads/writes the library doc
there; _their_ cloud moves it between machines. We never see the bytes.
**Caveat:** the File System Access API is Chromium-only — on Firefox/Safari, fall back to
the manual export/import already shipped. So "automatic" is a Chromium upgrade over a
baseline that works everywhere.
2. **Conflict rule.** Two machines edit offline, both write the file. Per-snippet
last-write-wins with a version stamp, surfacing a visible "conflicted copy" duplicate
when stamps clash (Dropbox's own behavior — and exactly SOUL value #6, _predictable, not
clever_). This is the only genuinely new logic, and it belongs in `core/` where it's
testable.
3. **Format.** Essentially the existing export doc, perhaps with per-snippet version stamps
added. An extension of a schema, not a new one.
**Monetization, if any:** a **one-time purchase** (fits "not a money grab," no recurring-
billing or expired-subscription support load, matches an audience that distrusts
subscriptions), or **donations / GitHub Sponsors** for pure sustainability that gates
nothing. Not a subscription.
---
## 7. SOUL impact — minimal
This barely touches the SOUL, which is the point. _Local-by-default_, _own-your-data_,
_no-account_, _no-server_ all stay literally true — "your library is a file you control"
becomes **more** true, since it's now a real file in the user's own cloud. The only line
needing softening is "no sync," and single-user move-my-own-library isn't the multi-user
**collaboration** that line was written to exclude. The deliberate SOUL amendment shrinks to
roughly one sentence.
---
## 8. If/when this is revisited — next steps
- **Validate the need first, for free.** Export/import is already a manual sync ("export,
drop in Dropbox, import on the other machine"). If people won't do _that_, they won't pay
for the automatic version — and you've learned it without building anything.
- **Map the three touch-points** before committing: the `app/infrastructure/` file-handle
adapter, the `core/` merge rule (with tests), and the one-sentence `SOUL.md` carve-out.
- **Decide the conflict UX deliberately** (likely a `/council` question): how the
"conflicted copy" surfaces to the user.
- **Never DIY the crypto** if this ever grows toward hosted/E2EE — reach for Evolu/Jazz.
- **Keep the core free, local, and offline forever.** It's both the soul and the growth
engine; the moment a power user hits a paywall on the thing the app _is_, the word-of-mouth
that local-first products live on is lost.
@@ -1,115 +0,0 @@
# Multi-view data model — scope & plan
Astrolabe authors arbitrary Vega-Lite, including **composed** specs (`layer`,
`hconcat`/`vconcat`/`concat`, `facet`, `repeat`). Most of the spec-structure
machinery already handles composition; the data-facing features carried
single-view assumptions. This memo records the assessment, the data-model
contract that anchors the work, and the milestone plan to make multi-view support
durable. The guiding constraint: **extend Vega-Lite, never break it** — every
native data form must keep working.
## The data-model contract
A dataset reference is exactly Vega-Lite **named data**: a `data` block with a
string `name` and no `values`/`url`/generator key, whose name the spec does not
self-define via top-level `datasets`. This mirrors Vega-Lite's `isNamedData`
(`reference/vega-lite/src/data.ts`). The classification is owned by
`core/spec-data` (`classifyData`, `libraryRefName`); reference extraction
(`spec-refs`), rename (`spec-refs`), and render-time resolution (`rendering`) all
route through it. See `docs/architecture/07` §3.1.
Library references resolve to inline data before embedding
(`core/rendering``prepareSpecForRender`); a self-defined `datasets` name is
left for Vega-Lite to resolve natively.
## Assessment: already multi-view vs. single-view assumptions
**Already composition-aware** (recurse all view operators): ref extraction/rename
(`spec-refs`), reference resolution + fit-mode (`rendering`), structural wrap/
unwrap/add-view (`spec-transforms`, `spec-cursor`, `spec-insert`), derived-field
collection (`spec-fields`), config baking (`spec-config`), standalone export
(`chart-export`, reuses `prepareSpecForRender`).
**Single-view assumptions** (the work):
- **Editor data context** (`app/services/active-dataset`) resolves _one_ dataset
for the whole draft (first ref, or first inline data), with no notion of which
view the cursor sits in. Completion/hover/inlay (`spec-dataset-hints`) and the
facet/repeat field defaults (`spec-transform-actions`) therefore offer the wrong
view's columns in a composition whose views bind different datasets.
- **Extract inline data** (`app/stores/ExtractStore`) lifts only the top-level
`data` block.
- **Data inspector** (`DataInspector`) surfaces one input + one resolved table; a
composition produces several `source_<n>`/`data_<n>`.
- **`deriveSnippetName`** (`core/snippet`) reads top-level `mark`/`encoding` only,
so a composed spec falls back to the default name (graceful, not a bug).
- **Chart builder** is single-view by design; its strict round-trip hydration
returns `null` for composed specs, so they stay Monaco-only (correct).
## Vega-Lite fidelity clashes
1. **Named inline/url data misread as a reference** — classifying on "has a
string `name`" alone caught named-inline (`{ name, values }`) and named-url,
breaking valid specs (spurious `DatasetNotFoundError`, or clobbered inline
values). _Resolved_ by the `core/spec-data` classifier (M1).
2. **Shadowing** — a library dataset whose name equals a self-defined `datasets`
key is silently ignored (self-defined wins). Documented precedence; candidate
for a user-facing note, no code change required.
3. **Case-rule split** — library matching is case-insensitive (`naming.ts`);
self-defined exclusion and Vega-Lite's own named-data lookup are case-sensitive.
These are distinct namespaces, so the split is defensible; minor.
4. **Runtime-injected named data** — Vega-Lite allows binding `{ name }` at
runtime; Astrolabe always pre-resolves, so an imported spec relying on runtime
injection won't render. Out of scope.
## Milestone plan
- **M1 — data-model foundation** ✅ — `core/spec-data` classifier mirroring
`isNamedData`; `spec-refs` + `rendering` routed through it. Closes clash 1.
- **M2 — view-scoped editor context** ✅ — `dataBindingAtPath` (climb the cursor's
JSON path to the nearest enclosing `data`, honoring Vega-Lite's parent→child
inheritance) + `derivedFieldNamesAtPath` (ancestor-chain `as` outputs).
`active-dataset` is cursor-scoped (`dataInfoAt(text, offset)`), resolving columns
for every form (library ref case-insensitive, inline, named-inline, self-defined
`datasets`, url/generator → none); the three Monaco providers and the
facet/repeat defaults pass the cursor offset.
- **M4 — multi-view inspection** ✅ (pulled ahead of M3) — `core/inspect-views`
enumerates the distinct tables the marks draw (from the compiled Vega spec's
`from.data` + `data[].source` lineage), each with its input + resolved ends;
`RenderHandle.inspectData()` returns those tables with rows; `DataInspector` adds
a `SelectControl` view picker (hidden for the single-table case), labels via
`inspectViewLabel` (named dataset or "View N", never compiler names) + a
`columns · rows` cue. Enumerating by drawn table (not authored view) is forced by
Vega-Lite desugaring (a `point: true` line compiles to two layers). Replaced the
single-pair `core/result-data`.
- **M5 — live / interactive inspection** ✅ — the inspector reacts to interactive
selections. `RenderHandle.onDataChange` attaches a debounced `view.addDataListener`
to each drawn table's resolved + input names; a selection-as-**filter**
(`filter: {param}`) recomputes a downstream view's `data_N`, so its listener fires
and `LivePreview` bumps a `liveEpoch` that re-reads the table ("what am I
visualizing _now_"); a selection-as-**highlight** (a `condition` encoding) changes
no data, so nothing fires. The watcher is gated on the inspector being open
(a collapsed one costs nothing) and is **always live, no toggle** — the table just
tracks the brush; the ~120ms debounce coalesces a drag's continuous pulses.
Selection `*_store` tables are not drawn, so the M4 enumeration already ignores them.
- **M3 — view-scoped extract** ✅ — Extract is scoped to the view at the cursor.
`services/extract-action` resolves the focused view's data binding
(`dataBindingAtPath`) and lifts whichever of two embedded-data shapes it carries:
a view's inline **`data.values`** (`inlineValuesOf` → rewrite that view's `data`
block at its anchor path), or a **`{ name }` reference to a self-defined
`datasets` entry** (`selfDefinedPayloadOf``promoteSelfDefinedDataset`: drop the
`datasets` entry, and the map when it empties, so the same reference resolves to
the new library dataset; rename refs when the name changes, pre-filled with the
existing name). The toolbar offers Extract whenever any view carries either shape
(`specHasExtractableData`); a cursor in a view with neither (a library ref, url,
generator) gets a guide toast. A single-view spec resolves to the root binding
from any cursor, so the common case is unchanged. Confirm re-serializes in the
app's house style. A `lookup` transform's inline `from.data` is covered incidentally
`dataBindingAtPath` finds it like any view binding (which also means the editor
_hints_ read the lookup table's columns when the cursor sits inside the transform;
acceptable for now, noted). The orphan case — a `datasets` entry no view references
— is out of scope (no view to scope the cursor to; it is dead data to delete).
Delivery is incremental, one milestone per commit, verified against real behavior.
The durable contract is recorded in `docs/architecture` 05 (live inspection) and 07
(reference detection + extraction, §3.13.2); this memo stays the point-in-time record.
-73
View File
@@ -1,73 +0,0 @@
# 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.
@@ -1,168 +0,0 @@
# Visual Composition Editing — Exploration
> **Status:** research recorded 2026-06-29. Point-in-time record of a feasibility study for a
> **visual, drag-editable view of multi-view composition** (`vconcat`/`hconcat`/`concat`/
> `layer`/`facet`/`repeat`). Two surfaces were weighed: a **schematic wireframe panel** and an
> **on-chart overlay** aligned to the real rendered chart.
>
> **Decision (2026-06-29):** build the **schematic wireframe first** — it carries ~90% of the
> value with a deterministic spec↔box mapping and no coupling to Vega runtime internals. The
> **on-chart overlay** is a feasible later "geometry skin" over the same edit core, deferred
> because its risk (compiled-name↔source-path correlation, and an edit-vs-interact pointer
> conflict) is real and isolated. First build step: a **read-only Phase A spike**
> `viewTree(spec)` + a static nested-box renderer with click-to-cursor sync.
>
> **Shipped (2026-06-29):** Phases AC — the wireframe, mark-type leaf glyphs, in-container
> reorder (drag + `Alt+↑/↓`), and cross-container drag-to-restructure (`core/spec-restructure`
> `wrapViews`). The live contract is now arch 08 (transforms) + arch 10 (interaction). Phase D
> (on-chart overlay) and Phase E (size editing) remain deferred — size deferred by choice.
---
## 1. The brief
A visual, interactive representation of a spec's multi-view structure — boxes for the views,
showing arrangement and nesting (what contains what), draggable to rearrange, with the spec
reacting. Explicitly **not** a chart preview: a wireframe of blocks. Reference feel: a Tableau
dashboard's GUI.
## 2. The framing realization: a tree of tiled containers, not a canvas
Vega-Lite composition is a **nested tree**, not a free 2D plane:
- `layer` / `hconcat` / `vconcat` / `concat` hold an **array** of child views.
- `facet` / `repeat` hold a **single, data-generated** child (`spec`), not an array.
So the apt analogy is Tableau's **tiled containers** (nested horizontal/vertical), not Tableau's
**floating** layout. Every drag must resolve to a discrete tree operation — reorder within a
container, move across containers, wrap siblings into a new container, flip orientation, unwrap —
never an arbitrary `(x, y)` drop. Communicating that constraint _as_ the design (snap-to-zones,
not free placement) is the central UX problem.
## 3. Two surfaces, one edit core
Both surfaces feed the **same mutation core** and differ only in where the boxes come from.
| | **Schematic wireframe** | **On-chart overlay** |
| -------------------------------- | ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
| Box geometry | Computed from the source tree | Read from Vega's rendered scenegraph |
| Vega-runtime dependency | None | Hard (`view.scenegraph()`) |
| Path ↔ box mapping | **Deterministic** (we own every path) | **Brittle** — correlate compiled group names (`concat_0_group`, `child__a_group`) back to source paths |
| Reflects true rendered sizes | No (schematic; equal-weight unless explicit `width`/`height`) | Yes (pixel-accurate) |
| Works on mid-edit / invalid JSON | Yes (error-tolerant parse) | No (needs a successful render) |
**Geometry sync fidelity (overlay).** High. `view.scenegraph()` exposes each sub-view as a
`SceneGroup` with exact `bounds`/`width`/`height`; the default **SVG** renderer also yields real
DOM `<g name="…">` nodes measurable via `getBoundingClientRect()`. The app already holds the live
`view` handle (the Inspector reads live data from it), so exposing the scenegraph is a small
`RenderHandle` extension. The fidelity ceiling is not geometry — it is the three overlay risks in
§6.
## 4. Existing infrastructure vs. new work
**Reusable today** (much of it from the cursor-scoped CodeLens work):
- Mutations & paths: `compositionTargetAt` / `insertView` / `moveView` / `elementOffset`
(`core/spec-insert`), wrap/unwrap with property-partition rules (`core/spec-transforms`:
`SHARED_TOP`/`LAYER_TOP`, `placeholderView`, `unwrapSingleton`, `ARRAY_COMPOSITIONS`).
- Data model: `dataBindingAtPath` (`core/spec-data`) for data inheritance.
- Write-back: the whole-document reformat + paired `pushUndoStop` path (`spec-transform-actions`),
so a drag is one ⌘Z.
- UI scaffolding: pointer-drag hook `useResizeDrag` (no DnD library in use), resizable-panel
scaffolding (`PanesStore`/`AppStore`), the preview pane's stacked layout (header / chart /
`DataInspector`).
- Render handle: `chart-renderer` keeps `result.view`; SVG by default.
**New work, roughly in build order:**
- **`viewTree(spec)`** — recursive source-spec → tree of `{ kind, path, label, children, sizeHint }`.
Small, pure core. No tree builder exists today; `inspectableViews` walks the _compiled_ vg spec
for data tables, which is the wrong layer for structure.
- **Cross-container mutations**`moveViewTo(from, toContainer, index)`, `wrapSiblings(...)`
(drop-creates-container), `removeView` + collapse. **Medium-large and correctness-sensitive**
the real cost and the subtle bugs live here (§6).
- **Wireframe renderer** — nested flex boxes from `viewTree`, plus drag + drop-zones +
create-container zones, and selection↔cursor sync (reuses cursor plumbing).
- **(Overlay only)** scenegraph→path correlation, an absolutely-positioned overlay with
coordinate transforms, re-sync on every render, and an edit⇄interact mode toggle.
## 5. Effort & phasing
- **Phase A — read-only wireframe.** `viewTree` + static nested-box renderer + click-to-cursor
sync. Small. De-risks the model with zero mutation risk.
- **Phase B — reorder within a container.** Drag → `moveView` (exists). Small.
- **Phase C — cross-container move + wrap-on-drop + delete/collapse.** Medium-large. The core
value and the correctness work.
- **Phase D — on-chart overlay (optional).** Geometry skin over the proven core. Medium; risk
isolated to correlation + mode conflict.
- **Phase E — resize handles → `width`/`height` (optional).** Medium.
## 6. Edge cases & hidden problems
**Composition model**
- **Facet/repeat cells are data-generated** — count depends on data cardinality (unknown without
running), and individual cells are not arrangeable (they do not exist in the source). Render as
one "grid" placeholder with a badge.
- **`layer` is z-order, not spatial** — children coincide in one box; needs a depth/stack
metaphor, and in the overlay the layer rectangles overlap (ambiguous hit-testing).
- **`concat` + `columns: N`** is a wrap-grid — a third layout mode beside pure h/v.
- Deep nesting → tiny boxes (min-size + zoom/scroll); mixed orientations recurse.
**Mutation correctness (the subtle traps)**
- **Property migration across container types.** `width`/`height` live on the _child_ in concat
but on the _wrapper_ in layer; `data`/`resolve` on the wrapper. A cross-type move must relocate
these or the view silently renders wrong. The partition rules exist for wrap; cross-move needs
the analogue.
- **Data-inheritance breakage.** A child with no explicit `data` inherits its nearest ancestor's.
Moved under a different data source it silently rebinds; detect via `dataBindingAtPath` and
pin the effective data onto the moved view (a real decision, not free).
- **Degenerate drops** — onto itself, into its own descendant (cycle), or a move that empties /
single-childs a container (collapse via `unwrapSingleton`, which can strand a `resolve`/`spacing`
that no longer has a composition to apply to).
**Round-trip & sync**
- Specs are **plain JSON; reformat strips comments** and rewrites the whole document — already
true of the existing transforms, so consistent.
- Source of truth is the draft text; wireframe and editor both mutate it → reuse the atomic
write-back + undo-stop path.
- Mid-edit invalid JSON: wireframe degrades to last-valid; overlay has no fresh render to track.
**Overlay-specific**
- **Compiled-name ↔ source-path correlation is an undocumented compiler contract** — can shift
across Vega-Lite versions and is ambiguous for layers and facet internals. The overlay's biggest
risk.
- **Edit-overlay vs. the chart's own interactivity.** With `params`/brush/pan-zoom selections, an
editing overlay steals the pointer events those selections need → requires an explicit
**edit ⇄ interact** mode toggle, an interaction split the wireframe avoids.
- The view is **finalized and recreated each render**, so the overlay re-measures every time (brief
flicker) and must track scroll/resize/DPR and `autosize`-container coordinate transforms.
**Accessibility**
- Drag-and-drop needs a keyboard path (Move up/down exist; cross-container needs a keyboard
equivalent), per the WAI-ARIA APG drag-and-drop pattern, plus reduced-motion. A `/council`
item before the interaction is built.
## 6a. Deferred polish (Phase A follow-ons)
- **Mark-type glyph per leaf.** _(Shipped.)_ A simplified glyph of each unit view's mark inside
its box (the `mark-*` icon sub-family), so which-is-which reads at a glance.
- **A more legible `layered` primitive.** _(Shipped.)_ A layer renders as **one frame** holding
its child marks as a row of glyphs, badged as layered (the `layers` glyph) — not separate boxes,
so it reads as one space and stays distinct from a concat (which is box-per-view). The
overlapping/stacked-planes options weighed here were rejected: at glyph scale, overlapping
line-art muddies the very marks the glyphs exist to show; legibility beat the z-order-depth cue.
- **Hide the affordance for single-view specs.** _(Shipped.)_ The toolbar glyph appears only when
the spec has a composition.
## 7. Recommendation
Build the schematic wireframe (Phase A → C) first: deterministic mapping, no Vega-internal
coupling, works mid-edit, no pointer conflict with chart interactivity. Treat the on-chart overlay
as an optional later skin over the same proven edit core. Start with the **Phase A spike**
(`viewTree` + static nested boxes + click-to-cursor) to make the model concrete before committing
to the mutation work.
-71
View File
@@ -1,71 +0,0 @@
# VS Code / Positron Extension — Exploration
_Point-in-time memo, 2026-07-04. Option recorded for post-1.0; nothing is being built.
Assesses shipping Astrolabe's authoring intelligence as an editor extension._
## The idea
Port the editor-intelligence layer — scaffolding CodeLenses, spec-aware completions, the
data inspector, live preview — to a VS Code extension (and Positron via OpenVSX), working
on `.vl.json` files in the user's own workspace.
## Why it's cheap: the core/adapter boundary
Everything interesting is already editor-agnostic. `src/core/` (spec analysis, scaffold
construction, site detection over JSON offsets, rendering preparation) has no Monaco
imports; the Monaco layer (`spec-param-scaffold`, `spec-transform-scaffold`,
`editor-cursor-lens`) is thin adapters. VS Code's extension API has one-to-one
counterparts:
| Astrolabe piece | VS Code counterpart |
| ------------------------------- | ---------------------------------------------------- |
| CodeLens scaffolds | `languages.registerCodeLensProvider` + core verbatim |
| Param/transform completions | `registerCompletionItemProvider` / code actions |
| Live preview (`chart-renderer`) | Webview panel running vega-embed (same browser ctx) |
| Data inspector (`inspectData`) | Same webview, message bridge to the render handle |
| Bundled schema (offline) | `jsonValidation` contribution |
| Dataset-by-name (`data.name`) | Resolve against a workspace `datasets/` folder |
Both editors expose offset↔position conversion, so the core's offset-based site
detection transfers without change.
## The marketplace gap
Preview exists; authoring help does not.
- [Vega Viewer](https://marketplace.visualstudio.com/items?itemName=RandomFractalsInc.vscode-vega-viewer)
and [Vega Preview](https://marketplace.visualstudio.com/items?itemName=mdk.vega-preview)
render specs — preview-only.
- The [official vega plugin](https://github.com/vega/vega-vscode) is deprecated; its note
points out VS Code's built-in JSON service already gives `$schema`-driven completion and
validation. **Schema completion/validation are therefore table stakes in VS Code, not
differentiators** — unlike on the web, where Astrolabe had to build them.
- Nothing in the marketplace offers scaffolds, an input-vs-resolved data inspector,
dataset references, or theme configs.
Positron (Posit's VS Code fork, OpenVSX-distributed) concentrates the Altair audience —
people whose wrapper output is already Vega-Lite and who live in an editor. For them an
extension is more native than any website.
## v1 scope, if built
In: bundled-schema validation (offline), scaffold CodeLenses (params/transforms), live
preview panel, data inspector, `data.name` resolution against a workspace folder, theme
as an apply-able config file. Out, deliberately: the library, drafts/publish (git covers
it), the chart builder UI, the theme builder, fonts UI. The extension is "Astrolabe's
authoring intelligence, detached" — the _home_ identity does not port, because in an
editor the workspace already is the home.
## Strategic read
For: real organic discovery (people search "vega" in the marketplace; nobody web-searches
for a snippet manager they don't know exists); every extension user is a lead for the
app; the port validates the core-first architecture.
Against: a second product surface for a single maintainer; it showcases the commodity
part of Astrolabe (editing) rather than the unique part (the home); Positron/OpenVSX
publishing is an extra channel to maintain.
**Decision: defer until after the web app's 1.0.** The only standing cost of keeping the
option open is one we already pay by conviction: keep `src/core/` free of editor and
browser imports.
-47
View File
@@ -1,47 +0,0 @@
# Why Astrolabe exists
I've worked with visualization tools for close to a decade, and for the last few years
I've also taught data work in most of its forms — SQL, Python, spreadsheets, Power BI,
Tableau, the list goes on.
Out of everything I've used in that time, Vega-Lite is one of my favorite ways to make a
chart. It combines things that almost never come together in one tool:
- **It's an open standard.** A chart is a plain JSON file. No registration, no account,
no service that can be discontinued out from under it. Specs I wrote years ago still
render today, and I have every reason to believe they'll render in ten.
- **It's web-native.** Most popular tools have to be installed (Tableau, Power BI) or run
inside a particular environment (Altair, ggplot). For teaching, that raises the floor
twice: technically — students on Linux can't install most BI suites at all — and
conceptually. I want to teach _visualization_, not programming.
- **It's declarative.** This point is really an ode to the Grammar of Graphics that
ggplot popularized: you describe what the chart is, not how to draw it. My favorite
tool for preparing data is SQL, another declarative language, and Vega-Lite scratches
exactly the same itch.
- **It's interactive.** This is what sets it apart from workhorses like matplotlib and
ggplot: cross-filtering, tooltips, and brushing are a few lines of JSON, not an
afternoon of event-handler code. Interactivity multiplies one chart into dozens — a
different view for every filter state a reader can choose.
And yet, when I went looking for a comfortable place to write and keep these charts, I
couldn't settle on anything. Vega-Lite came buried inside heavier BI tools, or in the
official editor — a scratchpad that holds one spec at a time and keeps nothing. And using
the full power of the format was fiddly in practice: to attach a custom font to a chart,
you had to be handy with web development first. The low floor I praise to my students
wasn't there for me either.
So I decided to build my ideal tool for Vega-Lite charts :)
At first it was just a snippet editor: a list of specs, and clicking one opens an editor
with a live preview. Then datasets wanted to be their own thing — stored once, referenced
by name from any chart, instead of pasted into every spec. Then a visual chart builder,
for the days when you'd rather click than type. And then custom themes with your own
fonts — the exact itch that used to require a web developer, now a file-upload away.
Somewhere along the way the tool also acquired a stance. Everything stays in your
browser: no account, no server, and no AI model reading your charts. Part of that is
principle. Most of it is the plain reality that the data I chart at work is confidential,
and I wanted a tool where that question simply never comes up.
If you make charts with Vega-Lite — or you've been looking for a reason to start —
[Astrolabe](/) is where mine live now.
-16
View File
@@ -1,16 +0,0 @@
## Що надихнуло на цей проєкт
Я використовую інструменти візуалізації в роботі вже майже десятиліття, а також вже декілька років викладаю роботу з даними та візуалізацію в багатьох аспектах - 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 :)
Спершу це була ідея просто редактор "сніппетів" - список специфікацій, по вибору відкривається редактор і превʼю. Потім зʼявилась ідея додавати/витягувати набори даних в окремі сутності. Згодом виникла і ідея побудувати візуальний редактор, а ще згодом - можливість створення та збереження кастомних тем
-80
View File
@@ -1,80 +0,0 @@
# Manual Verification Checklist
> A standing memo, **not** a milestone. These are the things automated tests can't
> cover — they need a real browser, a real install, or a human eye. Run the relevant
> sections before a release, or after any change to the app shell, PWA config,
> routing, theming, or focus/keyboard behavior. Tests stay green ≠ these pass.
## Offline & installable (PWA)
- [ ] First load online, then go offline (DevTools → Network → Offline) and reload —
the app boots, fonts render (no system-font fallback), a previously-opened
snippet still renders.
- [ ] Install as a standalone app (desktop install button / Android "Add to Home
screen") — it installs, launches in its own window, and shows the astrolabe
icon (not a generic glyph).
- [ ] Android adaptive icon (maskable) fills the OS shape without clipping the mark.
- [ ] Service-worker update flow: ship a new build, reload — the update-available
prompt appears and applying it loads the new version (`registerType: 'prompt'`).
- [ ] iPad add-to-home-screen (Safari → Share → Add to Home Screen) shows the astrolabe
`apple-touch-icon.png`, not a page screenshot or generic glyph. Phones are out of
scope — iPad is the only touch surface Astrolabe targets.
## Keyboard & accessibility
- [ ] Full keyboard-only run-through: create/select/edit a snippet, open and dismiss
each modal, drive the pane toggle strip and both resize handles, reach Datasets.
- [ ] Focus is never orphaned or trapped: modals trap focus and return it to the
opener on close; hiding a pane from the strip keeps focus on the toggle.
- [ ] Visible focus ring on every interactive control, in both themes.
- [ ] Text and UI contrast pass AA in light and dark.
## Routing & view-state
- [ ] Reload restores the view from the URL hash (`#snippet-<id>`, `#datasets/…`).
- [ ] Browser Back/Forward moves through view-state as expected.
## Theming
- [ ] Light⇄dark flip repaints the whole app — chrome, Monaco, and the chart.
- [ ] No placeholder styling or raw hexes leak through on any surface.
## Reduced motion & feedback
- [ ] With `prefers-reduced-motion`, toasts and transitions honor it (no large motion).
- [ ] Toasts stack, auto-dismiss, and fade as specified; the live-preview busy
indicator appears for slow (>~1s) renders and clears after.
## URL datasets (remote data snapshot)
> Needs a real network, real CORS, and real offline — tests mock the fetch.
- [ ] Add a dataset by URL from a CORS-friendly host (e.g. a GitHub raw `.csv` or a
vega-datasets URL) — Save shows "Fetching…", then the dataset appears profiled
(rows, columns, size) with a "Fetched <time>" line and the source address.
- [ ] Go offline (DevTools → Network → Offline) and reload — a snippet that references
that URL dataset still renders from the local snapshot (no fetch at render time).
- [ ] Add a URL that blocks cross-origin requests (or while offline) — the form shows a
cause-specific error and a "Paste data inline instead" button that switches to an
inline paste keeping the name/comment; no broken record is saved.
- [ ] Refresh a URL dataset whose source changed — rows/size and the "Fetched" time
update; refreshing while offline raises an error toast and leaves the snapshot intact.
- [ ] Build Chart from a fetched URL dataset works (columns are known); from an
unfetched URL reference the builder has no schema until Refresh.
## Visual sweep of the M6 surfaces
- [ ] Library search / sort / empty states, the storage monitor, About & Donate
modals, and the busy indicator all look deliberate and behave per spec.
## Driving these checks headlessly
The eye-checks above can be scripted as a headless walk-through (Playwright with the
already-installed Chromium browser cache) for repeatable screenshots, alongside a live
browser.
- **Select by accessible name / role / placeholder, never by CSS class.** CSS-module class
names are content-hashed and unstable; the component tests select the same way.
- **Reaching the Chart Builder:** open Datasets from the **header command button** (the
pane-toggle-strip `Datasets` button only exists once the workspace has content) → **New
Dataset** → paste rows inline → **Build Chart**.
-60
View File
@@ -1,60 +0,0 @@
# 00 · Product Overview
This document set is a UX/behavioral specification for **Astrolabe**, a browser-based snippet manager for [Vega-Lite](https://vega.github.io/vega-lite/) visualizations. It describes _what the app does_ from the user's perspective — its capabilities, workflows, and structural layout — so the app can be recreated on any web/HTML/TS stack. It deliberately avoids prescribing _how_ anything is built: no frameworks, libraries, storage technologies, code structure, or concrete visual styling are mandated. Implementers are free to choose those.
## What Astrolabe Is
Astrolabe is a local-first tool for authoring, organizing, and previewing Vega-Lite charts. A user keeps a personal library of **snippets** (saved chart specifications), edits each one as JSON with live validation, and sees the result render in real time beside the editor. Reusable **datasets** can be stored once and referenced by many snippets. Everything lives in the user's browser — there is no account, no server, and no network dependency after first load.
## Who It Is For
People who work with Vega-Lite directly and want a fast, private workspace to draft, iterate on, and keep many visualizations: data practitioners, analysts, educators, and chart authors. Familiarity with Vega-Lite's JSON spec format is assumed; the app does not abstract Vega-Lite away (though the _Chart Builder_ offers a no-JSON starting point).
## Core Value
- **Iterate quickly** — edit JSON and watch the chart update live, with schema-aware assistance and instant error feedback.
- **Stay organized** — a searchable, sortable library of named, annotated snippets.
- **Experiment safely** — a draft/published model lets users tinker without losing a known-good version.
- **Reuse data** — datasets stored once, referenced anywhere, in multiple formats and from inline data or remote URLs.
- **Own your data** — fully local, private, and offline-capable, with import/export for backup and transfer.
## Scope & Principles
- **Local-first** — all data is stored in the browser and survives reload; the app works fully offline and is installable as a standalone app.
- **Single-screen workspace** — a three-pane layout (library · editor · preview) plus modals for cross-cutting tools (datasets, chart builder, settings, help).
- **Vega-Lite native** — snippets _are_ Vega-Lite specs; the app validates, renders, and reasons about them as such.
- **Keyboard-friendly and shareable** — common actions have shortcuts, and the current location is reflected in a shareable URL.
## Non-Goals
- No user accounts, authentication, or cross-device sync (use _Import & Export_ to move data).
- No server-side storage, rendering, or processing.
- No collaboration or multi-user features.
- No general BI/dashboarding — a snippet is a single Vega-Lite visualization, not a composed report.
## Key Concepts (Glossary)
- **Snippet** — a saved Vega-Lite specification plus metadata (name, comment, timestamps, tags, dataset references). The primary user-authored entity. See _Snippet Library_ and _Data Model & Persistence_.
- **Spec** — the Vega-Lite JSON specification that defines one visualization.
- **Draft vs Published** — each snippet holds a stable **published** spec and an editable **draft**; edits affect only the draft until the user publishes. See _Spec Editor & Draft/Published Workflow_.
- **Dataset** — a named, reusable data source (JSON/CSV/TSV/TopoJSON; inline or URL) that snippets reference by name. See _Datasets_.
- **Dataset reference** — a Vega-Lite named-data reference, e.g. `{ "data": { "name": "MyDataset" } }`, linking a spec to a stored dataset.
- **Live preview** — the rendered chart, updated automatically as the spec changes. See _Live Preview_.
## How This Specification Is Organized
| # | Section | Covers |
| --- | -------------------------------------- | ------------------------------------------------------------------------------------------ |
| 00 | Product Overview | This document — purpose, scope, glossary. |
| 01 | Application Shell & Navigation | Layout, panes, header, modals, keyboard shortcuts, URL state, toasts, offline/installable. |
| 02 | Snippet Library | Browsing, search, sort, metadata, create/duplicate/delete, storage monitor. |
| 03 | Spec Editor & Draft/Published Workflow | Editing, auto-save, auto-render, draft/publish/revert, extract-to-dataset. |
| 04 | Live Preview | Rendering, reference resolution, fit modes, error display. |
| 05 | Datasets | Dataset manager, formats, sources, profiling, references, linking. |
| 06 | Chart Builder | Visual no-JSON chart composition from a dataset. |
| 07 | Settings | Appearance, editor, performance, and formatting preferences. |
| 08 | Import & Export | Backup/transfer file format, import normalization and merging. |
| 09 | Data Model & Persistence | Entity field definitions, storage tiers, relationships. |
| 10 | Non-Functional Requirements | Platform, performance, accessibility, reliability, privacy. |
Read 00 first for orientation, then any section independently. Sections cross-reference one another by title where behavior spans more than one area.
-135
View File
@@ -1,135 +0,0 @@
# 01 · Application Shell & Navigation
This section describes the overall workspace structure, the header toolbar, the modal system, keyboard shortcuts, URL-based navigation, transient notifications, and the offline/installable nature of the app. Feature-specific behavior lives in the sections referenced inline.
## A. Workspace Layout
Astrolabe is a single-screen workspace. Below a fixed top header sits a three-pane working area, each pane dedicated to one part of the snippet-editing workflow:
- **Snippet library** (left) — browse, search, select, and manage saved snippets (see _Snippet Library_).
- **Spec editor** (center) — edit the Vega-Lite spec of the selected snippet (see _Spec Editor & Draft/Published Workflow_).
- **Live preview** (right) — render the current spec (see _Live Preview_).
Behavior:
- All three panes are visible by default, laid out side by side in the order library, editor, preview.
- Adjacent panes are separated by a vertical drag handle. The user can drag a handle left/right to resize the two panes it sits between; the rest of the layout is unaffected. When the editor is hidden, the library and preview become adjacent and a single handle between them re-splits the freed space.
- Each pane enforces a minimum width while resizing, so a pane cannot be dragged to nothing.
- Each pane can be individually shown or hidden via a persistent **toggle strip** (a narrow vertical strip of toggle buttons, one per pane, each indicating whether its pane is currently shown). The toggle strip also contains a shortcut button that opens the Datasets manager.
- When a pane is hidden, the remaining visible panes expand to fill the freed space, redistributing proportionally to their remembered widths. When a previously hidden pane is shown again, it returns at its remembered width. The center editor is normally the flex filler with no fixed width, so it remembers the width it had at the moment it was hidden and reclaims that width when shown again; the library and preview then keep the split ratio they were left at while it was hidden.
- Hiding all panes is permitted; the toggle strip remains available to bring panes back.
- Pane widths and per-pane visibility persist locally across sessions and are restored on next load. The app remembers a pane's preferred width even while it is hidden, so re-showing it restores that width rather than an arbitrary one.
## B. Header / Toolbar
A fixed header spans the top of the app.
- **Left side**: the app icon, the app title ("Astrolabe"), and a version badge showing the current app version.
- **Right side**: a row of **icon-only** utility entry points (each with a tooltip and an accessible name), then — set off by a divider — the text-labelled Support button and the theme toggle. Each opens a destination:
| Entry point | Opens |
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| Datasets | The Datasets manager modal (see _Datasets_). |
| Import workspace | A file-picker dialog to choose a previously exported file; the chosen file is imported (see _Import & Export_). |
| Export workspace | Immediately produces a downloaded file containing all snippets and datasets (see _Import & Export_). |
| About | The About & Help modal (keyboard shortcuts, about, and privacy information); privacy lives inside. |
| Support | The Support modal — two ways to give back: feedback to the author, or a donation to Ukraine's defense. The one text-labelled, soft-accent button. |
Notes:
- The utilities are icon-only so the header reads as quiet chrome (Carbon UI-shell header: global actions are a right-aligned icon row). The accessible names scope the workspace-level Import/Export ("Export workspace") apart from the preview pane's per-chart "Export" — the two were previously both labelled "Export" at once.
- Import and Export act directly (file dialog / file download); they do not open in-app modals.
- The Datasets, About, and Support entry points each open a modal (see _Modal System_).
- **Settings are not a header entry point.** A design review (see _Settings_) distributed
preferences to the panes they affect — the appearance theme is a header toggle, and the
Editor / Performance / Formatting clusters open from a gear control in their own pane. There
is no central Settings button or modal.
## C. Modal System
The app shows at most one modal at a time. The modal set is: Datasets, About & Help, Support, Chart Builder, Extract-to-Dataset, and Theme Builder. (Settings are deliberately _not_ a modal — they are distributed to per-pane controls; see _Settings_.)
- Opening any modal closes whichever modal was previously open; the two never overlap.
- Every modal can be dismissed by: clicking its close button, pressing **Escape**, or clicking the backdrop outside the modal body. Exception: modals holding in-progress work (the Chart Builder and Theme Builder) ignore backdrop clicks; Escape and the close button still dismiss them.
- Clicking inside the modal body does not dismiss it.
- The Chart Builder, Extract-to-Dataset, and Theme Builder modals are opened from within the Datasets / snippet / preview workflows (see _Chart Builder_, _Datasets_, and _Live Preview_), not from the header.
- Dismissing a modal returns the user to the underlying workspace unchanged.
## D. Keyboard Shortcuts
Shortcuts are platform-aware: the modifier is **Cmd** on Mac and **Ctrl** on other platforms (shown below as Cmd/Ctrl).
| Shortcut | Action |
| -------------------- | ---------------------------------------------------------------------------------- |
| Cmd/Ctrl + Shift + N | Create a new snippet (see _Snippet Library_) |
| Cmd/Ctrl + K | Toggle the Datasets manager open/closed |
| Cmd/Ctrl + S | Publish the current snippet's draft (see _Spec Editor & Draft/Published Workflow_) |
| Cmd/Ctrl + , | Open the Editor settings cluster (see _Settings_) |
| Escape | Close the active modal |
Notes:
- Cmd/Ctrl + K is a toggle: if the Datasets manager is already open it closes it; otherwise it opens it.
- Escape only acts when a modal is open; with no modal open it does nothing.
- Cmd/Ctrl + S publishes the active draft regardless of where focus is — **including
while the editor has focus** — so it behaves as a "save" you reach for mid-edit. The
other shortcuts (new snippet, toggle Datasets, settings) are suppressed while the
user is typing in the editor or an input, so they don't interrupt text entry.
- The shortcut actions override the browser's default behavior for those key combinations.
## E. Navigation & Shareable URL State
The app reflects its current location in the URL hash so that reloading restores the same view and the browser's Back/Forward buttons move between prior states. The user can copy the URL to share or bookmark a specific location.
States and their hash forms:
| State | Hash |
| ------------------------------ | ------------------------------ |
| A selected snippet | `#snippet-<id>` |
| Datasets manager (list) | `#datasets` |
| A specific dataset | `#datasets/dataset-<id>` |
| New-dataset form | `#datasets/new` |
| Chart Builder for a dataset | `#datasets/dataset-<id>/build` |
| Chart Builder, no dataset open | `#build` |
`#build` is the Chart Builder opened with an empty dataset library (its no-datasets state). When datasets exist, an un-targeted builder open immediately lands on one (see _Chart Builder → Opening_), so the URL shows the dataset form instead.
Behavior:
- Selecting a snippet updates the URL to that snippet; reloading reopens that snippet.
- Opening the Datasets manager updates the URL to `#datasets`; opening a specific dataset, the new-dataset form, or the Chart Builder for a dataset updates the URL to the corresponding form above.
- Browser Back/Forward navigate between these states (e.g. closing a modal via Back returns to the previously selected snippet).
- 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.
- Four kinds, each visually distinct: **success**, **error**, **warning**, **info**.
- Each toast auto-dismisses after a few seconds, and can also be dismissed manually via its close control.
- Multiple toasts stack rather than replacing one another, and appear/disappear with a brief fade.
Toasts confirm outcomes the user **cannot already see** — a side effect, a disappearance, or a state change with no strong on-screen cue. An action whose result is immediately visible (a newly created snippet opening in the editor; a new dataset shown selected in its detail pane) is confirmed by that visible change, not by an added toast, which would only be noise (Nielsen Norman "aesthetic and minimalist design"; Carbon notification usage; see _docs/architecture/10 → Toast copy_). Assistive-technology users still receive an announcement on the changed region.
Events that raise toasts include:
- Snippet actions: **duplicating** (the copy is easily mistaken for an edit of the original), **deleting** (the snippet disappears), **publishing** a draft, and **reverting** a draft. _Creating_ a snippet opens it in the editor and needs no toast.
- Dataset actions: **deleting**, and **extracting** inline data into a dataset (the dataset is created off-screen while the user is in the editor). _Creating_ a dataset in the Datasets modal is confirmed by the new dataset appearing selected.
- Import/Export: results of an import (success/partial/failure) and confirmation of an export.
- Errors: spec/data validation failures and local-storage capacity warnings.
**Copy Reference** (Datasets) is the exception that proves the rule: a clipboard write is invisible, but the universal pattern confirms it _inline on the control_ ("Copied"), announced politely to assistive tech — a toast per copy would be noise.
## G. Offline & Installable
Astrolabe is local-first and usable without a network connection.
- After the first successful load, the app works fully offline; the interface and previously loaded content remain available with no connection.
- All snippets, datasets, and settings are stored locally and remain accessible offline (see _Data Model_).
- The app is installable as a standalone application from a supporting browser and, once installed, launches in its own window.
-104
View File
@@ -1,104 +0,0 @@
# 02 · Snippet Library
The Snippet Library is the left pane and the primary entry point to the app. A **snippet** is a saved Vega-Lite specification together with metadata (name, comment, timestamps, tags, references to external datasets). The library lets the user browse, search, sort, select, and manage their snippets. Editing the specification, the draft-vs-published workflow, the live preview, and dataset management are covered elsewhere (see _Spec Editor & Draft/Published Workflow_, _Live Preview_, _Datasets_); this section covers only the library and management surface.
## The List
The list shows every saved snippet and is always visible. A persistent **creation surface** sits at the top of the list, above all snippets, so the user can always start a new chart regardless of scroll position. It offers the two ways in with a clear hierarchy:
- **Build Chart** — the primary action; opens the **Chart Builder** (see _Chart Builder_), the guided no-JSON path. The builder opens on the most recently modified dataset (or its no-datasets state when the library has none) — it is not gated on first selecting a dataset.
- **New JSON snippet** — a ghost/tertiary action beside it; creates and selects a new snippet directly in the editor (see _Snippet Operations_ → Create New), the expert path — always visible, one click, never hidden behind the guided one.
When the library pane is dragged narrow, the buttons shed their labels (the long ghost label first) and keep their icons and accessible names.
- The list shows all snippets, ordered newest-modified first by default (see _Sort_).
- Selecting a snippet makes it the **active snippet**: it loads into the editor and preview, becomes highlighted in the list, and the URL updates to reflect the selected snippet so the state is shareable and survives a page reload (see _Application Shell & Navigation_).
- Exactly one snippet is active at a time.
- When no snippets match the current search, the list shows an empty-state message ("No snippets match your search", with a hint to try a different term). This is the list's only empty state: a genuinely empty library never shows the list at all (see next).
- When the library is empty (first run, or after the last snippet is deleted), the workspace presents a full-width **onboarding canvas** in place of the panes — including the library list — rather than seeding placeholder content (see _First-Run & Empty Workspace_).
## First-Run & Empty Workspace
When the library is empty — on first run, or after the user deletes their last snippet — the app does **not** seed placeholder content. Instead the **onboarding canvas takes the full workspace**, replacing the pane chrome (the pane toggle strip, the library list, the editor, and the preview): with no snippets, the library's create/search/sort/storage controls and the pane toggles have nothing to act on, so the welcome gets the whole width. The user starts from a deliberate choice rather than dropped into the middle of an unfamiliar spec.
- 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.
- **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_).
- Leaving the canvas by creating the first snippet(s) lays the workspace out at a sensible **default split** (library · editor · preview ≈ 25 · 25 · 50) with all three panes shown, so the first chart opens with a generous preview rather than the generic remembered widths.
- The onboarding canvas is shown **only while the library is empty**; as soon as any snippet exists, the normal panes return. Re-emptying the library brings it back.
## List Item
Each list item is a compact row summarizing one snippet, designed for fast scanning.
- Shows the snippet **name**.
- Shows a **last-modified date**, rendered relatively for recent items ("Today", "Yesterday", "Nd ago" within the past week) and as a full date beyond that, formatted per the user's date-format setting (see _Settings_). When sorting by Created, the item shows the created date instead of the modified date.
- Shows the snippet **size** (in KB), but only once the snippet reaches at least about 1 KB; smaller snippets omit the size to reduce clutter.
- Shows a **status indicator** distinguishing a snippet that has unpublished draft changes from one that is fully published (the indicator communicates "draft" vs "published"). The publish and revert actions themselves live in _Spec Editor & Draft/Published Workflow_.
- Shows a small **dataset icon** when the snippet references one or more external datasets (see _Datasets_); the icon is omitted otherwise.
- The active snippet is visually highlighted.
## Search
A live search box lets the user narrow the list as they type. It exists so users with many snippets can find one by name, by note, or by something inside the specification itself.
- The search box filters the list immediately on each keystroke.
- Matching is case-insensitive and spans the snippet **name**, the snippet **comment**, and the **specification content** (the current working/draft spec text), so a search for a field name, mark type, or dataset name in the spec will surface matching snippets.
- The search has a clear control that empties the box and returns focus to it, restoring the full list.
- Search affects only which snippets are shown; it does not change the active snippet or any data.
## Sort
The user chooses how the list is ordered. The choice persists across sessions so the library always opens the way the user left it.
- Sort fields: **Modified**, **Created**, **Name**, **Size**.
- An ascending/descending toggle controls direction; the current field and direction are indicated (e.g. a directional arrow on the active field).
- Selecting the already-active sort field flips the direction; selecting a different field switches to it and resets to descending.
- Default ordering is **Modified, descending** (newest changes first).
- Name sorts alphabetically; Size sorts by stored snippet size; Created and Modified sort chronologically.
- The **Modified** time advances on every save — including silent draft auto-saves (see _Spec Editor & Draft/Published Workflow_) and inline name/comment edits — so under the default Modified-descending sort the active snippet continually rises to the top while it is being edited.
- The selected sort field and direction persist across sessions.
## Selected-Snippet Metadata Panel
When a snippet is active, a metadata panel (within the left pane) exposes its editable properties and key facts. It exists so the user can rename, annotate, and inspect a snippet without leaving the library.
- Shows and lets the user edit the **Name** inline; edits save automatically.
- Shows and lets the user edit a multiline **Comment** (free-form notes); edits save automatically.
- Shows read-only **Created** and **Modified** timestamps, formatted per the user's date-format setting (see _Settings_).
- When the snippet references external datasets, shows a **Linked Datasets** list of the referenced dataset names, each with a dataset icon (see _Datasets_). The list is omitted when there are no references.
- The panel also exposes the Duplicate and Delete operations for the active snippet (see _Snippet Operations_).
## Snippet Operations
The library provides the lifecycle operations for snippets. An operation whose outcome the user can't already see confirms it with a toast; an operation whose result is immediately visible needs none (see _Application Shell & Navigation_ → Toasts).
- **Create New**: starts a new snippet from a small sample Vega-Lite bar-chart template (a few inline category/value rows), assigns it an auto-generated default name (see _Naming & Tags_), saves it, and makes it the active snippet. Opening in the editor _is_ the confirmation, so no toast is raised.
- **Duplicate**: creates an independent copy of the active snippet with a name suffixed "(copy)". The copy carries over the specification, comment, tags, and dataset references, gets fresh created/modified timestamps and a new identity, and becomes the active snippet. A success toast confirms the duplication.
- **Delete**: permanently removes the active snippet after the user confirms a warning that the action cannot be undone. After deletion the newest remaining snippet becomes active (so the editor and detail panel stay populated); if none remain, no snippet is active. A toast confirms the deletion.
- These operations never affect other snippets.
## Naming & Tags
New snippets get a sensible default name, and a tag field exists on each snippet for categorization, though tags are not a primary user surface.
- A new snippet receives an auto-generated default name based on the current date and time, so it is uniquely identifiable until the user renames it (renaming happens in the metadata panel).
- Names follow a **provenance hierarchy**: an **explicitly chosen** name (set via rename in the metadata panel) is frozen — the app never rewrites it. Every **app-picked** name (the timestamp default, a Chart Builder-generated name, or a previously derived one) is a "next best pick" that keeps tracking the spec: on each publish it is re-derived as the spec's `title` when present, else a mark + encodings description (the same dialect the Chart Builder names its output in), else the existing name stands. So the library reads by chart rather than by creation time, until the user takes over a name — at which point their word is final (see _Spec Editor → Publish_, _Data Model → `nameSource`_).
- Each snippet stores a list of **tags**. Tags are persisted and carried through duplication; for example, snippets brought in via import are tagged "imported" (see _Import & Export_).
- There is no dedicated tag-management UI; tags are stored on the data model but are not surfaced as a primary browsing or editing control.
## Storage Monitor
A small indicator at the bottom of the library shows what the app's local storage is **made of** — a compact breakdown of how much space is taken by **snippets**, by **datasets**, and by the **app itself** (its offline-cached code and assets). It is informational: it helps the user see where space is going, not a fuel gauge counting down to a limit.
- The indicator stays **hidden until the user's own data — snippets + datasets — reaches a meaningful size** (about 10 MB). Below that there is nothing worth managing, so it adds no clutter; the app's own cached footprint does not count toward this threshold.
- When shown, the breakdown is a single proportional bar plus a labelled legend giving each category's size; meaning never rests on colour alone.
- There is **no "X of Y free" figure**. Browsers report only an unreliable, padded storage _quota_, so a precise "free space" number would mislead; the app shows real measured sizes instead.
- Snippet and dataset sizes are always shown — the app measures them directly. The **app** portion is shown when the browser exposes an overall usage figure; when it does not, the breakdown simply omits it.
- The genuine "out of room" moment is handled where it happens: if a save fails because storage is full, the system warns that the snippet could not be saved rather than silently losing data, so the user can delete snippets or datasets to free space (see _Import & Export_ error handling).
-94
View File
@@ -1,94 +0,0 @@
# 03 · Spec Editor & Draft/Published Workflow
The center pane is where the user reads and edits the active snippet's Vega-Lite specification. It is a code editor for JSON paired with a Draft/Published workflow: edits are made against a working draft that auto-saves silently, drive the _Live Preview_ automatically, and become the snippet's stable version only when explicitly published. The pane is empty when no snippet is selected; it loads the active snippet's spec when one is chosen in the _Snippet Library_.
## A. The Spec Editor
The editor presents the active snippet's spec as formatted JSON with full code-editing affordances tuned for Vega-Lite.
- The editor displays the spec as indented, readable JSON with syntax highlighting.
- As the user types, the spec is validated against the Vega-Lite schema; problems are surfaced as inline indicators at the offending locations (e.g. squiggles/markers), without blocking continued editing.
- The editor offers schema-driven autocomplete/suggestions while typing (property names and allowed values from the Vega-Lite schema).
- Pasting content reformats the spec automatically so it stays consistently indented; while typing, the editor maintains consistent indentation as new lines are entered (auto-indent). A format-on-demand action reflows the whole spec to the same compact, readable style.
- 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.
- A short moment after the user stops typing, the current editor content is parsed and stored as the snippet's working draft, silently and with no notification.
- Auto-save only commits when the editor content is valid JSON; if the content is momentarily unparseable, the save is skipped and retried after the next pause in typing, so a half-typed spec never overwrites the stored draft.
- Auto-save writes the **draft** only. It never alters the published version (see _D. Draft vs Published_).
- Auto-save is distinct from Publish: auto-save preserves in-progress work; Publish promotes that work to stable.
## C. Auto-Render to Preview
Edits flow to the _Live Preview_ automatically, so the user sees results without invoking a render.
- A brief moment after the user stops typing, the current spec is sent to the _Live Preview_ for rendering.
- The delay before rendering is a configurable debounce (see _Settings_ / _Live Preview_), letting the user trade responsiveness against churn while typing heavy specs.
- Rendering also occurs immediately when a snippet is first loaded into the editor or the Draft/Published view is switched.
- Rendering specifics (dataset reference resolution, fit modes) belong to _Live Preview_; the editor's role is to supply the current spec text on each settle.
## D. Draft vs Published Workflow
Every snippet carries two versions of its spec: a **published** (stable) version and a **working draft**. This separation is the central editing model. A view toggle in the pane header switches which version the editor shows.
- The header offers a Draft/Published toggle; the currently active view is visually indicated.
- **Draft view** shows the working draft and is the editable surface — all typing, auto-save, and auto-render act on the draft.
- **Published view** shows the last published version, for reference; the published version is never modified by ordinary editing.
- Editing the draft never touches the published version until the user publishes.
- The _Snippet Library_ status indicator reflects whether a snippet currently has unpublished draft changes (draft differs from published); this section only produces that difference, it does not render the indicator.
### Publish
- A **Publish** action promotes the current draft to become the published version (the two are made identical).
- Publish is also triggered by the keyboard shortcut Cmd/Ctrl+S.
- If the snippet's name is **app-picked** (never explicitly renamed by the user — the timestamp default, a builder-generated name, or an earlier derived one), publish re-derives it from the now-published content: the spec's `title` verbatim when present (string, line array, or `{ text }` forms), else a mark + encodings description in the Chart Builder's naming dialect (e.g. "Bar chart of count by Ship Mode"), else the existing name stands. A name the user has set is never rewritten (see _Snippet Library → Naming & Tags_).
- The snippet's dataset references track the draft continuously (recomputed on auto-save, extract, and revert), so publish needs no special reference handling — promoting the draft simply carries the already-current references onto the published version (see _Datasets_ for reference linking).
- A success toast confirms the snippet was published.
- Publish is unavailable when no snippet is active.
### Revert
- A **Revert** action discards all draft changes and restores the draft to match the last published version.
- Revert requires explicit confirmation before discarding, warning that the action cannot be undone.
- 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. Error Surface
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 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.
## F. Extract Inline Data to a Dataset
When a snippet's spec embeds its data inline, the user can lift that data out into a reusable, named dataset and have the spec reference it instead. This keeps specs lean and lets the same data serve multiple snippets (see _Datasets_).
- When the active snippet's draft spec contains inline data, an **Extract to Dataset** action is available in the pane header; it is hidden when the spec has no inline data.
- Choosing it opens a modal that shows a read-only preview of the inline data and asks the user for a dataset name (required).
- The user enters a name and confirms creation. Names must be non-empty and unique; if the name is blank or already in use, the modal shows an inline error and the action does not proceed.
- On success, the system: saves the inline data as a new dataset (preserving its detected format), rewrites the snippet's draft spec so the inline data is replaced by a reference to the dataset by name, links the dataset to the snippet **immediately** (the link appears without waiting for a publish, since references track the draft), and reloads the editor to show the rewritten spec. Reverting the draft before publishing removes the link again.
- A toast confirms the dataset was created, and the modal closes.
- The user can cancel the modal at any time, leaving the spec unchanged.
- Dataset-side specifics (formats, storage, the bidirectional snippet↔dataset link) are described in _Datasets_.
## G. Spec ↔ Config Actions
Three editor actions make the injected chart theme portable (see _Live Preview → Chart theme_). Their visible home is a **Config** menu in the editor toolbar (a value-select disclosure listing the actions with a one-line description each), disabled when no snippet is active or the read-only published view is shown; the editor's right-click context menu and F1 command palette offer the same actions as expert accelerators. All replace the document as a single undoable edit (⌘/Ctrl+Z restores), reformatted in the app's JSON style, and all refuse with a clear toast when the document is not a valid JSON object.
- **Merge Chart Theme into Spec** — bakes the currently selected chart theme into the spec's own `config` block, deep-merging under any existing `config` so the spec's own keys win and the rendered result is unchanged. Use it before publishing a spec somewhere the app's theme won't follow. When the selected theme injects nothing (Stock Vega-Lite), the action explains there is nothing to merge.
- **Extract Config from Spec** — removes the spec's `config` block and copies it to the clipboard, for cleaning baked-in styling out of a pasted spec. The clipboard copy happens **before** the removal; if the copy fails, the spec is left unchanged so the config is never lost. A spec with no config block reports that and changes nothing.
- **Extract Config to New Theme** — removes the spec's `config` block and saves it as a new custom chart theme (see _Live Preview → Theme Builder_), named after the snippet and auto-suffixed if taken, then selects that theme as the active chart theme so the styling keeps applying to this chart from the injected side. The success toast names the created theme and points at Edit themes… for renaming or refining. A spec with no config block reports that and changes nothing. (If a non-stock theme was active before, any of its keys the extracted config didn't cover stop applying — the new theme replaces it wholesale.)
-130
View File
@@ -1,130 +0,0 @@
# 04 · Live Preview
The right pane renders the active snippet's current specification as a live Vega-Lite visualization. It mirrors whatever the editor currently shows and updates on its own as the user types, giving immediate visual feedback without any explicit "run" action.
## Purpose & Live Updating
- Renders the active snippet's current spec as a Vega-Lite visualization.
- Always reflects the version currently shown in the editor: while the user edits the draft, the preview renders the draft; once published/viewing the published version, it renders that (see _Spec Editor & Draft/Published Workflow_).
- Updates automatically as the user edits, after a brief render debounce so rapid keystrokes do not trigger constant re-rendering. The debounce delay is user-configurable (see _Settings_).
- A subtle busy indication may appear over the preview while a render is in progress; it clears when rendering completes.
- When no snippet is active, or the editor content is empty/blank, the preview renders nothing (a clean, empty pane) rather than showing an error.
## Dataset Reference Resolution
When a spec uses inline data, the preview renders it directly. When a spec instead references a named dataset from the library, the preview resolves that reference and renders using the stored dataset's contents (see _Datasets_).
- A spec may point at a dataset from the library by name instead of embedding the data inline.
- Before rendering, the preview substitutes the referenced dataset's stored contents into the spec.
- URL-sourced datasets render from their **local snapshot** (fetched when the dataset was created or last refreshed; see _Datasets_), so the preview does not fetch at render time and works offline. A URL dataset that has never been fetched has no snapshot and instead renders against its **live URL** as a fallback, until it is refreshed.
- If a referenced dataset cannot be found, the preview shows a readable error (see _Error Display_) rather than a broken chart.
## Fit / Sizing Modes
The preview pane header has a "Fit" control offering exactly four modes that determine how the chart is sized within the pane. The chosen mode applies immediately and re-renders the current chart.
- **Original** — renders the chart at its natural size as defined by the spec. If the chart is larger than the pane, it overflows and the pane provides scrolling to reach the rest.
- **Width** — fits the chart's width to the pane (the width becomes responsive to the pane); the height is left to the chart's own natural sizing.
- **Height** — fits the chart's height to the pane; the width is left to the chart's own natural sizing.
- **Full** — fits the chart to the pane in both dimensions, so it occupies the full available width and height.
The exact spec transform each mode performs is defined in _Rendering Contract_ below.
Behavior of the selected mode:
- The control shows the four modes with the active one visibly indicated.
- The selected mode persists across sessions, stored in _Settings_ as `previewFitMode`.
- The default is the natural Original mode.
## Chart theme
The preview pane header carries a **Chart theme** picker — a value-select disclosure choosing which Vega-Lite config is injected when charts render:
- **Astrolabe** (default) — the house style; follows the app's light/dark theme.
- **Stock Vega-Lite** — injects nothing; charts render exactly as plain Vega-Lite defaults would anywhere else (white background, default palette and fonts).
- **Custom themes** — the user's saved themes (see _Theme Builder_ below), listed by name between the built-ins and the presets.
- **Edit themes…** — closes the custom-themes block (before the long preset roster, so it's visible without scrolling); opens the Theme Builder instead of changing the selection.
- **Presets** — the `vega-themes` preset configs (Excel, ggplot2, FiveThirtyEight, LA Times, Power BI, the Carbon family, …), rendered verbatim and independent of the app's light/dark theme. A **divider** separates the preset roster from everything above it — the built-ins, the user's themes, and the manage entry read as "ours"; the presets as the imported catalogue.
Behavior:
- The choice is a **global preference**, not per-snippet; it persists across sessions, stored in _Settings_ as `ui.chartTheme` (`custom:<id>` for a custom theme).
- The injected config applies at render time only — it is never written into the snippet's stored spec. A spec's own `config` block overrides the injected config property by property, so a snippet can opt out of any part of it locally (see also _Spec Editor → Spec ↔ config actions_).
- Image export reflects the selected theme: exports render from the same themed view.
- The Chart Builder preview and onboarding thumbnails are app surfaces and stay house-styled regardless of this choice.
- A selected custom theme whose record is missing (still loading, or deleted in another tab) renders as the house style; deleting the actively-selected theme resets the selection to Astrolabe.
## Theme Builder
The **Theme Builder** is a full-size modal for creating and editing custom chart themes — named, persistent Vega-Lite configs (see _Data Model → CustomTheme_). It opens from the Chart theme picker's "Edit themes…" entry.
Layout: a saved-theme list on the left; the open theme's editor on the right.
- **New theme** creates a theme seeded as a **copy of the chart theme currently selected** in the preview (house style, stock, a preset, or another custom theme), named after its source (e.g. "FiveThirtyEight copy") and auto-suffixed if taken. Duplicating a preset is the expected starting point. The other creation path is the editor's **Extract Config to New Theme** action (see _Spec Editor → Spec ↔ Config Actions_), which turns a pasted spec's `config` block into a theme directly.
- **Structured controls** organize the config into panels by domain — Color, Marks, Type, Title, Layout, Axes & grid, Legend, Headers, Formats — navigated by a vertical tab list, each panel's properties grouped into collapsible sections. A control writes one config property, and clearing it removes the key, so a theme stays a minimal diff against stock. The controls cover the common brand-tuning surface, not every Vega-Lite property.
- The theme's **name** and its full **config as editable JSON text** sit below the controls as the escape hatch for anything they don't expose. Invalid JSON is reported inline and blocks saving (and is the only editing surface while invalid); the text must parse to a JSON object.
- A **font control** applies a chosen font family across the whole config in one step: it sets the top-level `font` (Vega-Lite's default for every text mark, label, and title) and rewrites every explicit `font`/`labelFont`/`titleFont`/`subtitleFont` slot anywhere in the config — the slots that would otherwise keep overriding the new default. Offered fonts are the self-hosted roster plus any the user uploads (see _Data Model → FontAsset_), the user's own faces listed first.
- A **gallery** of small fixed sample charts re-renders live from the draft config — the same config-injection path the preview uses — spanning the mark types, the color families, faceting, and titled charts so one edit is previewed across every surface a config styles. **Every structured control has a visible mirror** in at least one card; the default chart size and tooltips are the exceptions (the cards are fixed-size; tooltips are hover-only). While the JSON is invalid, the gallery keeps the last valid state.
- **Save** commits the draft (disabled while unchanged or unparseable). Names are unique case-insensitively, like dataset names. **Delete** removes the theme after confirmation.
- Closing with unsaved edits prompts for discard, like other form modals. A backdrop click does not dismiss the builder (Escape and the close button do).
## Export control
The preview pane header also carries a per-chart **Export** control — a disclosure for copying or downloading the current chart's spec, or downloading its rendered image (PNG/SVG). It exports what the preview shows. The behavior is specified in _Import & Export → Per-chart export_; it lives in this header because the image formats are produced from the live rendered view.
## Data Inspector
Below the chart, a collapsible **Data** panel shows the rows behind the chart — the answer to "why is my chart empty or wrong" is to look at the data the spec produced. It reads the live rendered view, so it reflects exactly what was drawn.
- A **disclosure** at the bottom of the pane, collapsed by default (a debugging aid, not the default view); the open state persists across sessions, stored in _Settings_ as `ui.dataInspectorOpen`.
- An **Input | Resolved** switch chooses which rows to show:
- **Resolved** (default) — the rows the chart draws, _after_ the spec's transforms (filters, calculated fields, aggregation).
- **Input** — the parsed source rows, _before_ those transforms. A spec with no transforms shows the same rows for both.
- The table is read-only and capped (the first rows, with a "first N of M" note), like the dataset and builder previews, and updates whenever the chart re-renders.
- States: when nothing has rendered, the panel guides the user to render a chart; when the chosen view's table is empty, it names which side is empty and why (the resolved side's transforms produced nothing to draw; the input side's source has no rows).
- A **draggable divider** between the chart and the panel sizes the panel's height (the chart above absorbs the change) — the same window-splitter interaction as the layout panes (see _Application Shell & Navigation_). The height persists, stored in _Settings_ as `ui.dataInspectorHeight`.
## Rendering Contract
Before the chart is drawn, the spec shown in the editor is transformed into the spec actually rendered. Two deterministic transforms are applied in order. They are specified here because reproducing them faithfully is what makes references and fit modes behave correctly; the result is observable as the rendered chart.
**1. Dataset reference resolution.** Any named-data reference (`data` with a `name`) is replaced in-place with the referenced dataset's actual contents, shaped by the dataset's source and format (see _Datasets_):
| Dataset source / format | The reference's `data` becomes |
| ------------------------- | -------------------------------------------------------------------------------- |
| URL, fetched (any format) | its snapshot, inlined and tagged exactly like the inline rows below |
| URL, not yet fetched | a live URL reference to the dataset's address, tagged with its format (fallback) |
| Inline JSON | the parsed values, inlined |
| Inline CSV / TSV | the raw text, inlined, tagged with its format (CSV or TSV) |
| Inline TopoJSON | the value, inlined, tagged as TopoJSON |
- Resolution recurses into nested sub-specs (layered and concatenated specs, and a parent spec's child `spec`), so references anywhere in the spec are resolved.
- If a referenced dataset does not exist, rendering fails with a "dataset not found" error (see _Error Display_).
**2. Fit-mode sizing.** The selected fit mode rewrites the spec's sizing using Vega-Lite's responsive `"container"` sizing keyword, recursing into the same nested sub-specs:
| Mode | Transform |
| -------- | ------------------------------------------------------------------------------------------ |
| Original | spec sizing left untouched (the spec's own `width`/`height`, or Vega-Lite defaults, apply) |
| Width | set `width` to `"container"`; remove any explicit `height` |
| Height | set `height` to `"container"`; remove any explicit `width` |
| Full | set both `width` and `height` to `"container"` |
- For the responsive (non-Original) modes the chart's container-relative dimension follows the pane size, while the unconstrained dimension is recomputed naturally — this is why Width/Height do not preserve the original aspect ratio.
- The transform operates on a copy; the user's stored spec is never modified by rendering.
The preview renders the resulting spec without the charting library's built-in action/export menu, so the output is a clean chart with no overlaid controls.
## Error Display
When a spec cannot be rendered, the preview replaces the chart area with a clear, readable error message rather than a broken or partial visualization, and recovers on its own once the spec becomes valid again.
- Invalid JSON, incomplete specs, Vega-Lite errors, and data problems (e.g. a missing or unfetchable dataset) all surface as a legible error message.
- The message identifies it as a rendering error and includes the underlying reason, with a hint to check the JSON syntax and the Vega-Lite specification.
- As soon as the spec becomes valid again, the error clears automatically and the chart renders without any manual retry.
- Empty/blank specs are not treated as errors — they simply render nothing.
## Responsiveness
- The preview re-fits when the pane is resized, re-applying the current fit mode so the chart continues to honor the chosen sizing (see panes in _Application Shell & Navigation_).
- Resizing does not require a manual refresh; the displayed chart adapts to the new pane dimensions.
-110
View File
@@ -1,110 +0,0 @@
# 05 · Datasets
The **Dataset Manager** is a modal for creating and managing named, reusable datasets that snippets can reference by name. It is the home of the dataset library: a place separate from snippets where data lives once and is shared across many visualizations.
## Purpose & Model
Datasets are named blobs of data stored in the user's local library, independent of any single snippet. A snippet references a dataset by name rather than embedding the data inline, so the same data can power many snippets and be edited in one place.
- Datasets persist locally across sessions in a high-capacity local store, far larger than the budget available to snippets — large datasets belong here, not inline in specs.
- A snippet references a dataset using a Vega-Lite named-data reference, e.g. `{ "data": { "name": "MyDataset" } }`. When the _Live Preview_ renders a spec, it resolves any such named reference against the dataset library (see _Live Preview_).
- See _Data Model_ for the stored shape of a dataset.
## Opening & Navigation
- Opened from a header control or via the keyboard shortcut Cmd/Ctrl+K.
- The current view and the selected dataset are reflected in the URL, so a selected dataset produces a shareable/back-navigable location (see _Application Shell & Navigation_).
- Closing the modal clears the current selection and any open create form.
## Layout
A two-pane modal:
- **List pane** (left): a "New Dataset" action plus the list of all datasets, sorted most-recently-modified first.
- **Detail pane** (right): shows the selected dataset's details, the create form when creating, or an empty prompt ("Select a dataset or create a new one") when nothing is selected.
### List item
Each list item shows:
- The dataset **name**.
- A **meta line** combining: source ("URL" prefix for URL datasets), row count when known, the **format label** (JSON / CSV / TSV / TOPOJSON), and **size** (human-readable, e.g. B / KB / MB). A URL dataset that has not yet been fetched shows "not fetched" in place of the figures it does not have.
- A **usage badge** when one or more snippets reference the dataset, indicating how many.
Clicking an item selects it and shows its detail. Per-item actions (delete, plus copy-reference and build-chart) live in the detail pane for the selected dataset.
## Source Types
A dataset has one of two source types, chosen when creating it:
- **Inline** — the data itself is pasted in and stored directly in the library.
- **URL** — the dataset is fetched once from a remote http/https address when it is created, and the fetched data is **snapshotted** into the library, along with the address (kept so the snapshot can be re-fetched). A URL dataset then works offline and renders from its local copy; it is never re-fetched at render time, only on demand (see _Refresh_, below).
Either way the library holds the full data and profiles it. A URL dataset additionally records the address it was fetched from and when it was last fetched. Because the fetch happens in the browser, an address the browser cannot reach — offline, or one that blocks cross-origin requests — cannot be snapshotted; see _Actions → New / Create New_ for how that failure is handled.
## Supported Formats
Four data formats are supported, named in the UI and stored on the dataset:
- **JSON** — an array of objects (most common, profilable) or a single object.
- **CSV** — comma-separated with a header row.
- **TSV** — tab-separated with a header row.
- **TopoJSON** — topology/map data (a JSON object whose type marks it as a topology).
### Auto-detection
When the user pastes inline data, the app auto-detects the format and reports a **confidence** level (high / medium / low):
- Valid JSON parses to JSON, or to TopoJSON when it is a topology object — high confidence.
- Otherwise, multi-line text with a header row is detected as TSV (when tab-separated) or CSV (when comma-separated) — medium confidence.
- Unrecognized input yields no format (low confidence); saving is blocked with a message asking the user to check the input.
The detected format and source are shown as badges in the create form so the user can confirm or override the source (Inline/URL) before saving. For URL datasets the create form shows a format hint inferred from the URL's file extension (`.csv`, `.tsv`, `.json`, `.topojson`); the dataset's actual format is determined from the **fetched content** when it is created, falling back to the extension when the content is ambiguous.
## Profiling
For tabular data (JSON array-of-objects, CSV, TSV) — whether pasted inline or fetched from a URL — the app computes and stores a profile:
- **Row count** and **column count**.
- The list of **column names**.
- An **inferred type per column**: number, text/string, date, or boolean. Type inference looks at the column's values: all-numeric becomes number, all `true`/`false` becomes boolean, otherwise string; empty cells are ignored.
- **Size** in bytes of the stored data.
A **truncated data preview** of the raw data is also retained for display. A fetched URL dataset is profiled exactly like inline data; only non-tabular data and a URL dataset that has **not yet been fetched** are unprofiled (counts show "N/A").
## Detail Panel
The detail pane for a selected dataset shows:
- **Name**.
- **Comment** (optional free-text notes), when present.
- **Source** (URL datasets only): the address the snapshot was fetched from, and when it was last fetched (or "Not fetched yet"), alongside the **Refresh** action.
- **Overview**: statistics (rows, columns, size), the **column list** with each column's name and inferred type shown with a simple type indicator, and created/modified timestamps.
- **Preview**: a sample of the data. Tabular datasets (CSV, TSV, or a JSON array-of-objects — inline or fetched) render as a **table** of the first rows under the profiled column names, with a note when more rows exist than are shown. Non-tabular payloads (a single JSON object, TopoJSON) render as pretty-printed JSON, and a URL dataset that has not yet been fetched shows a short placeholder.
- **Linked Snippets**: the list of snippets that reference this dataset by name. This is the dataset side of bidirectional dataset↔snippet linking (see _Snippet Library_).
## Actions
A destructive or off-screen outcome raises a confirming toast; an action whose result is immediately visible is confirmed by that change. Any action may raise an error toast on failure (see _Application Shell & Navigation_ → Toasts).
- **Copy Reference** — copies the by-name reference object to the clipboard, ready to paste into a spec:
`{ "data": { "name": "MyDataset" } }`
The clipboard write is invisible, so it is confirmed _inline on the control_ ("Copied"), announced politely to assistive technology — not a toast.
- **New / Create New** — opens the create form in the detail pane with fields: **name** (required, unique), **source** toggle (Inline / URL), the **data** (a paste area for inline, a URL field for URL source), and an optional **comment**. Save is disabled until a name and valid data/URL are present. For a **URL** dataset, saving fetches and snapshots the address; the Save control shows a busy state while fetching. If the fetch fails — offline, blocked by the host (cross-origin), not found, empty, timed out, or larger than the fetch size limit — the form shows a readable, cause-specific error and offers a one-click **"Paste data inline instead"** that switches the form to an inline paste (keeping the name and comment), rather than saving a broken record. The size-limit case is the exception: because pasting an oversized file inline would not help, its message points at using a smaller or pre-aggregated source rather than the inline fallback. On success the new dataset is shown selected in the detail pane — that visible result is the confirmation, so no toast is raised (a dataset created _off-screen_ via Extract does toast; see _Spec Editor_).
- **Refresh** (URL datasets) — re-fetches the dataset's address and re-snapshots it, re-profiling the data and advancing the last-fetched time. The updated figures are the visible confirmation, so success raises no toast; a failed refresh raises an error toast. Refresh is also how a URL dataset that has not yet been fetched (e.g. one migrated from an older version) acquires its snapshot.
- **Edit** — rename, edit the comment, change the source data, and (for URL datasets) change the address. Updating inline data re-profiles it; changing a URL dataset's address re-fetches and re-snapshots it; editing only a URL dataset's name or comment does **not** re-fetch. The modified timestamp advances.
- **Delete** — asks for confirmation ("Delete \"Name\"? This cannot be undone."), then removes the dataset and clears the selection.
## Build Chart From Dataset
From a selected dataset the user can launch the visual _Chart Builder_ (see _Chart Builder_) pre-targeted at that dataset, producing a new snippet whose spec references the dataset by name. This relies on the dataset's profiled columns, so a URL dataset must have been fetched first — an unfetched URL reference has no schema to map (use _Refresh_ to fetch it).
## Extract Inline Data → Dataset
The reverse flow starts in the editor: a user can extract inline `data.values` out of a spec into a new named dataset (see _Spec Editor & Draft/Published Workflow_). The result appears here as a new dataset, and the originating snippet's spec is rewritten to reference it by name.
## Naming & Uniqueness
- Dataset names must be **unique**. Attempting to create a dataset with a name already in use is rejected with an error toast.
- During bulk operations such as import, conflicting names are automatically suffixed to remain unique rather than overwriting existing datasets (see _Import & Export_).
- Renaming a dataset that is referenced by snippets keeps references consistent by updating the matching named-data references in affected specs.
-180
View File
@@ -1,180 +0,0 @@
# 06 · Chart Builder
The Chart Builder is a visual, no-JSON way to compose a Vega-Lite chart from a selected dataset. The user picks a mark type and maps the dataset's columns to encoding channels; the builder produces a complete Vega-Lite spec and saves it as a new snippet that references the dataset. It is intended for users who want to start a chart quickly without hand-writing JSON in the _Spec Editor & Draft/Published Workflow_.
> **Design level — "smart + guarded" (Tier B).** The builder is field-first — the user works from a shelf of the dataset's columns and drops them onto encoding channels — and stays within the inputs below, but it is not a dumb composer: it picks a sensible default mark for the data shape, offers only field types valid for each column, keeps unsuitable channel mappings out of reach, and surfaces non-blocking guidance for encodings that render poorly. These behaviors are derived from cross-source chart-choice research recorded in [`docs/exploration/chart-builder-research.md`](../exploration/chart-builder-research.md) (the convergence of Draco, Voyager, the FT Visual Vocabulary, and Datawrapper). On top of this "smart + guarded" base sits the **intent-first front door** (Tier C — _what do you want to show?_, see _Intent_ below): an on-ramp that recommends a whole chart from the user's stated intent, without replacing the mark-first builder beneath it.
## Opening
The builder has several doors; the guided creation path must be visible where the intent to make a chart forms, not buried behind the data model.
- **The library's creation surface** — the primary **Build Chart** action (see _Snippet Library → The List_). Opens the builder with no preselected dataset; the builder picks the **most recently modified dataset** itself.
- **The onboarding canvas** — the data-first "Build a chart from your data" door (see _Snippet Library → First-Run & Empty Workspace_).
- **A dataset's "Build Chart" action** in the _Datasets_ manager — the contextual shortcut; opens the builder on that dataset.
- Opens as a modal dialog over the application; the URL reflects the loaded dataset's "build" form (or `#build` while no dataset is loaded) so the open builder is shareable/restorable (see _Application Shell & Navigation_).
- On open, the builder loads its dataset and pre-populates sensible defaults (see below).
### Dataset picker
Which data the chart builds from is itself a builder choice. A **Dataset** picker sits at the top of the configuration pane, showing the loaded dataset and letting the user switch to any other without leaving the builder.
- Switching while the configuration is still the untouched opening default **re-derives fresh smart defaults** for the new dataset.
- Switching after the user has built on the configuration **rebases** it instead: chart-level intent survives (mark, title/subtitle, explicit size, sort/stack, calculated fields, expression filters), while bindings to columns the new dataset lacks are shed (encodings cleared, predicate filters dropped). A same-schema dataset — the common switch — keeps everything.
### No datasets
With an empty dataset library the builder shows a **no-datasets state** instead of controls: it says what the builder does and offers one primary next step — **Add a dataset**, which opens the _Datasets_ manager on its create form. The guided path never dead-ends.
### Intent (the front door)
A persistent **"What do you want to show?"** strip sits at the top of the configuration pane, under the dataset picker — the builder's guided on-ramp (Tier C). It offers a small set of analytic **intents**, each of which, when chosen, **sets the whole chart up for you** ("do it for me", modelled on Tableau's _Show Me_):
- The intents map the FT Visual Vocabulary / Datawrapper taxonomy onto the builder's marks and channels: **Compare** (magnitude across categories → a bar of counts), **Ranking** (the same, sorted by value), **Change over time** (a line of the first measure over a date), **Correlation** (a scatter of two measures), **Distribution** (a histogram — a binned measure vs count), **Part-to-whole** (a stacked bar split by a second category), and **Heatmap** (a two-category grid shaded by count).
- Picking an intent reshapes the chart — its **mark, encodings, and sort/stack** — to that intent's recommended layout, derived from the dataset's column roles. It **keeps** the dataset, the data transforms (filters / calculated fields), and the chart properties (title/subtitle/size): those are orthogonal to _what kind of chart_.
- The strip is an **on-ramp, not a gate**: it seeds the mark-first builder, which the user can then adjust freely or ignore entirely (and can always drop to Monaco). The chosen intent is builder-local steering — it **never enters the produced spec** (the JSON stays the document).
- On open, the strip **pre-highlights the intent matching the data-aware default** (so a category-vs-count default opens on _Compare_). The highlight is **derived from the configuration, not stored**: the chip whose recommended layout the live chart currently matches stays highlighted; once the user edits away from any recommended layout, none is highlighted — a **Custom** chart.
- Intents the dataset **cannot satisfy** are **disabled** (Tableau _Show Me_): a _Correlation_ needs two number columns, a _Heatmap_ or _Part-to-whole_ needs two category columns, _Change over time_ needs a date, and so on. A disabled chip stays perceivable and carries the reason in its accessible name; it is never hidden.
- Keyboard/focus follow [`architecture/10`](../architecture/10-interaction-and-feedback.md) §5 (an APG **toolbar**: one tab stop, a roving tabindex, arrow keys move focus, Enter/Space applies the intent — so navigation never reshapes the chart by accident).
## Layout
A two-pane modal:
- **Left — configuration:** the dataset picker, a **Data** section (row filters, calculated fields, and a collapsible row preview — see _Data_ below), mark type selector, a **field shelf** (the dataset's columns, field-first), a **Marks** card (the Colour and Size encodings), and a "Create Snippet" action. Chart-level properties (title/subtitle, width/height) sit on the **preview side**, under the chart (see _Chart properties_).
- **Right — live preview:** the **Columns** (X) and **Rows** (Y) shelves stacked above a rendered chart that updates as the configuration changes, with a placeholder/error area. Position is a property of the chart, so its controls sit on the chart (Tableau's Columns/Rows metaphor).
## Data (preview, filters, calculated fields)
A **Data** section sits at the top of the configuration pane — "here are your rows; shape them, then encode them". It previews the source rows first, then offers controls to shape them before encoding. Everything here is optional; a chart can be built with none of it. The shaping controls emit the spec's top-level `transform` array (see _Output_).
The section is ordered **input → shaping** so the distinction reads at a glance: the row preview (the **input** data) comes first, the filters and calculated fields (which shape what the chart actually draws) come below it.
### Data preview
- The first item in the section: a collapsible, **read-only** sample of the dataset's first rows (capped), with a per-column **type chip** in each header. It lets the user sanity-check inferred types against the actual values _before_ building — exactly when type inference is most likely to surprise. Editing the data is out of scope. A non-tabular payload (a single JSON object, TopoJSON, an unfetched URL) has no rows to show.
- The preview shows the dataset's **raw source rows**_before_ the filters and calculated fields below are applied; it does not show derived columns. Its position above those controls makes that explicit: it is the input, not the result. The transformed result is shown by the **data inspector** under the chart on the preview side (below), which switches between input and resolved rows read from the rendered view.
### Filters
- A list of row filters, each added via **Add filter** and removable. All filters combine (logical **AND**) and apply to the raw rows **before** any encoding aggregation, so "filter rows, then aggregate" is the natural reading.
- A filter is, by default, a **guarded predicate** — a **field**, an **operator**, and a **value** — needing no expression for the common case:
- The **field** dropdown offers the dataset's columns plus any calculated fields.
- The **operators** offered depend on the field's type: a measure or temporal field offers `is` / `is not` / `<` / `≤` / `>` / `≥` / `is between` (two bounds); a category offers `is` / `is not` / `is one of` (a comma-separated membership list). A quantitative value compares as a number; other types compare as text (ISO dates sort correctly as text).
- A filter can be switched to an **expression** power-mode — a raw Vega predicate expression (e.g. `datum.value > 0`) — for what the guarded shelf can't say. The toggle is reversible.
- An **incomplete** filter (no value yet, a blank range bound, an empty or syntactically-invalid expression) is ignored, so the live preview keeps rendering while the user types.
### Calculated fields
- A list of derived fields, each added via **Add field** and removable: a **name** and a **Vega expression** that produces a new column (e.g. `profit` = `datum.revenue - datum.cost`).
- A named calculated field appears in the encoding **channel dropdowns** like any real column (it defaults to **Quantitative**, the common arithmetic case, and its type can be overridden on the channel within the valid set). Calculated fields are applied **before** filters, so a filter may reference a derived field.
- Removing or renaming a calculated field that a channel referenced **clears that channel** (the produced spec never encodes a field that no longer exists).
### Expression validation
- Both expression inputs (a filter's expression mode, a calculated field) are validated with **Vega's own expression parser** — the same one the chart uses — so a syntax error is reported **inline** the moment it appears, matching exactly what the chart would accept.
- A `datum.<field>` reference that does not match a known column raises a soft **"unknown field"** warning (a typo guard) without blocking — the value is genuinely valid Vega, it just won't resolve.
- An expression that does not parse is **left out of the produced spec** (like an incomplete filter or an unnamed calculated field): the inline error is the only feedback, and a half-typed expression never reaches the renderer — the preview keeps showing the last valid chart.
## Inputs and Controls
### Mark type
- Single selection from an exact set of six mark types: **Bar, Line, Point, Area, Circle, Heatmap**. **Heatmap** is the Vega-Lite `rect` mark — an X×Y grid of cells shaded by a Colour measure; it is labelled by the chart it makes rather than its geometry, since "Rect" is opaque to the no-JSON audience.
- On open, the mark **defaults to the type that best fits the pre-populated X/Y field-type shape** (Tier B smart default): a temporal axis against a measure → **Line**; two measures → **Point**; a category against a measure → **Bar**; two categories → **Point**; and **Bar** as the fallback when only one axis (or none) is mapped. **Heatmap is never the auto-default** — it reads only with a Colour measure, which the X/Y shape alone can't determine, so it stays a deliberate pick (guidance nudges the missing Colour). The user can switch to any of the six afterward.
- Exactly one mark type is active at any time; selecting one updates the preview.
### Encoding channels
Exactly four channels are offered: **X, Y** (the positional axes, on the on-chart Columns/Rows shelves) and **Colour, Size** (the Marks card). Assignment is **field-first**.
- **The field shelf** lists the dataset's detected columns (see _Datasets_), each with a small type glyph, plus any calculated fields and a field-less **"Count of records"** measure (Vega-Lite `count`). Past a threshold of columns the shelf groups into **Dimensions** (categories/dates) and **Measures** (numerics); a small dataset stays a single flat list. A column already mapped somewhere is dimmed (it may still be placed on more than one channel).
- **Assigning a field:** clicking a shelf field opens an explicit **channel chooser** listing the channels that accept it (an occupied channel is labelled with what it would replace); picking one places the field there. With a channel **armed**, the chooser is skipped — the clicked field fills the armed channel directly. A channel slot is armed by clicking it; arming is visible at the shelf (an accent ring plus a status line naming the target — "Assigning to X — choose a field below. Esc cancels"), and **Esc** disarms without closing the builder. A field that no channel can take offers no choices.
- **A mapped channel is a pill:** a leading type chip, the field (or "Count") label, and a remove (×). The type chip **is the field-type control** — activating it opens a **direct pick** of the types **valid for that column** and channel (Tier B valid-type locking): number → {Quantitative (default), Ordinal, Nominal}; date → {Temporal}; text → {Nominal (default), Ordinal}; boolean → {Nominal}. When only one type applies (e.g. a date), the chip is inert. A fresh mapping defaults its type from the inferred column type (numeric → Quantitative, date → Temporal, otherwise Nominal).
- **Constant values (the Property model):** the **Colour** and **Size** channels may instead hold a **fixed constant** — a literal colour or size applied to every mark, emitted as Vega-Lite `{ value }` rather than a field binding. An empty Colour/Size slot offers a **"Use a constant"** ghost button; the bound constant shows a colour picker (Colour) or a number (Size). X and Y stay field-only (a constant position is not useful). Switching a channel between a field and a constant is reversible, and the prior field type is preserved across the toggle.
- **Size discipline:** the **Size** channel accepts only columns whose natural type is a magnitude (numeric) — size implies an ordered magnitude, so categories and dates are not placed on Size by assignment (they remain available on X/Y/Colour). A constant size is always allowed.
- **Clearing** a channel (the pill's ×) leaves it out of the produced spec.
- A **Swap X/Y** control, by the on-chart shelves, exchanges the X and Y bindings (field/constant and type) in one click — for quickly flipping the axes of the pre-populated default.
### Faceting placeholder (reserved)
Each on-chart shelf shows, beside its axis slot, a **non-interactive placeholder** for **faceting → small multiples** (a future capability). It only signals where row/column faceting will live; it does nothing yet.
### Transforms (per channel)
Once a column is mapped, the channel offers the transforms that apply to its field type — and only those:
- **Aggregate** (any field — the menu narrows by field type): a Quantitative field offers `Sum`, `Mean`, `Median`, `Min`, `Max`, `Count distinct`, or `None`; a Temporal or Ordinal field offers `Min`, `Max`, `Count distinct` (an ordering but no arithmetic); a Nominal field offers `Count distinct` alone. `Count distinct` counts a field's unique values, so the channel reads as a **quantitative measure** whatever the field's own type (e.g. "unique customers per region" on a Color or Y channel); the field's asserted type is preserved and restored when the aggregate is removed. (The field-less `Count` measure is chosen via the "Count of records" column option above.)
- **Bin** (a Quantitative field): bins the values into ranges — e.g. a Quantitative X binned with a Count Y is a histogram. Binning and aggregating the same field are mutually exclusive (setting one clears the other).
- **Granularity** (a Temporal field): a Vega-Lite `timeUnit` — Year, Year-Quarter, Year-Month, Year-Month-Day, Quarter, Month, Week, Day of month, Day of week, Hour — or `None` (raw timestamps). Defaults to **None** (no silent change to what the raw data shows).
### Sort and stacking (chart-level)
These controls appear only when they apply:
- **Sort** (when X and Y form a category-vs-measure pair): sorts the categorical axis by the measure — `Ascending`, `Descending`, or `None` — the standard way to rank a bar chart.
- **Stacking** (a Bar or Area mark with a Color series): `Stacked` (absolute) or `100%` (normalized, part-to-whole). Bars/areas without a Color series, or other marks, show no stacking control.
### Default pre-population
- On open, the builder chooses a **data-aware "safest bet"** so it never opens on a degenerate, unrenderable chart (e.g. a 10k-row dataset whose first two columns are an id and a high-cardinality key would otherwise draw one bar per row). When the dataset is profiled (per-column cardinality available), it prefers, in order: a **low-cardinality category vs a count of records** (a tidy bar); else a **time series** of the first measure over a date; else a **scatter** of two measures. Each is guaranteed to render and read cleanly. The measure for the category case is the field-less **count** deliberately — it is always meaningful and avoids summing an id-like numeric (e.g. a Row ID) into nonsense.
- When the dataset carries no cardinality stats (older or URL-backed datasets), it falls back to the original positional rule: the first detected column on **X** and the second (if any) on **Y**, each with its derived field type.
- Either way, remaining channels start unmapped with no transforms, and the mark starts at the smart default for the resulting X/Y shape (see _Mark type_), not unconditionally Bar. The intent-first front door (future) layers richer recommendations on top of this default; it does not replace the need for a sane opening state.
### Guidance (non-blocking)
The builder surfaces short, plain-language hints for configurations that render but read poorly — advisory only, never blocking the **Create Snippet** action (validation below is the sole gate). A hint states the _problem_; where there is an obvious remedy, it also offers one or more **one-click fix** buttons that apply the change to the configuration (e.g. _Aggregate as Sum_, _Swap X/Y_, _Switch to Point_, _Stack_, _Remove colour_). A fix is an offer, never a forced change — applying it updates the config and the hint re-derives away. Interaction/accessibility of these actions follows [`architecture/10`](../architecture/10-interaction-and-feedback.md) §5 (polite announcement, focus moved off the removed button). These follow the chart-choice research ([`docs/exploration/chart-builder-research.md`](../exploration/chart-builder-research.md)) and include, for example:
- A **Line**, **Area**, or **Heatmap** mark with only one axis mapped (both axes are needed to draw it).
- A **Bar/Line/Area** whose X and Y are both categories (nothing to measure).
- A **Heatmap** with both axes mapped but **no measure on Colour** (its cells have nothing to shade) — offers a one-click _Colour by count_, the canonical cross-tab heatmap. _Two measures on a heatmap are exempt from the scatter nudge below: a binned 2-D histogram is two quantitative axes shaded by count._
- **Two measures** on a non-scatter mark (a scatter — Point/Circle — usually reads better; Heatmap excepted).
- An **Area** chart split into multiple colour series (per-series change is hard to see).
- A **Bar/Line/Area** that pairs a category axis with a **raw (un-aggregated) measure** over a many-row dataset — it draws one mark, and one axis label, per row, so the category axis becomes an unreadable picket fence. The hint suggests aggregating the measure (one mark per category) or, for a bar, flipping to a horizontal bar (Swap X/Y) where long labels stay readable (FT Visual Vocabulary / Datawrapper). Only the un-aggregated case (mark-count = row-count) is detected; flagging an _aggregated_ axis that still has many distinct categories needs per-column distinct counts the profiler does not yet compute (a known gap).
A clean configuration shows no hints.
### Chart properties (optional)
A slim strip pinned **under the live preview** — these describe the chart itself, so they live on the chart side rather than in the configuration pane:
- **Title** and **Subtitle** text inputs, written into the spec's top-level `title` (a bare string for a lone title; the `{ text, subtitle }` object form when both are set). A subtitle is emitted only alongside a title — Vega-Lite has no standalone subtitle — so the Subtitle input is disabled until a title exists. A non-empty title is also preferred verbatim as the created snippet's name.
- Optional numeric **Width** and **Height** inputs in pixels.
- When left empty, sizing is default/responsive (consistent with _Live Preview_); when provided, the values are written into the spec **and the builder preview renders at that explicit size** (the preview's fit-to-pane sizing applies only while sizing is auto).
## Live Preview
- The right pane renders the chart described by the current mark, encodings, and dimensions, resolving the dataset reference to its actual data (same rendering behavior as _Live Preview_).
- Updates are debounced: changes to mark, encodings, or dimensions trigger a re-render after a short pause rather than on every keystroke.
- While no encoding is mapped, the pane shows a placeholder instructing the user to configure at least one encoding.
- If the spec fails to render, the pane shows an inline error message describing the problem instead of a chart.
- Under the chart, the same **data inspector** as _Live Preview_ (Input | Resolved rows, collapsed by default) lets the user compare the source rows against what the builder's filters and calculated fields produced. It appears only with a live chart, so it never doubles the placeholder/error.
## Validation
- A chart requires **at least one** channel bound — a field, a count, or a constant value.
- While nothing is bound, the "Create Snippet" action is disabled and the preview shows the configuration prompt.
## Output / Create
Selecting "Create Snippet" produces the final artifact:
- Builds a complete Vega-Lite spec containing: the schema reference, a named data reference to the dataset, any top-level `transform` (calculated fields first, then row filters — see _Data_), the chosen mark (with tooltips enabled), the bound encodings (a field encoding carries its field and field type plus any aggregate / bin / `timeUnit` transform; a constant encoding is a `{ value }`), chart-level sort and stacking where set, any title/subtitle, and any explicit width/height.
- Channels left unmapped are omitted; if no encodings exist the spec omits the encoding block entirely (prevented by validation here).
- Creates a new snippet from that spec with an auto-generated descriptive name, adds it to the snippet library, and records that it was built from the dataset.
- Links the snippet to the dataset by recording the dataset reference, so the bidirectional snippet↔dataset relationship is established (see _Datasets_).
- Closes the builder; the newly created snippet becomes the active snippet in the library/editor. **No success toast** — the result is immediately visible (the new snippet opens in the editor), so a toast would be noise (architecture 10 §1, "toast only what the user can't already see"). This refines the earlier blanket "every action toasts" rule, consistent with the Extract-to-dataset / publish reconciliation.
## Open in builder (edit in place)
The builder is also the way to **revise** a chart it could have produced — not only create one. The **editor toolbar** (top of the Spec Editor, alongside the Draft/Published toggle, Config, Revert, and Publish) offers an **Open in builder** action that reopens the active snippet in the builder, populated from its spec — the visual counterpart to editing the same chart's JSON, placed where that editing happens.
- **When it is offered.** Only when the active snippet's **published spec** is **losslessly representable** in the builder's dialect _and_ the dataset it references still exists. Losslessness is judged by parsing the spec back to a builder configuration and **re-assembling it for an exact comparison** against the original (ignoring key order, the `$schema` stamp, and the builder's injected `tooltip`), not by enumerating supported features — so the gate stays correct automatically as the builder's dialect grows. A spec the builder cannot reproduce exactly (hand-authored richness, an unsupported channel/mark, inline `data.values`, a `url` source) stays **Monaco-only**, and the action is **hidden** for it — the same content-gated treatment as _Extract to Dataset_ (a permanently-disabled control the user can't enable in the moment would read as broken; see _Interaction & Feedback_ → action visibility). The builder references a dataset **by name** — its sole data model — so only a snippet that references a saved dataset can hydrate.
- **What opening does.** Hydrates the builder from the snippet — mark, encodings, transforms, sort/stack, title/subtitle, size — and loads the referenced dataset's columns. The configuration pane names the snippet under edit ("Editing _name_"), and the dataset picker behaves as a **rebase** (built-on work) for any subsequent dataset switch — a loaded chart is never treated as a fresh default.
- **An edit session is builder-local, not part of the URL.** Like the builder's in-progress configuration generally, the "editing _name_" context is transient: a reload, Back, or shared link reopens the builder as a fresh **create** flow on the same dataset rather than restoring the edit. No data is lost — the published snippet is untouched until _Save changes_ — and re-entering the edit is one click from the toolbar.
- **Saving.** The primary action becomes **Save changes** (in place of "Create Snippet"): it **republishes** the built spec into the same snippet — overwriting **both** its published and draft versions, so there is no pending draft to reconcile — while keeping the snippet's identity, timestamps' `created`, and dataset links. A user-chosen name is preserved; an auto-named snippet re-derives its name from the new content (as _Publish_ does). The edited snippet becomes the active snippet. As with Create, there is no success toast (the result is immediately visible) and the action is gated on the same validation (at least one channel bound).
- **The JSON stays the document.** Because Open-in-builder is strict, the builder never silently overwrites a richer spec it cannot represent — it is a view that emits the spec, never a competing source of truth.
## Closing
- The builder can be dismissed without creating anything (close control / modal dismissal).
- Closing resets all builder state (dataset, mark type, encodings, dimensions, preview) so a later open starts fresh, and any pending preview render is cancelled.
-85
View File
@@ -1,85 +0,0 @@
# 07 · Settings
Astrolabe lets users tune appearance, the spec editor, preview performance, and date formatting. Rather than a separate Settings modal, **each preference lives next to what it affects and applies immediately** — the appearance theme is a header toggle, editor preferences sit in the editor pane, render performance in the preview pane, and date formatting in the library. All settings persist locally and apply across sessions on the same device. Settings load at startup; any unknown or missing value falls back to its factory default, so older or partial saved settings never break the app.
> **Why distributed, not a modal (resolved).** Settings were originally specified as one central modal with an explicit Apply/Cancel commit. A design review (see _Architecture 10 · Interaction & Feedback_) moved them to per-pane, live-applied controls: it matches how theme and preview fit mode already work, lets a changes effect be seen in the very pane being configured, and keeps each settings block independently extensible. The settings, options, and defaults below are unchanged — only their presentation and commit model changed.
## Opening the settings
- **Appearance (UI theme)** is a one-click toggle in the application header.
- **Editor**, **Performance**, and **Formatting** clusters are each opened by a small **settings (gear) control** in the toolbar of the pane they govern — the editor pane, the preview pane, and the library, respectively. The control discloses a popover of that clusters controls.
- The keyboard shortcut **Cmd/Ctrl+,** opens the **Editor** settings cluster (the primary configuration surface).
- A disclosed popover is dismissed with **Esc** (which returns focus to its gear) or by clicking outside it; at most one settings popover is open at a time.
## Settings
### Appearance
Controls the overall UI theme. Choosing the Dark theme switches the whole application chrome to a dark presentation.
| Setting | Options | Default |
| -------- | ----------- | ------- |
| UI theme | Light, Dark | Light |
The UI theme is a one-click **header toggle** (shipped in M1.5). It reads and
writes the persisted `ui.theme` value directly and applies immediately — there is
no separate Appearance control to keep in sync.
### Editor
These settings configure the spec editor used to edit Vega-Lite specs (see _Spec Editor & Draft/Published Workflow_). They take effect in the editing surface for the snippet spec.
| Setting | Options / Range | Default |
| ------------ | -------------------------------------------------- | ------- |
| Font size | 1018 px (integer) | 12 px |
| Editor theme | Auto + explicit overrides (provisional — see note) | Auto |
| Minimap | On / Off | Off |
| Word wrap | On / Off | On |
| Line numbers | On / Off | On |
| Tab size | Integer number of spaces | 2 |
- Font size is chosen along a 1018 range; the current value is shown alongside the control.
- Editor theme controls the syntax/color presentation inside the editor. **Provisional (to be finalized as we implement the editor):** the default is **Auto**, which derives the editor theme from the app UI theme (light app theme → light editor theme, dark → dark), using custom Monaco themes that match the app chrome. The user may override Auto with an explicit editor theme; the exact override list (custom themes, and whether to include High Contrast or the stock Monaco themes) is deferred. Stored as `editor.theme` with an `'auto'` sentinel for the follow-the-app default.
- Minimap toggles the condensed overview strip beside the editor.
- Word wrap toggles soft wrapping of long lines.
- Line numbers toggles the line-number gutter.
- Tab size sets the indentation width applied while editing.
### Performance
| Setting | Range | Default |
| --------------- | ----------- | ------- |
| Render debounce | 5005000 ms | 1500 ms |
- Render debounce is the delay after the user stops typing before the preview re-renders (see _Live Preview_).
- Tradeoff: a lower value makes the preview feel snappier and more immediate but re-renders more often and uses more CPU; a higher value keeps the app calmer and lighter but makes the preview feel laggier behind the spec.
- The current value is shown alongside the control.
### Formatting
Governs how dates are rendered throughout the app, for example the timestamps shown in the _Snippet Library_ list.
| Setting | Options | Default |
| ------------------ | ----------------------- | ------- |
| Date format | Smart, ISO 8601, Custom | Smart |
| Custom date format | Free-text format string | (empty) |
- **Smart**: relative, human-friendly rendering (e.g. "Today", "Yesterday", "3d ago", falling back to a full date for older items).
- **ISO 8601**: a full ISO 8601 timestamp.
- **Custom**: dates render using the user-supplied format string.
- The custom format string field is only relevant when Date format is set to Custom; it is shown only in that case (placeholder guidance such as `yyyy-MM-dd HH:mm`).
## Related persisted preferences (documented elsewhere)
The following preferences also persist locally across sessions and, like the clusters above, are managed by controls in the pane they affect; they are documented in their own sections:
- **Preview fit mode** — how the preview is sized/fit; see _Live Preview_.
- **Chart theme** — which config charts render and export with (`ui.chartTheme`); see _Live Preview → Chart theme_.
- **Snippet sort preference** — the snippet list's sort field and direction; see _Snippet Library_.
## Behaviors
- **Live apply**: Every control applies its change immediately — there is no Apply/Cancel commit step. The effect is visible in the pane being configured (the editor reflows, the preview re-renders at the new debounce, the library re-formats its dates), so no separate confirmation is needed. This matches the always-live header theme toggle and preview Fit control.
- **Reset to defaults**: The **Editor** cluster offers a Reset that restores the editor settings to their factory defaults. (Other clusters are single, self-evident controls; there is no global "reset everything" — each control is individually reversible.)
- **No dirty / discard state**: Because changes commit as made, there is no "unsaved changes" indicator and nothing to discard on dismiss; closing a settings popover simply hides it.
- **Startup load**: Settings are read on startup and applied to the UI, editor, preview, and library; missing or unrecognized values silently use their defaults.
-124
View File
@@ -1,124 +0,0 @@
# 08 · Import & Export
Astrolabe lets a user back up or transfer their entire workspace as a single JSON file, and bring data back in by importing such a file. These two whole-workspace actions are triggered from header controls labelled **Import** and **Export**. Import always merges with existing data; it never replaces what is already stored.
Separately, a single chart can be exported on its own — its spec or its rendered image — from the Live Preview pane (see _Per-chart export_ below). That is distinct from the workspace Export: it gets _one_ chart out, not a backup of the library.
## Export
Export produces one downloadable JSON file containing every snippet (see _Snippet Library_), every dataset (see _Datasets_), every custom chart theme (see _Live Preview → Chart theme_), and every uploaded font face (see _Live Preview → Chart theme → fonts_), wrapped in an envelope carrying format metadata.
- **Trigger**: the **Export** header control runs the export immediately (no intermediate dialog).
- **Contents**: all snippets, datasets, custom chart themes, and uploaded fonts currently stored, plus envelope metadata.
- **Empty workspace**: if there are no snippets, the user is informed ("No snippets to export") and no file is downloaded — even if datasets, themes, or fonts exist.
- **Filename**: `astrolabe-project-YYYY-MM-DD.json`, where the date is today's date (export day).
- **Feedback**: on success a toast reports the counts, e.g. "Exported 4 snippets, 2 datasets and 1 theme" (the dataset, theme, and font clauses are omitted when their counts are zero; singular/plural wording adapts to the counts).
### Export envelope shape
The downloaded file is a single JSON object: an envelope with a format `version`, an export timestamp, an exporter tag, and the data arrays.
```json
{
"version": "1.0",
"exportedAt": "2026-06-03T12:00:00.000Z",
"exportedBy": "Astrolabe",
"snippets": [
/* full snippet objects (see Data Model) */
],
"datasets": [
/* full dataset objects (see Data Model) */
],
"themes": [
/* full custom chart theme objects (see Data Model) */
],
"fonts": [
/* uploaded font records, bytes base64-encoded (see Data Model) */
]
}
```
- `version` — export format version (currently `"1.0"`).
- `exportedAt` — ISO 8601 timestamp of the export.
- `exportedBy` — fixed identifier `"Astrolabe"`.
- `snippets` / `datasets` / `themes` — arrays of complete records as defined in _Data Model_, each including its record `version` field. (This is the per-record schema version, not the envelope `version` above.) `themes` is additive: exports always write it, and importers treat it as optional, so pre-theme envelopes remain valid `"1.0"` files.
- `fonts` — the uploaded font faces, each a complete record with its bytes **base64-encoded** (JSON cannot carry binary). Without this a theme or snippet referencing an uploaded font would import on another machine with only the family name, falling back to a system font. Like `themes`, `fonts` is additive — exports always write it, importers treat it as optional, so older envelopes remain valid `"1.0"` files.
## Per-chart export
A single chart can be exported on its own, separately from the whole-workspace Export above. The affordance is an **Export** control in the **Live Preview pane header** — placed there because exporting an image needs the chart that is currently rendered, and "export this chart" reads naturally beside the chart you are looking at. It is a disclosure that reveals four actions in two groups:
**Spec** (always available when a snippet is open):
- **Copy spec** — copies the currently-shown spec (draft or published, matching the editor's view) to the clipboard as JSON. Confirmed by a success toast, since a clipboard write is otherwise invisible.
- **Download JSON** — downloads the currently-shown spec as a `.vl.json` file.
- **Referenced data** (shown only when the spec references one or more saved datasets) — a choice between **Inline** (default) and **Keep refs**. _Inline_ replaces each `{ data: { name } }` reference with the dataset's actual values so the exported spec renders standalone (outside Astrolabe), leaving the authored sizing untouched; _Keep refs_ exports the reference as written (which only resolves inside Astrolabe). It applies to both spec actions. If inlining is requested but a referenced dataset is missing from the library, the export is declined with a clear error.
**Image** (available only when a chart is currently rendered — the actions are disabled, with an explanatory line, while the preview is empty or showing an error):
- **Download PNG** — a rasterized image of the chart as shown.
- **Download SVG** — a vector image of the chart as shown. When the chart uses an **uploaded** font (not a built-in roster or system family), that face is embedded into the SVG as a base64 `@font-face` rule, so the file renders the right type off-app instead of falling back to a system font; this happens automatically, with no option to configure.
- **Resolution** (PNG) — `1×` / `2×` / `3×`, default `1×`. These are multipliers **of the display's pixel density**, so `1×` already matches on-screen crispness on a high-DPI (Retina) display; higher values produce larger images for print or zoom. (SVG is resolution-independent and ignores this.)
- **Background**`Theme` (default) / `White` / `None`. The chart itself renders on a transparent background (so on screen it shows the pane colour); export therefore fills it: _Theme_ matches the active theme's background, _White_ is always white, _None_ keeps it transparent. Applies to both PNG and SVG.
Every format exports _what is on screen_: the same spec the editor shows and the same chart the preview renders (current fit mode included). With a _Theme_ background the exported image reflects the active light/dark theme.
- **Filename**: derived from the snippet's name, made filesystem-safe (whitespace and illegal characters normalized; letters of any script preserved), with the format as the extension — e.g. `sales-by-region.png`, `sales-by-region.vl.json`. A name with nothing usable falls back to `chart`. No date or `astrolabe-` prefix (unlike the workspace export) — the user is exporting one named chart and wants its name on the file.
- **Feedback**: a success toast naming the saved file (or confirming the copy); a clear error if the clipboard is blocked or the chart is not ready to rasterize.
- **Availability**: the Export control is disabled when no snippet is open. The image actions additionally require a live rendered chart.
## Import
Import lets the user pick a JSON file from their device; its contents are normalized, merged into the current workspace, and saved.
- **Trigger**: the **Import** header control opens a file picker restricted to JSON files. After a file is chosen (or the picker cancelled) the control is ready to be used again immediately.
### Accepted inputs
The importer recognizes several shapes so that both Astrolabe exports and looser snippet files work:
- **Astrolabe export envelope** — an object with a `version` and a `snippets` array; optional `datasets`, `themes`, and `fonts` arrays are imported too.
- **Bare array of snippets** — a top-level JSON array is treated as a list of snippets (no datasets, themes, or fonts).
- **Single snippet object** — any other object is treated as one snippet.
- **Older / foreign snippet shapes** — snippets that do not match the current model are normalized onto it:
- Alternative field names are mapped: `content` → spec, `draft` → draft spec, `createdAt` → creation timestamp.
- Missing timestamps are generated at import time (creation and modification set to now, or derived from the source timestamp when present).
- Missing identifiers, names, comments, tags, dataset references, metadata, and record `version` are filled with defaults (a missing `version` is treated as the earliest shape and migrated up on read — see _Data Model_).
- Such normalized imports are tagged `"imported"` so the user can find them.
A snippet is treated as already in current Astrolabe format when it carries an ISO-style creation timestamp; in that case its existing fields (id, name, timestamps, spec, draft spec, comment, tags, dataset references, metadata) are preserved as-is, with sensible fallbacks for any missing field.
### Merge behavior
- Imported snippets are **appended** to the existing library; nothing is overwritten or removed.
- **ID collisions** (an incoming snippet whose id already exists) are resolved by assigning the incoming snippet a fresh unique id; the original snippet keeps its id.
- Datasets, custom themes, and uploaded fonts are imported **before** snippets so that snippet dataset references resolve and the whole import rolls back together if the snippet write fails.
- Imported custom themes (and fonts) always receive fresh ids from their library; an envelope's ids never displace existing records.
- An unusable font record (missing family or bytes, or bytes that aren't valid base64) is skipped; the rest of the import continues.
### Name conflicts
When an imported dataset's or custom theme's name already exists in the library, it is auto-renamed to a unique name rather than overwriting the existing one (see _Datasets_).
- A numeric suffix is appended to the original name; further suffixes are added until the name is unique.
- The renamed records are reported to the user via a warning toast listing each `original -> new` rename.
- A dataset rename is propagated into the imported snippets that reference it; theme renames need no propagation (nothing references a theme by name).
- If a single dataset fails to import, it is skipped and the rest of the import continues.
**Fonts conflict differently — skip, not rename.** A font is identified by its family, which is the key embedded directly in a config's font slots, so a same-named face already in the library satisfies any incoming reference. When an imported font's family already exists, the **incoming face is skipped** and the existing one is kept (the references resolve to it) — rather than renamed to a copy. This also means re-importing your own backup adds no duplicate "Font 2" copies. Skipped fonts are listed in the import's warning toast.
### Storage limit handling
Snippet storage has an approximate 5 MB budget (see _Snippet Library_ storage monitor).
- If the incoming snippets would push total snippet storage over the budget, the user is warned about the overage amount, but the app still attempts to save the import.
- If the save ultimately fails because the storage quota is exceeded, the user is told to delete some snippets and try again, and no partial snippet import is committed.
- The storage check applies to snippets; datasets are stored separately and saved during the dataset phase above.
### Feedback
- **Success**: a toast reports how many snippets (and datasets, themes, and fonts, when any) were imported, e.g. "Imported 4 snippets, 2 datasets and 1 theme".
- **Renames / skips**: when datasets or themes were renamed, or fonts were skipped as already-present, the success message is shown as a warning toast that also lists the renames and skips.
- **Empty file**: if no snippets are found in the file, the user is informed ("No snippets found in file") and nothing is imported — even if the file carries datasets, themes, or fonts.
- **Quota failure**: a clear error advising the user to delete snippets and retry.
- **Invalid file**: a non-JSON or unparseable file produces a clear error ("Failed to import. Please check that the file is valid JSON."); an unreadable file produces a read error. In all error cases the existing workspace is left unchanged.
-150
View File
@@ -1,150 +0,0 @@
# 09 · Data Model & Persistence
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_.
## A. Snippet
A **Snippet** is a saved Vega-Lite specification together with its metadata. Snippets are the primary user-authored entity, listed and managed in the _Snippet Library_.
| Field | Type | Meaning |
| ------------- | -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id` | string | Unique, stable identifier for the snippet. |
| `version` | number | Schema version of this record, used for read-time migration (see _Schema versioning_ below). |
| `name` | string | Human-readable title shown in the library. |
| `nameSource` | `'auto' \| 'user'`? | Name provenance: `auto` names keep tracking the spec on publish; `user` names are frozen (see _Snippet Library → Naming & Tags_). Optional — absent on records predating the field, which are treated as `user` unless the name is provably app-picked: the recognizable timestamp default, or identical to what the app derives from the record's own published spec. |
| `created` | ISO-timestamp string | When the snippet was first created. |
| `modified` | ISO-timestamp string | When the snippet was last saved. |
| `spec` | JSON value | The **published** Vega-Lite spec. May be an object or a string. This is the version rendered and shared by default. |
| `draftSpec` | JSON value | The **working draft** Vega-Lite spec being edited. May be an object or a string. |
| `comment` | string | Free-form user note about the snippet. |
| `tags` | string[] | User-assigned labels for filtering and organization. |
| `datasetRefs` | string[] | Names of _Datasets_ referenced by this spec (see relationships below). |
| `meta` | object | Free-form, extensible metadata bag for app- or feature-specific data. |
### Dual spec / draftSpec model
A snippet carries two specs at once. `draftSpec` is the editable working copy; `spec` is the last published copy. Editing affects only `draftSpec` until the user publishes, at which point `draftSpec` is promoted to `spec`. This separation backs the draft/published workflow described in _Spec Editor & Draft/Published Workflow_ — it lets users experiment freely while keeping a known-good published version, and drives indicators for unpublished changes.
### datasetRefs
`datasetRefs` records the **names** of datasets the spec depends on. It is the link used to display a snippet's linked datasets and, conversely, to find which snippets use a given dataset (see _Cross-entity relationships_). It mirrors the dataset names referenced by the **draft** spec — the version the user is editing — and is recomputed on every change to the draft (auto-save, the Extract-to-Dataset rewrite, revert) and on publish. Tracking the draft means a snippet's linked datasets reflect what the editor currently shows, not only the last published version; recomputation only ever runs on a valid (parseable) spec, so a half-typed draft never disturbs the links.
## B. Dataset
A **Dataset** is a named, reusable data source that snippets can reference by name instead of inlining data. Datasets are managed in the _Datasets_ manager and support multiple formats and two source kinds.
| Field | Type | Meaning |
| ------------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id` | number | Unique numeric identifier. |
| `version` | number | Schema version of this record, used for read-time migration (see _Schema versioning_ below). |
| `name` | string | Unique, human-readable name; the key snippets reference via `datasetRefs`. |
| `data` | JSON value | The payload, shaped by format: raw CSV/TSV text, or the parsed JSON/TopoJSON value. For `source = url` this is the **fetched snapshot**, or `null` before the first successful fetch. |
| `format` | string | One of `json`, `csv`, `tsv`, `topojson`. |
| `source` | string | One of `inline` (data pasted into the record) or `url` (data fetched once from a remote address and snapshotted into the record). |
| `url` | string (url only) | The remote address a `url` dataset was fetched from, retained so it can be re-fetched ("Refresh"). Absent for inline datasets. |
| `fetchedAt` | ISO-timestamp or null | For `url` datasets: when the snapshot was last fetched, or `null` if never fetched. Absent for inline datasets. |
| `comment` | string | Free-form user note about the dataset. |
| `rowCount` | number or null | Number of data rows, or null when unknown/not applicable. |
| `columnCount` | number or null | Number of columns, or null when unknown/not applicable. |
| `columns` | string[] | Column names, in order. |
| `columnTypes` | array of `{ name, type }` | Per-column inferred type. `name` is the column; `type` is one of `number`, `string`, `date`, `boolean`. |
| `size` | number | Approximate payload size in bytes. |
| `created` | ISO-timestamp string | When the dataset was first added. |
| `modified` | ISO-timestamp string | When the dataset was last changed. |
The `rowCount`, `columnCount`, `columns`, `columnTypes`, and `size` fields are derived summaries computed when data is added or updated — including when a `url` dataset is fetched or refreshed; a fetched URL snapshot profiles exactly like inline data. They support previews and type display without re-parsing the full payload.
### Schema versioning
Both **Snippet** and **Dataset** records carry a numeric `version` recording the shape of that individual record. When a record is read from storage it is migrated up to the current shape before the app uses it; new writes always store the current version. A record written before versioning existed (no `version` field) is treated as version `1`. This is distinct from the storage container's own layout version, and from the _Import & Export_ envelope `version` (which describes the file format, not a record). Records exported via _Import & Export_ include their `version`.
The current **Dataset** version is `2`. The v1→v2 migration reflects the URL-snapshot model: a v1 `url` dataset stored its address in `data`, so migration moves that address into the new `url` field and clears `data` to `null` — the record becomes an _unfetched reference_ that renders against its live URL until the user refreshes it, at which point the fetched snapshot is stored and profiled.
## C. UserSettings
**UserSettings** holds persisted user preferences as a single structured record. The semantics and UX of each option are covered in _Settings_; the shape below is the storage contract.
| Field | Type | Meaning |
| ----------------------------- | ------- | -------------------------------------------------------------------------------------------- |
| `version` | number | Schema version of the settings record, used for migration. |
| `editor.fontSize` | number | Editor font size. |
| `editor.theme` | string | Editor color theme identifier. |
| `editor.minimap` | boolean | Whether the editor minimap is shown. |
| `editor.wordWrap` | string | `on` or `off`. |
| `editor.lineNumbers` | string | `on` or `off`. |
| `editor.tabSize` | number | Spaces per indentation level. |
| `performance.renderDebounce` | number | Delay (ms) before re-rendering the preview after edits. |
| `ui.theme` | string | App theme: `light` or `dark`. |
| `ui.previewFitMode` | string | Preview sizing: `default`, `width`, `height`, or `full`. |
| `ui.chartTheme` | string | Chart theme: `astrolabe`, `stock`, a preset id, or `custom:<id>` naming a _CustomTheme_ (G). |
| `formatting.dateFormat` | string | Date display mode: `smart`, `iso`, or `custom`. |
| `formatting.customDateFormat` | string | Pattern used when `dateFormat = custom`. |
A reference shape:
UserSettings = { version, editor: { fontSize, theme, minimap, wordWrap, lineNumbers, tabSize }, performance: { renderDebounce }, ui: { theme, previewFitMode, chartTheme }, formatting: { dateFormat, customDateFormat } }
## D. App / UI preferences (persisted separately)
Some preferences persist independently of _UserSettings_ so they can update frequently without rewriting the settings record. They are stored locally and restored on load.
- **Snippet sort preference** — how the _Snippet Library_ list is ordered. `sortBy` is one of `name`, `modified`, `created`; `sortOrder` is `asc` or `desc`. Default is `modified` / `desc` (most recently changed first).
- **Panel layout** — the resizable three-panel arrangement: per-pane widths and per-pane visibility (which panels are shown or hidden). Restored so the workspace reopens as the user left it.
## E. Persistence & limits
| Tier | What it holds | Capacity & behavior |
| ---------------------- | ------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| Snippet store | All _Snippet_ records | Local, with a practical budget of about 5 MB. A storage monitor tracks usage and surfaces warnings as the budget fills (see _Snippet Library_). |
| Dataset store | All _Dataset_ records | Local, in a separate, much higher-capacity store, suited to larger payloads. |
| Theme store | All _CustomTheme_ records (G) | Local, separate store; records are small (a config object plus metadata). |
| Font store | All _FontAsset_ records (H) | Local, separate store; holds raw font-file bytes, so it is sized like the dataset tier (per-face cap ~10 MB). |
| Settings & preferences | _UserSettings_ plus the app/UI preferences in (D) | Local, small. |
Everything stays in the browser — no server or account is involved. All tiers survive reload and function offline. Because capacity is finite and per-browser, _Import & Export_ is the supported path for backup and for moving data between browsers or devices.
## F. Cross-entity relationships
Snippets and datasets are linked **bidirectionally by dataset name**: `snippet.datasetRefs` holds dataset names, and each such name matches a `dataset.name`.
- From a snippet, `datasetRefs` yields its linked datasets.
- From a dataset, scanning snippets for its `name` in `datasetRefs` yields the snippets that reference it.
This name-based link is what the _Snippet Library_ and _Datasets_ surfaces use to show linkage in both directions. The actual resolution of a referenced dataset into spec data at render time is covered in _Live Preview_.
## G. CustomTheme
A **CustomTheme** is a user-named Vega-Lite config saved in the library and offered by the _Live Preview → Chart theme_ picker alongside the built-in themes and presets. It is created and edited in the _Theme Builder_ (see _Live Preview_).
| Field | Type | Meaning |
| ---------- | -------------------- | --------------------------------------------------------------------------------------------- |
| `id` | number | Unique numeric identifier. The picker/persistence selection id is the string `custom:<id>`. |
| `version` | number | Schema version of this record, used for read-time migration (see _Schema versioning_ above). |
| `name` | string | Unique, human-readable name shown in the picker (case-insensitive uniqueness, like datasets). |
| `config` | object | The Vega-Lite config injected at render time when this theme is selected. |
| `created` | ISO-timestamp string | When the theme was first created. |
| `modified` | ISO-timestamp string | When the theme was last changed. |
Selection is keyed by `id` (not name) so renaming a theme never invalidates the persisted `ui.chartTheme`. A persisted `custom:<id>` whose record no longer exists is not an error: charts render with the house style until the record appears (themes hydrate asynchronously), and deleting the actively-selected theme resets the selection to `astrolabe` explicitly. Custom themes travel in the _Import & Export_ envelope alongside snippets and datasets (spec §08).
## H. FontAsset
A **FontAsset** is a user-uploaded font face stored once and reused across themes and snippets. It is added and managed in the _Theme Builder → Type_ panel (see _Live Preview_), and referenced from a config's font slots by its `family` — exactly as a dataset is referenced by name. The raw bytes are registered as a live `FontFace` so a chart measures and renders the real face.
| Field | Type | Meaning |
| ---------- | -------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
| `id` | number | Unique numeric identifier (store key). |
| `version` | number | Schema version of this record, for read-time migration (see _Schema versioning_ above). |
| `family` | string | Unique CSS family name — the key configs reference (case-insensitive uniqueness, like datasets/themes). |
| `data` | bytes | The raw font-file bytes (registered as a `FontFace`). In an export envelope these are **base64-encoded** (spec §08). |
| `format` | `woff2`/`woff`/`ttf`/`otf` | Container format, from the file extension. |
| `fileName` | string | Original file name, kept for display and provenance. |
| `source` | `file`/`google` | Provenance. Only `file` (an upload) ships today; `google` is reserved for a later keyless-catalog tier. |
| `axes` | array, optional | Variation axes for a variable font (parsed from `fvar`); drives the `FontFace` weight/width ranges. Absent for a static face. |
| `size` | number | Byte length of `data`. |
| `created` | ISO-timestamp string | When the font was first added. |
| `modified` | ISO-timestamp string | When the font was last changed (e.g. renamed). |
A font is identified by its `family`. No theme or snippet stores a font field: the faces a config uses are derived by scanning its font slots (the config is the source of truth), so a snippet can use a font with no theme to carry it. Fonts travel in the _Import & Export_ envelope alongside snippets, datasets, and themes; on import a family clash **skips** the incoming face rather than renaming it (spec §08 → Name conflicts).
-56
View File
@@ -1,56 +0,0 @@
# 10 · Non-Functional Requirements
This section defines quality attributes the rebuild must satisfy — performance, accessibility, reliability, privacy, and platform posture — independent of any single feature. Feature behavior lives in the other sections; this one constrains _how well_ that behavior must work.
> The cross-cutting **interaction patterns** that satisfy these attributes — the feedback-channel decision table, latency budgets, the loading/empty/error triad, the recovery and keyboard/focus contracts — are specified in [Architecture 10 · Interaction & Feedback](../architecture/10-interaction-and-feedback.md). This section is the _what_ (the quality bar); that doc is the _how_ (the patterns that meet it).
## Platform & Form Factor
- **Target**: modern evergreen desktop browsers. The app is a single-page application that loads once and then runs locally.
- **Desktop-first**: the primary experience is the three-pane workspace (see _Application Shell & Navigation_), designed for wide viewports. Each pane has a minimum usable width and stops shrinking below it.
- **Small screens**: the three-pane layout is not expected to reach full parity on narrow/mobile viewports. A graceful fallback (e.g. collapsing to fewer visible panes via the toggle strip, or a single-column arrangement) is acceptable; an unusable or broken layout is not.
- **Offline & installable**: after first load the app must function fully offline, and must be installable as a standalone application that launches in its own window (see _Application Shell & Navigation_).
## Embedding & Environment Assumptions
Astrolabe is specified as a standalone single-page app that owns its whole viewport. A team integrating these capabilities into a larger product should know which shared environment surfaces the app currently reserves, so they can decide how to reconcile each with the host. (Surfacing the assumption is the spec's job; choosing the reconciliation is the integrator's.)
- **Global keyboard shortcuts** — the shortcuts in _Application Shell & Navigation_ are bound document-wide and override the browser default, regardless of which element has focus or which modal is open. In a host app they may collide with the host's own bindings.
- **URL hash as view state** — the app stores its current view (selected snippet, open dataset, chart-builder target) in the URL hash and reads it on load (see _Navigation & Shareable URL State_). A host that owns routing will need to share or namespace the hash.
- **Local browser storage** — all state persists to local browser storage across the tiers in _Data Model & Persistence_; storage keys are not namespaced against a co-resident host app.
- **Full-window workspace** — the layout assumes a wide, app-owned viewport (header, three panes, and modals). Hosting it within a smaller region falls under the small-screen fallback above.
## Performance & Responsiveness
- **Live editing stays fluid**: typing in the editor must remain smooth regardless of spec size; rendering must never block input.
- **Debounced rendering**: preview rendering is deferred until the user pauses typing, by a user-configurable delay (see _Settings_ / _Live Preview_), so rapid keystrokes do not cause continuous re-rendering.
- **Non-blocking renders**: while a render is in progress the UI stays interactive; a busy indication may overlay the preview but must not freeze editing or navigation.
- **Auto-save is cheap and silent**: persisting the working draft must not interrupt typing or cause visible stalls (see _Spec Editor & Draft/Published Workflow_).
- **Scales with the library**: search, sort, and list rendering must stay responsive with a large number of snippets, and large datasets must be handled by the high-capacity dataset store rather than inflating snippet storage (see _Data Model & Persistence_).
## Accessibility
- **Keyboard operable**: all primary actions are reachable from the keyboard — the global shortcuts (see _Application Shell & Navigation_) plus standard tab/focus traversal of controls, lists, and forms.
- **Modal focus management**: opening a modal moves focus into it and returns focus sensibly on close; **Escape** closes the active modal; focus is contained within an open modal.
- **Labelled controls**: form fields, toggles, and icon-only buttons carry accessible names so assistive technology can announce them.
- **Reduced motion**: animations and transitions (toast fades, etc.) are suppressed when the user's system requests reduced motion.
- **Contrast**: text and interactive elements meet legible contrast in every offered UI theme; a theme that cannot meet contrast in part of the UI is not considered complete (see _Settings_).
## Reliability & Data Safety
- **No silent data loss**: edits are auto-saved as drafts; a known-good published version is always preserved separately (see _Spec Editor & Draft/Published Workflow_).
- **Confirm destructive actions**: deleting snippets or datasets, reverting a draft, and resetting settings require explicit confirmation.
- **Surface storage, warn on failure**: storage use is surfaced as a composition breakdown (snippets / datasets / app) rather than a budget gauge — browsers expose no reliable free-space figure to count down from — and the user is told when a save fails rather than losing data silently (see _Snippet Library_).
- **Non-destructive import**: importing always merges with existing data and never overwrites or removes it; on failure the existing workspace is left unchanged (see _Import & Export_).
- **Resilient rendering**: an invalid or unrenderable spec produces a readable error and recovers automatically when fixed; it never leaves the app in a broken state (see _Live Preview_).
- **State survives reload**: the current selection/view is restored from the URL, and all data persists across reloads and sessions (see _Application Shell & Navigation_, _Data Model & Persistence_).
## Privacy & Security
- **Local-only data**: all snippets, datasets, and settings stay in the browser. No user content is transmitted to any server, and the app requires no account or login.
- **User-initiated network only**: the only outbound requests for user content are fetches of URL-sourced datasets or remote data referenced by a spec, which the user explicitly created (see _Datasets_). The app performs no background upload of user content.
- **Client-side rendering of untrusted input**: specs and data are user-authored and rendered locally; rendering must fail safely on malformed input rather than crashing the app.
## Internationalization
- **Locale-aware formatting where it exists**: date rendering follows the user's chosen format mode (see _Settings_). Full UI translation is out of scope unless explicitly added later; the spec does not require multiple UI languages.
-33
View File
@@ -1,33 +0,0 @@
# Astrolabe — Product Specification
A UX/behavioral specification of **Astrolabe**, a browser-based snippet manager for [Vega-Lite](https://vega.github.io/vega-lite/) visualizations. It describes _what the app does_ from the user's perspective so it can be recreated on any web/HTML/TS stack.
## How to read this spec
- Start with [00 · Product Overview](00-product-overview.md) for orientation and the glossary.
- 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
- **Implementation.** No frameworks, libraries, languages, storage technologies, or code architecture are prescribed. Storage is described behaviorally (e.g. "persists locally across sessions", capacity tiers), not by naming a technology.
- **Visual design.** Structural layout (panes, regions, modal vs inline, where controls live) is specified; concrete styling, colors, and the app's visual aesthetic are left to the implementer.
- **Domain exception.** Vega-Lite and its vocabulary (specs, marks, encoding channels, field types) and data-format names (JSON, CSV, TSV, TopoJSON) _are_ named — they are the product domain, not implementation choices.
## Contents
| # | Section |
| --- | ----------------------------------------------------------------- |
| 00 | [Product Overview](00-product-overview.md) |
| 01 | [Application Shell & Navigation](01-application-shell.md) |
| 02 | [Snippet Library](02-snippet-library.md) |
| 03 | [Spec Editor & Draft/Published Workflow](03-editor-and-drafts.md) |
| 04 | [Live Preview](04-live-preview.md) |
| 05 | [Datasets](05-datasets.md) |
| 06 | [Chart Builder](06-chart-builder.md) |
| 07 | [Settings](07-settings.md) |
| 08 | [Import & Export](08-import-export.md) |
| 09 | [Data Model & Persistence](09-data-model.md) |
| 10 | [Non-Functional Requirements](10-non-functional.md) |
-55
View File
@@ -1,55 +0,0 @@
# UX second pass — batched council review
A running parking lot of small UX / interaction decisions deferred for a **batched
[`/council`](../.claude/skills/council/SKILL.md) review**, rather than gating each one the
moment it surfaces. Append quirks here as they come up; resolve them together in one pass,
record the resolution into the contract (`docs/architecture/09`+`10` and the relevant
`docs/spec/`), then delete the row.
## 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
more _actionable_ (deleting the tier you're saving into does free space) but misstates the
scope. Decide: keep the actionable per-tier framing, or switch to a whole-origin "Storage is
full — free space (the Storage monitor shows what's using it)". Affects both
`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
be a progressive enhancement on top of the chooser model, not a replacement. Revisit if
real usage asks for it (likely alongside Phase 4 faceting, where drag-to-shelf reads most
naturally).
- **Theme Builder config editor stays a plain textarea** (decided 2026-06-13) — a second
Monaco mount is heavy inside a modal for an occasional surface; the parse error is the
feedback channel that matters. Revisit only if real usage asks for config completions.
The gallery's per-card captions stand as the canvas charts' text alternative.
-54
View File
@@ -1,54 +0,0 @@
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
import globals from 'globals';
export default tseslint.config(
// Ignore build output and generated artifacts.
{ ignores: ['dist', 'dev-dist', 'coverage'] },
js.configs.recommended,
// Type-aware linting for the TypeScript sources only.
{
files: ['**/*.{ts,tsx}'],
extends: [...tseslint.configs.recommendedTypeChecked],
languageOptions: {
ecmaVersion: 2022,
globals: { ...globals.browser, __APP_VERSION__: 'readonly' },
parserOptions: {
// Type-aware linting: powers no-floating-promises, no-misused-promises, etc.
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
// Allow intentionally-unused args/vars when prefixed with _ (matches tsconfig intent).
'@typescript-eslint/no-unused-vars': [
'error',
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
],
},
},
// Tests exercise looser patterns and run in Node.
{
files: ['**/*.test.{ts,tsx}'],
languageOptions: { globals: { ...globals.node } },
},
// 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,mjs}'],
extends: [tseslint.configs.disableTypeChecked],
languageOptions: { globals: { ...globals.node } },
},
);
+72 -14
View File
@@ -1,19 +1,77 @@
<!doctype html>
<html lang="en" data-theme="light">
<!DOCTYPE html>
<html lang="en">
<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="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>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Astrolabe - your Vega-Lite IDE/Snippet manager</title>
<script defer data-domain="olehomelchenko.github.com"
src="https://plausible.io/js/script.outbound-links.tagged-events.js"></script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/vega@5"></script>
<script src="https://cdn.jsdelivr.net/npm/vega-lite@5"></script>
<script src="https://cdn.jsdelivr.net/npm/vega-embed@6"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.47.0/min/vs/loader.js"></script>
<script src="https://cdn.jsdelivr.net/npm/jsonc-parser@3.2.0/lib/umd/main.js"></script>
<link rel="stylesheet" href="src/styles.css">
<!-- favicon goes here -->
<link rel="icon" href="src/astrolabe.svg" type="image/svg+xml">
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/landing/main.tsx"></script>
<div class="container">
<header class="app-header">
<div class="header-left">
<a href="/">
<img src="src/astrolabe.svg" alt="Astrolabe logo">
<h1>Astrolabe</h1>
</a>
</div>
<div class="header-controls">
<button class="button mini" id="export-snippets">Export</button>
<button class="button mini" id="import-snippets">Import</button>
<input type="file" id="import-file" accept=".json" style="display: none">
<a href="/about.html" class="about-link">About</a>
</div>
</header>
<div class="panel">
<div class="panel-header">
<h2>Snippets</h2>
<button class="button" id="new-snippet">New Snippet</button>
</div>
<input type="text" id="snippet-search" class="snippet-search" placeholder="Search snippets...">
<div class="snippet-list" id="snippet-list">
<!-- Snippets will be populated here -->
</div>
</div>
<div class="resize-handle"></div>
<div class="panel">
<div class="panel-header">
<h2>Editor</h2>
<div class="editor-controls">
<button class="button" id="save-snippet" disabled>Save</button>
<button class="button secondary" id="version-switch" style="display: none">View Saved</button>
</div>
</div>
<div id="monaco-editor"></div>
</div>
<div class="resize-handle"></div>
<div class="panel preview-panel">
<div class="panel-header">
<h2>Preview</h2>
</div>
<div id="vis"></div>
</div>
</div>
<div id="comment-modal-background" class="comment-modal-background"></div>
<div id="comment-modal" class="comment-modal">
<h2 id="comment-modal-title">Comment Content Editor</h2>
<div id="comment-editor" style="height: calc(100% - 2rem);"></div>
</div>
<script type="module" src="src/main.js"></script>
</body>
</html>
-5
View File
@@ -1,5 +0,0 @@
{
"$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"]
}
-19
View File
@@ -1,19 +0,0 @@
<!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="Interactive deep dives into Vega-Lite — the hidden capabilities and how they combine. For people past the basics who want the full power of the spec."
/>
<title>Vega-Lite, deeper — Astrolabe</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/learn/main.tsx"></script>
</body>
</html>
-9304
View File
File diff suppressed because it is too large Load Diff
-75
View File
@@ -1,75 +0,0 @@
{
"name": "astrolabe",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "A browser-based snippet manager for Vega-Lite visualizations.",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build && node scripts/check-light-entries.mjs",
"preview": "vite preview",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"format": "prettier --write .",
"prepare": "husky"
},
"lint-staged": {
"*.{ts,tsx}": [
"eslint --fix",
"prettier --write"
],
"*.{json,css,md}": "prettier --write"
},
"dependencies": {
"@fontsource/caveat": "^5.2.8",
"@fontsource/ibm-plex-mono": "^5.2.7",
"@fontsource/ibm-plex-sans": "^5.2.8",
"@fontsource/ibm-plex-sans-condensed": "^5.2.8",
"@fontsource/ibm-plex-serif": "^5.2.7",
"@fontsource/inter": "^5.2.8",
"@fontsource/libre-franklin": "^5.2.8",
"@fontsource/playfair-display": "^5.2.8",
"@fontsource/roboto-condensed": "^5.2.8",
"@fontsource/source-serif-4": "^5.2.9",
"@fontsource/space-grotesk": "^5.2.10",
"@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",
"react-dom": "^19.2.7",
"vega": "^6.2.0",
"vega-embed": "^7.1.0",
"vega-expression": "^6.1.0",
"vega-lite": "^6.4.2",
"vega-scale": "^8.1.0",
"vega-themes": "3.0.0",
"zustand": "^5.0.14"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@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",
"fake-indexeddb": "^6.2.5",
"globals": "^17.6.0",
"happy-dom": "^20.0.0",
"husky": "^9.1.7",
"lint-staged": "^17.0.7",
"prettier": "^3.6.2",
"typescript": "^5.9.2",
"typescript-eslint": "^8.60.1",
"vite": "^7.1.0",
"vite-plugin-pwa": "^1.0.3",
"vitest": "^3.2.4"
}
}
-10
View File
@@ -1,10 +0,0 @@
# 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
Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

-44
View File
@@ -1,44 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<defs>
<radialGradient id="sky" cx="50%" cy="42%" r="75%">
<stop offset="0%" stop-color="#0E7490"/>
<stop offset="100%" stop-color="#06323E"/>
</radialGradient>
</defs>
<!-- mater / background -->
<rect width="64" height="64" rx="14" fill="url(#sky)"/>
<!-- throne: the suspension loop that says "astrolabe", not "compass" -->
<circle cx="32" cy="8.5" r="3" fill="none" stroke="#5EEAD4" stroke-width="2"/>
<!-- limb ring -->
<circle cx="32" cy="32" r="22" fill="none" stroke="#5EEAD4" stroke-width="3"/>
<!-- degree ticks (skip the heading where the star sits) -->
<g stroke="#5EEAD4" stroke-width="2" stroke-linecap="round" opacity="0.55">
<line x1="32" y1="13" x2="32" y2="16.5"/>
<line x1="32" y1="51" x2="32" y2="47.5"/>
<line x1="13" y1="32" x2="16.5" y2="32"/>
<line x1="51" y1="32" x2="47.5" y2="32"/>
<line x1="18.6" y1="18.6" x2="21" y2="21"/>
<line x1="18.6" y1="45.4" x2="21" y2="43"/>
<line x1="45.4" y1="45.4" x2="43" y2="43"/>
</g>
<!-- alidade, rotated to sight the star -->
<g transform="rotate(-32 32 32)">
<path d="M 13.5 32 L 20 29.6 L 44 29.6 L 50.5 32 L 44 34.4 L 20 34.4 Z"
fill="#FFCB2E"/>
</g>
<!-- pivot -->
<circle cx="32" cy="32" r="3.4" fill="#FFCB2E"/>
<circle cx="32" cy="32" r="1.3" fill="#06323E"/>
<!-- the sighted star, pinned on the limb at the alidade's heading -->
<g transform="translate(50.7 20.3)">
<path d="M 0 -6.2 C 0.9 -2.1 2.1 -0.9 6.2 0 C 2.1 0.9 0.9 2.1 0 6.2 C -0.9 2.1 -2.1 0.9 -6.2 0 C -2.1 -0.9 -0.9 -2.1 0 -6.2 Z"
fill="#FFCB2E"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.6 KiB

-34
View File
@@ -1,34 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<defs>
<radialGradient id="sky" cx="50%" cy="42%" r="75%">
<stop offset="0%" stop-color="#0E7490"/>
<stop offset="100%" stop-color="#06323E"/>
</radialGradient>
</defs>
<!-- full-bleed background: the OS applies its own mask shape -->
<rect width="64" height="64" fill="url(#sky)"/>
<!-- artwork scaled to the 80% safe zone -->
<g transform="translate(32 32) scale(0.8) translate(-32 -32)">
<circle cx="32" cy="8.5" r="3" fill="none" stroke="#5EEAD4" stroke-width="2.4"/>
<circle cx="32" cy="32" r="22" fill="none" stroke="#5EEAD4" stroke-width="3"/>
<g stroke="#5EEAD4" stroke-width="2" stroke-linecap="round" opacity="0.55">
<line x1="32" y1="13" x2="32" y2="16.5"/>
<line x1="32" y1="51" x2="32" y2="47.5"/>
<line x1="13" y1="32" x2="16.5" y2="32"/>
<line x1="51" y1="32" x2="47.5" y2="32"/>
<line x1="18.6" y1="18.6" x2="21" y2="21"/>
<line x1="18.6" y1="45.4" x2="21" y2="43"/>
<line x1="45.4" y1="45.4" x2="43" y2="43"/>
</g>
<g transform="rotate(-32 32 32)">
<path d="M 13.5 32 L 20 29.6 L 44 29.6 L 50.5 32 L 44 34.4 L 20 34.4 Z" fill="#FFCB2E"/>
</g>
<circle cx="32" cy="32" r="3.4" fill="#FFCB2E"/>
<circle cx="32" cy="32" r="1.3" fill="#06323E"/>
<g transform="translate(50.7 20.3)">
<path d="M 0 -6.2 C 0.9 -2.1 2.1 -0.9 6.2 0 C 2.1 0.9 0.9 2.1 0 6.2 C -0.9 2.1 -2.1 0.9 -6.2 0 C -2.1 -0.9 -0.9 -2.1 0 -6.2 Z" fill="#FFCB2E"/>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

-13
View File
@@ -1,13 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
<!-- single-color mark: inherits currentColor, works light or dark -->
<g stroke="currentColor" fill="currentColor">
<circle cx="32" cy="8.5" r="3" fill="none" stroke-width="2.4"/>
<circle cx="32" cy="32" r="22" fill="none" stroke-width="3.2"/>
<g transform="rotate(-32 32 32)" stroke="none">
<path d="M 13.5 32 L 20 29.4 L 44 29.4 L 50.5 32 L 44 34.6 L 20 34.6 Z"/>
</g>
<g transform="translate(50.7 20.3)" stroke="none">
<path d="M 0 -6.6 C 0.95 -2.2 2.2 -0.95 6.6 0 C 2.2 0.95 0.95 2.2 0 6.6 C -0.95 2.2 -2.2 0.95 -6.6 0 C -2.2 -0.95 -0.95 -2.2 0 -6.6 Z"/>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 692 B

-47
View File
@@ -1,47 +0,0 @@
// Post-build gate: the marketing entries stay light. The landing (/) and the
// learning section (/learn/) must never gain a static edge into the heavy
// vendor chunks — this shipped once (Vite's preload helper emitted inside the
// monaco chunk chained every lazy import() to it; a vega-scale import in
// theme-controls was reachable from Landing). Vite lists an entry's full
// static graph as modulepreload links / module scripts in its HTML, so
// grepping the emitted HTML catches any regression regardless of cause.
//
// Runs as part of `npm run build` (after `vite build`). Exit 1 on violation.
import { existsSync, readFileSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
const pages = ['dist/index.html', 'dist/learn/index.html'];
if (existsSync('dist/learn')) {
for (const entry of readdirSync('dist/learn', { withFileTypes: true })) {
if (entry.isDirectory()) pages.push(join('dist/learn', entry.name, 'index.html'));
}
}
const HEAVY = /vendor-(?:monaco|vega)-[^"']*\.js/;
const missing = pages.filter((p) => !existsSync(p));
if (missing.length > 0) {
console.error(
`check-light-entries: expected pages missing from dist/:\n ${missing.join('\n ')}`,
);
process.exit(1);
}
// The gate bans chunks by name, so it must not fail open: if the names minted in
// vite.config.ts (manualChunks) ever change, this makes the rename update the gate
// instead of silently disarming it.
const assets = readdirSync('dist/assets');
for (const chunk of ['vendor-monaco', 'vendor-vega']) {
if (!assets.some((f) => f.startsWith(`${chunk}-`) && f.endsWith('.js'))) {
console.error(
`check-light-entries: no ${chunk}-*.js in dist/assets — chunk naming changed; update vite.config.ts manualChunks and this gate together.`,
);
process.exit(1);
}
}
const offenders = pages.filter((p) => HEAVY.test(readFileSync(p, 'utf8')));
if (offenders.length > 0) {
console.error(
`check-light-entries: light entries reference heavy vendor chunks:\n ${offenders.join('\n ')}`,
);
process.exit(1);
}
console.log(`check-light-entries: OK (${pages.length} pages clean of vendor-monaco/vendor-vega)`);
-93
View File
@@ -1,93 +0,0 @@
/**
* Generate one static `learn/<slug>/index.html` shell per lesson, so each lesson is
* its own indexable URL (`/learn/<slug>/`) carrying lesson-specific `<title>` and
* `<meta description>`. The shared `src/learn` entry renders the right view from the
* path. Driven by the lesson `.md` frontmatter adding a lesson stays "drop a
* file" and run from `vite.config.ts` at load (dev and build) so the shells stay
* in sync; the generated dirs are git-ignored.
*
* Plain content-shells only (per-URL metadata, client-rendered body). Prerendering
* the prose into the HTML would help non-JS crawlers but is deliberately deferred.
*/
import { mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
const ROOT = new URL('../', import.meta.url);
const LESSONS_DIR = fileURLToPath(new URL('src/learn/lessons/', ROOT));
const LEARN_DIR = fileURLToPath(new URL('learn/', ROOT));
interface LessonMeta {
slug: string;
title: string;
tagline: string;
}
// A minimal frontmatter reader, separate from `core/lesson-parse`'s `parseLesson`:
// this runs at vite-config load time, before the `@core` alias is registered, and
// only needs the three header fields (not the parsed body). The two must agree on
// the frontmatter shape — both read `slug`/`title`/`tagline` from `--- … ---`.
/** Pull `slug`/`title`/`tagline` from a lesson's `--- … ---` frontmatter. */
function frontmatter(md: string): LessonMeta | null {
const block = md.match(/^---\n([\s\S]*?)\n---/);
if (!block) return null;
const field = (key: string): string | undefined =>
block[1].match(new RegExp(`^${key}:\\s*(.+)$`, 'm'))?.[1].trim();
const slug = field('slug');
const title = field('title');
const tagline = field('tagline');
return slug && title && tagline ? { slug, title, tagline } : null;
}
/** Lesson metadata for every `src/learn/lessons/*.md`, sorted by slug. */
function readLessons(): LessonMeta[] {
return readdirSync(LESSONS_DIR)
.filter((f) => f.endsWith('.md'))
.map((f) => frontmatter(readFileSync(LESSONS_DIR + f, 'utf8')))
.filter((l): l is LessonMeta => l !== null)
.sort((a, b) => a.slug.localeCompare(b.slug));
}
/** Rollup inputs (one per lesson) → the generated `learn/<slug>/index.html` shells. */
export function lessonInputs(): Record<string, string> {
const input: Record<string, string> = {};
for (const { slug } of readLessons()) input[`learn-${slug}`] = `${LEARN_DIR}${slug}/index.html`;
return input;
}
const escapeHtml = (s: string): string =>
s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
function shell({ title, tagline }: LessonMeta): string {
return `<!doctype html>
<html lang="en" data-theme="light">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="mask-icon" href="/icon-mono.svg" color="#0e7490" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="${escapeHtml(tagline)}" />
<title>${escapeHtml(title)} Astrolabe</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/learn/main.tsx"></script>
</body>
</html>
`;
}
/** Write a shell per lesson and prune dirs for lessons that no longer exist. */
export function generateLearnPages(): void {
const lessons = readLessons();
const wanted = new Set(lessons.map((l) => l.slug));
for (const lesson of lessons) {
mkdirSync(`${LEARN_DIR}${lesson.slug}/`, { recursive: true });
writeFileSync(`${LEARN_DIR}${lesson.slug}/index.html`, shell(lesson));
}
for (const entry of readdirSync(LEARN_DIR, { withFileTypes: true })) {
if (entry.isDirectory() && !wanted.has(entry.name)) {
rmSync(`${LEARN_DIR}${entry.name}`, { recursive: true, force: true });
}
}
}
+67
View File
@@ -0,0 +1,67 @@
export class EditorManager {
constructor(snippetManager) {
this.snippetManager = snippetManager;
this.editor = null;
this.timeoutId = null;
}
setEditor(editor) {
this.editor = editor;
this.setupEditorEvents();
}
setupEditorEvents() {
this.editor.onDidChangeModelContent(() => {
// Skip event handling if in read-only mode
if (this.snippetManager.readOnlyMode) return;
this.snippetManager.hasUnsavedChanges = true;
this.snippetManager.uiManager.updateSaveButton(true);
// Auto-save to draft
if (this.timeoutId) clearTimeout(this.timeoutId);
this.timeoutId = setTimeout(() => {
this.snippetManager.saveDraft();
this.updateVisualization();
}, 1000);
});
}
revertChanges() {
const snippet = this.snippetManager.snippets.find(
s => s.id === this.snippetManager.currentSnippetId
);
this.editor.setValue(JSON.stringify(snippet.content, null, 2));
}
updateVisualization() {
try {
const value = this.editor.getValue();
const content = JSON.parse(value);
this.snippetManager.visualizationManager.updateVisualization(content);
} catch (e) {
console.error('Invalid JSON:', e);
}
}
updateReadOnlyState(readOnly) {
if (!this.editor) {
throw new Error('Editor not initialized');
}
this.editor.updateOptions({ readOnly });
}
getValue() {
if (!this.editor) {
throw new Error('Editor not initialized');
}
return this.editor.getValue();
}
setValue(content) {
if (!this.editor) {
throw new Error('Editor not initialized');
}
this.editor.setValue(JSON.stringify(content, null, 2));
}
}
+131
View File
@@ -0,0 +1,131 @@
export class PanelResizer {
constructor(snippetManager) {
this.snippetManager = snippetManager;
this.handleDragStart = this.handleDragStart.bind(this);
this.handleDrag = this.handleDrag.bind(this);
this.handleDragEnd = this.handleDragEnd.bind(this);
this.loadLayout();
this.initializeResizeHandles();
}
loadLayout() {
const stored = localStorage.getItem('panelLayout');
if (stored) {
const layout = JSON.parse(stored);
document.documentElement.style.setProperty('--snippet-width', layout.snippetWidth);
document.documentElement.style.setProperty('--editor-width', layout.editorWidth);
document.documentElement.style.setProperty('--preview-width', layout.previewWidth);
this.normalizePanelWidths();
} else {
const defaultLayout = {
snippetWidth: '0.25fr',
editorWidth: '0.25fr',
previewWidth: '0.5fr'
};
localStorage.setItem('panelLayout', JSON.stringify(defaultLayout));
document.documentElement.style.setProperty('--snippet-width', defaultLayout.snippetWidth);
document.documentElement.style.setProperty('--editor-width', defaultLayout.editorWidth);
document.documentElement.style.setProperty('--preview-width', defaultLayout.previewWidth);
}
}
saveLayout() {
const layout = {
snippetWidth: document.documentElement.style.getPropertyValue('--snippet-width'),
editorWidth: document.documentElement.style.getPropertyValue('--editor-width'),
previewWidth: document.documentElement.style.getPropertyValue('--preview-width')
};
localStorage.setItem('panelLayout', JSON.stringify(layout));
}
initializeResizeHandles() {
const handles = document.querySelectorAll('.resize-handle');
handles.forEach((handle, index) => {
handle.addEventListener('mousedown', (e) => this.handleDragStart(e, index));
});
}
handleDragStart(e, handleIndex) {
this.activeHandle = handleIndex;
this.startX = e.clientX;
this.handle = e.target;
this.handle.classList.add('active');
// Get the panels adjacent to the handle
const panels = document.querySelectorAll('.panel');
this.leftPanel = panels[handleIndex];
this.rightPanel = panels[handleIndex + 1];
// Store initial widths
this.leftWidth = this.leftPanel.getBoundingClientRect().width;
this.rightWidth = this.rightPanel.getBoundingClientRect().width;
document.addEventListener('mousemove', this.handleDrag);
document.addEventListener('mouseup', this.handleDragEnd);
}
handleDrag(e) {
if (!this.handle) return;
const dx = e.clientX - this.startX;
const containerWidth = document.querySelector('.container').getBoundingClientRect().width;
// Calculate new widths as fractions
let leftFr = (this.leftWidth + dx) / containerWidth;
let rightFr = (this.rightWidth - dx) / containerWidth;
// Ensure minimum width of 0.1fr for each panel
const minFr = 0.1;
leftFr = Math.max(minFr, leftFr);
rightFr = Math.max(minFr, rightFr);
// Apply new widths based on which handle is being dragged
if (this.activeHandle === 0) {
document.documentElement.style.setProperty('--snippet-width', `${leftFr}fr`);
document.documentElement.style.setProperty('--editor-width', `${rightFr}fr`);
} else {
document.documentElement.style.setProperty('--editor-width', `${leftFr}fr`);
document.documentElement.style.setProperty('--preview-width', `${rightFr}fr`);
}
this.normalizePanelWidths();
}
handleDragEnd() {
if (!this.handle) return;
this.handle.classList.remove('active');
this.handle = null;
this.saveLayout();
document.removeEventListener('mousemove', this.handleDrag);
document.removeEventListener('mouseup', this.handleDragEnd);
// Trigger Monaco editor resize
if (this.snippetManager.editorManager.editor) {
this.snippetManager.editorManager.editor.layout();
}
// Update visualization after resize is complete
try {
const editorValue = this.snippetManager.editorManager.getValue();
this.snippetManager.visualizationManager.updateVisualization(editorValue);
} catch (e) {
console.error('Error updating visualization after resize:', e);
}
}
normalizePanelWidths() {
const snippetWidth = parseFloat(document.documentElement.style.getPropertyValue('--snippet-width'));
const editorWidth = parseFloat(document.documentElement.style.getPropertyValue('--editor-width'));
const previewWidth = parseFloat(document.documentElement.style.getPropertyValue('--preview-width'));
const total = snippetWidth + editorWidth + previewWidth;
if (total !== 1) {
const factor = 1 / total;
document.documentElement.style.setProperty('--snippet-width', `${snippetWidth * factor}fr`);
document.documentElement.style.setProperty('--editor-width', `${editorWidth * factor}fr`);
document.documentElement.style.setProperty('--preview-width', `${previewWidth * factor}fr`);
}
}
}
+195
View File
@@ -0,0 +1,195 @@
import { StorageManager } from './StorageManager.js';
import { UIManager } from './UIManager.js';
import { VisualizationManager } from './VisualizationManager.js';
import { EditorManager } from './EditorManager.js';
export class SnippetManager {
constructor() {
this.storageManager = new StorageManager();
this.uiManager = new UIManager(this);
this.visualizationManager = new VisualizationManager();
this.editorManager = new EditorManager(this);
this.currentSnippetId = null;
this.hasUnsavedChanges = false;
this.isDraftVersion = false;
this.readOnlyMode = false;
this.snippets = this.loadSnippets();
this.uiManager.renderSnippetList(this.snippets, this.currentSnippetId);
}
setEditor(editor) {
this.editorManager.setEditor(editor);
if (this.snippets.length > 0) {
this.loadSnippet(this.snippets[0].id);
}
}
hasDraftChanges(id) {
const snippet = this.snippets.find(s => s.id === id);
return snippet && snippet.draft !== undefined;
}
loadSnippet(id, forceDraft = null) {
const snippet = this.snippets.find(s => s.id === id);
if (snippet) {
this.currentSnippetId = id;
const hasChanges = this.hasDraftChanges(id);
this.isDraftVersion = forceDraft !== null ? forceDraft : hasChanges;
const content = this.isDraftVersion && snippet.draft ?
snippet.draft :
snippet.content;
this.editorManager.setValue(content);
this.hasUnsavedChanges = this.isDraftVersion;
this.updateReadOnlyState();
this.updateUI();
this.visualizationManager.updateVisualization(content);
}
}
createNewSnippet() {
const existingNames = this.snippets
.filter(s => s.name.startsWith('Snippet #'))
.map(s => parseInt(s.name.replace('Snippet #', '')))
.filter(n => !isNaN(n));
const nextNumber = existingNames.length > 0 ? Math.max(...existingNames) + 1 : 1;
const name = `Snippet #${nextNumber}`;
const id = 'snippet-' + Date.now();
const newSnippet = {
id,
name,
createdAt: Date.now(),
content: {
"$schema": "https://vega.github.io/schema/vega-lite/v5.json",
"description": "New visualization",
"mark": "bar"
}
};
this.snippets.push(newSnippet);
this.saveSnippetsAndUpdateUI();
this.loadSnippet(id);
}
saveDraft() {
if (!this.currentSnippetId) return;
const content = this.parseEditorContent();
if (!content) return;
const snippetIndex = this.snippets.findIndex(s => s.id === this.currentSnippetId);
if (snippetIndex !== -1) {
const currentSnippet = this.snippets[snippetIndex];
if (JSON.stringify(content) !== JSON.stringify(currentSnippet.content)) {
this.snippets[snippetIndex].draft = content;
this.isDraftVersion = true;
this.saveSnippetsAndUpdateUI();
this.visualizationManager.updateVisualization(content);
}
}
}
saveCurrentSnippet() {
if (!this.currentSnippetId) return;
const content = this.parseEditorContent();
if (!content) return;
const snippetIndex = this.snippets.findIndex(s => s.id === this.currentSnippetId);
if (snippetIndex !== -1) {
this.snippets[snippetIndex].content = content;
delete this.snippets[snippetIndex].draft;
this.hasUnsavedChanges = false;
this.isDraftVersion = false;
this.saveSnippetsAndUpdateUI();
}
}
parseEditorContent() {
try {
return JSON.parse(this.editorManager.getValue());
} catch (e) {
console.error('Invalid JSON in editor');
return null;
}
}
handleReadOnlyOverride() {
this.readOnlyMode = false;
this.isDraftVersion = true;
delete this.snippets.find(s => s.id === this.currentSnippetId).draft;
this.saveSnippetsAndUpdateUI();
}
updateReadOnlyState() {
const hasChanges = this.hasDraftChanges(this.currentSnippetId);
this.readOnlyMode = hasChanges && !this.isDraftVersion;
this.editorManager.updateReadOnlyState(this.readOnlyMode);
}
deleteSnippet(id) {
if (confirm('Are you sure you want to delete this snippet?')) {
this.snippets = this.snippets.filter(s => s.id !== id);
this.saveSnippetsAndUpdateUI();
if (this.currentSnippetId === id) {
this.currentSnippetId = null;
if (this.snippets.length > 0) {
this.loadSnippet(this.snippets[0].id);
} else {
this.editorManager.setValue('');
}
}
}
}
renameSnippet(id) {
const snippet = this.snippets.find(s => s.id === id);
if (!snippet) return;
const newName = prompt('Enter new name:', snippet.name);
if (newName && newName.trim() !== '') {
snippet.name = newName.trim();
this.saveSnippetsAndUpdateUI();
}
}
duplicateSnippet(id) {
const snippet = this.snippets.find(s => s.id === id);
if (!snippet) return;
const newSnippet = {
...snippet,
id: 'snippet-' + Date.now(),
name: snippet.name + ' (Copy)'
};
this.snippets.push(newSnippet);
this.saveSnippetsAndUpdateUI();
this.loadSnippet(newSnippet.id);
}
loadSnippets() {
const storedSnippets = this.storageManager.loadSnippets();
return storedSnippets.map(snippet => ({
...snippet,
comment: snippet.comment || '',
createdAt: snippet.createdAt || Date.now() // Ensure backwards compatibility
}));
}
saveSnippetsAndUpdateUI() {
this.storageManager.saveSnippets(this.snippets);
this.updateUI();
}
updateUI() {
this.uiManager.updateSaveButton(this.hasUnsavedChanges);
this.uiManager.updateVersionSwitch(this.currentSnippetId, this.isDraftVersion, this.hasDraftChanges(this.currentSnippetId));
this.uiManager.renderSnippetList(this.snippets, this.currentSnippetId);
}
}
+43
View File
@@ -0,0 +1,43 @@
import { defaultSnippets } from './config.js';
export class StorageManager {
constructor() {
this.SNIPPETS_KEY = 'vegaSnippets';
}
loadSnippets() {
const stored = localStorage.getItem(this.SNIPPETS_KEY);
return stored ? JSON.parse(stored) : defaultSnippets;
}
saveSnippets(snippets) {
localStorage.setItem(this.SNIPPETS_KEY, JSON.stringify(snippets));
}
exportSnippets() {
const snippets = this.loadSnippets();
const blob = new Blob([JSON.stringify(snippets, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'astrolabe-snippets.json';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
async importSnippets(file) {
try {
const text = await file.text();
const snippets = JSON.parse(text);
if (!Array.isArray(snippets)) {
throw new Error('Invalid snippets format');
}
this.saveSnippets(snippets);
return snippets;
} catch (err) {
throw new Error('Failed to import snippets: ' + err.message);
}
}
}
+174
View File
@@ -0,0 +1,174 @@
export class UIManager {
constructor(snippetManager) {
this.snippetManager = snippetManager;
this.setupEventListeners();
this.setupSearchInput();
}
setupEventListeners() {
this.setupEventListener('new-snippet', 'onclick', () => this.snippetManager.createNewSnippet());
this.setupEventListener('save-snippet', 'onclick', () => this.snippetManager.saveCurrentSnippet());
this.setupEventListener('version-switch', 'onclick', () => {
this.snippetManager.loadSnippet(this.snippetManager.currentSnippetId, !this.snippetManager.isDraftVersion);
});
this.setupEventListener('export-snippets', 'onclick', () => this.snippetManager.storageManager.exportSnippets());
this.setupEventListener('import-snippets', 'onclick', () => document.getElementById('import-file').click());
this.setupEventListener('import-file', 'onchange', (e) => this.handleImport(e));
this.setupEventListener('comment-modal-background', 'onclick', () => this.saveComment());
this.setupEventListener('save-comment', 'onclick', () => this.saveComment());
}
setupEventListener(elementId, event, handler) {
const element = document.getElementById(elementId);
if (element) {
element[event] = handler;
}
}
async handleImport(e) {
if (e.target.files.length > 0) {
try {
const snippets = await this.snippetManager.storageManager.importSnippets(e.target.files[0]);
this.snippetManager.snippets = snippets;
this.renderSnippetList(snippets, this.snippetManager.currentSnippetId);
if (snippets.length > 0) {
this.snippetManager.loadSnippet(snippets[0].id);
}
e.target.value = ''; // Reset file input
} catch (err) {
alert(err.message);
}
}
}
renderSnippetList(snippets, currentSnippetId) {
const container = document.getElementById('snippet-list');
container.innerHTML = '';
// Sort snippets by creation date (most recent first)
snippets.sort((a, b) => b.createdAt - a.createdAt);
snippets.forEach(snippet => {
const div = document.createElement('div');
div.className = `snippet-item ${snippet.id === currentSnippetId ? 'active' : ''}`;
div.onclick = () => this.snippetManager.loadSnippet(snippet.id);
const hasChanges = this.snippetManager.hasDraftChanges(snippet.id);
const indicator = hasChanges ? '🟡' : '🟢';
const contentDiv = document.createElement('div');
contentDiv.className = 'snippet-content';
contentDiv.textContent = `${indicator} ${snippet.name}`;
contentDiv.title = `Created at: ${new Date(snippet.createdAt).toLocaleString()}`;
div.appendChild(contentDiv);
const buttonsDiv = document.createElement('div');
buttonsDiv.className = 'snippet-buttons';
const commentButton = this.createButton('💬', 'comment-snippet', (e) => {
e.stopPropagation();
this.openCommentModal(snippet.id);
});
if (snippet.comment && snippet.comment.trim() !== '') {
commentButton.classList.add('has-comment');
}
buttonsDiv.appendChild(commentButton);
buttonsDiv.appendChild(this.createButton('✏️', 'edit-snippet', (e) => {
e.stopPropagation();
this.snippetManager.renameSnippet(snippet.id);
}));
buttonsDiv.appendChild(this.createButton('📄', 'duplicate-snippet', (e) => {
e.stopPropagation();
this.snippetManager.duplicateSnippet(snippet.id);
}));
buttonsDiv.appendChild(this.createButton('❌', 'delete-snippet', (e) => {
e.stopPropagation();
this.snippetManager.deleteSnippet(snippet.id);
}));
div.appendChild(buttonsDiv);
container.appendChild(div);
});
}
createButton(innerHTML, className, onClick) {
const button = document.createElement('button');
button.className = className;
button.innerHTML = innerHTML;
button.onclick = onClick;
return button;
}
openCommentModal(snippetId) {
const snippet = this.snippetManager.snippets.find(s => s.id === snippetId);
if (!snippet) return;
const commentEditor = window.commentEditor;
commentEditor.setValue(snippet.comment || '');
document.getElementById('comment-modal').style.display = 'block';
document.getElementById('comment-modal-background').style.display = 'block';
document.getElementById('comment-modal-title').textContent = `Edit comment for snippet: ${snippet.name}`;
this.currentCommentSnippetId = snippetId;
}
saveComment() {
const commentEditor = window.commentEditor;
const snippet = this.snippetManager.snippets.find(s => s.id === this.currentCommentSnippetId);
if (snippet) {
snippet.comment = commentEditor.getValue();
this.snippetManager.saveSnippetsAndUpdateUI();
}
document.getElementById('comment-modal').style.display = 'none';
document.getElementById('comment-modal-background').style.display = 'none';
}
closeCommentModal() {
const commentTextarea = document.getElementById('comment-textarea');
const snippet = this.snippetManager.snippets.find(s => s.id === this.currentCommentSnippetId);
if (snippet) {
snippet.comment = commentTextarea.value;
this.snippetManager.saveSnippetsAndUpdateUI();
}
document.getElementById('comment-modal').style.display = 'none';
document.getElementById('comment-modal-background').style.display = 'none';
}
updateSaveButton(hasUnsavedChanges) {
const saveButton = document.getElementById('save-snippet');
saveButton.disabled = !hasUnsavedChanges;
}
updateVersionSwitch(currentSnippetId, isDraftVersion, hasDraftChanges) {
const versionSwitch = document.getElementById('version-switch');
if (!versionSwitch) return;
versionSwitch.style.display = hasDraftChanges ? 'block' : 'none';
versionSwitch.textContent = isDraftVersion ?
'View Saved' :
'View Draft';
}
setupSearchInput() {
const searchInput = document.getElementById('snippet-search');
searchInput.addEventListener('input', (e) => {
const query = e.target.value;
let filteredSnippets;
try {
const regex = new RegExp(query, 'i');
filteredSnippets = this.snippetManager.snippets.filter(snippet => {
const snippetText = JSON.stringify(snippet);
return regex.test(snippetText);
});
} catch (err) {
filteredSnippets = this.snippetManager.snippets.filter(snippet => {
const snippetText = JSON.stringify(snippet).toLowerCase();
return snippetText.includes(query.toLowerCase());
});
}
this.renderSnippetList(filteredSnippets, this.snippetManager.currentSnippetId);
});
}
}
+25
View File
@@ -0,0 +1,25 @@
export class VisualizationManager {
constructor(containerId = 'vis') {
this.containerId = containerId;
}
async updateVisualization(spec) {
try {
const parsedSpec = typeof spec === 'string' ? JSON.parse(spec) : spec;
const displaySpec = {
...parsedSpec,
width: parsedSpec.width || 'container',
height: parsedSpec.height || 'container'
};
await vegaEmbed(`#${this.containerId}`, displaySpec, {
actions: true,
theme: 'light'
});
} catch (err) {
console.error('Error rendering visualization:', err);
document.getElementById(this.containerId).innerHTML =
`<div style="color: red; padding: 1rem;">Error rendering visualization: ${err.message}</div>`;
}
}
}
-123
View File
@@ -1,123 +0,0 @@
.app {
display: flex;
flex-direction: column;
height: 100%;
}
.header {
display: flex;
align-items: center;
gap: var(--space-4);
height: var(--header-height);
padding: 0 var(--space-5);
border-bottom: var(--border-width) solid var(--border);
background: var(--layer-01);
flex: 0 0 auto;
/* The header sits on --layer-01, so ghost/secondary controls step their hover
fill up to --layer-02 (arch 09 §4; consumed by Button/IconButton). */
--control-hover-fill: var(--layer-02);
}
.title {
margin: 0; /* it's an <h1> now — drop the UA heading margin */
font-weight: 600;
font-size: 16px;
letter-spacing: 0.01em;
}
/*
* Skip link visually hidden until focused, then pinned top-left above
* everything. Reveal uses :focus (not only :focus-visible) so a keyboard Tab
* shows it; the transition is neutralized under prefers-reduced-motion by base.css.
*/
.skipLink {
position: absolute;
top: -100%;
left: var(--space-4);
z-index: 1200;
padding: var(--space-3) var(--space-4);
background: var(--accent);
color: var(--accent-contrast);
border-radius: var(--radius);
transition: top var(--dur-fast) var(--ease);
}
.skipLink:focus {
top: var(--space-3);
}
/* The focus target itself shouldn't paint a ring (the skip link already shows). */
.panes:focus {
outline: none;
}
.version {
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-secondary);
/* A passive badge, not a control — subtle border (borders mark function). */
border: var(--border-width) solid var(--border);
border-radius: var(--radius);
padding: var(--space-1) var(--space-3);
}
.spacer {
flex: 1;
}
/* Vertical rule separating the utilities from Support + the theme toggle.
(The header buttons themselves are the shared Button primitive ghost
utilities, soft-accent Support; arch 09 §4.) */
/* TODO: --border-strong is the 3:1 component-boundary token (arch 09 §3.3), which
makes this passive divider fairly dark; --border would be the semantic choice but
is near-invisible on the --layer-01 header in light theme. A separator may want
its own subtler value if this reads too heavy. */
.headerDivider {
flex: 0 0 auto;
width: var(--border-width);
height: 20px;
margin: 0 var(--space-2);
background: var(--border-strong);
}
/* The Import file picker is driven programmatically by its header button. */
.hiddenInput {
display: none;
}
.panes {
display: flex;
flex: 1 1 auto;
min-height: 0;
}
/*
* Side panes (library, preview) carry an explicit width (set inline from the
* PanesStore) and don't grow or shrink the drag handles change that width.
* The editor between them flexes to fill the remainder, so a drag leaves the
* opposite side pane untouched (spec §01A). Panes are separated by the
* ResizeHandle, so no inter-pane borders here.
*/
.pane {
flex: 0 0 auto;
min-width: 0;
overflow: auto;
background: var(--bg);
}
/* Editor pane: Monaco manages its own scroll/layout, so no padding. */
.paneEditor {
flex: 1 1 0;
min-width: 0;
overflow: hidden;
background: var(--bg);
}
/* Onboarding canvas fills the whole panes area when the library is empty (it
replaces the entire chrome, not just editor+preview); it scrolls internally. */
.paneOnboarding {
flex: 1 1 0;
min-width: 0;
overflow: hidden;
background: var(--bg);
}
-207
View File
@@ -1,207 +0,0 @@
import { useEffect, useRef } from 'react';
import { Button } from './components/Button';
import { ConfirmDialog } from './components/ConfirmDialog';
import { LivePreview } from './components/LivePreview';
import { ModalShell } from './components/ModalShell';
import { Onboarding } from './components/Onboarding';
import { PaneSplitHandle } from './components/PaneSplitHandle';
import { Icon } from './components/Icon';
import { IconButton } from './components/IconButton';
import { PaneToggleStrip } from './components/PaneToggleStrip';
import { ResizeHandle } from './components/ResizeHandle';
import { SnippetLibrary } from './components/SnippetLibrary';
import { SpecEditor } from './components/SpecEditor';
import { ThemeToggle } from './components/ThemeToggle';
import { Toaster } from './components/Toaster';
import { openModal, setConfirm } from './modals/ModalCoordinator';
import { confirm } from './stores/ConfirmStore';
import { usePanesStore } from './stores/PanesStore';
import { useSnippetStore } from './stores/SnippetStore';
import { exportWorkspace, importWorkspace } from './services/transfer';
import styles from './App.module.css';
/**
* Application shell the three-pane workspace from spec §01A
* (library · editor · preview) under a fixed header.
*
* The center editor flexes; the library and preview carry remembered widths and
* are resized via the drag handles between them (spec §01A). Each pane can be
* shown/hidden from the toggle strip (the leftmost rail); a hidden pane frees its
* space and the rest redistribute when the editor is hidden the two side panes
* flex proportionally to their remembered widths.
*
* When the library is empty, this whole pane chrome (strip + panes) is replaced
* by the full-width onboarding canvas (spec §02 First-Run & Empty Workspace).
*/
export function App() {
const libraryWidth = usePanesStore((s) => s.libraryWidth);
const previewWidth = usePanesStore((s) => s.previewWidth);
const libraryVisible = usePanesStore((s) => s.libraryVisible);
const editorVisible = usePanesStore((s) => s.editorVisible);
const previewVisible = usePanesStore((s) => s.previewVisible);
// An empty library is the onboarding surface: the whole pane chrome (toggle
// strip, library list, editor, preview) is replaced by the full-width Onboarding
// canvas (spec §02 → First-Run & Empty Workspace).
const hasSnippets = useSnippetStore((s) => s.snippets.length > 0);
// Side-pane sizing: fixed remembered width while the editor (the flex filler) is
// present; when it's hidden, the side panes grow proportionally to those widths
// so they fill the freed space (spec §01A → "redistributing proportionally").
const sideStyle = (width: number): React.CSSProperties =>
editorVisible ? { width } : { flex: `${width} 1 0` };
// Hidden file input driving Import — the header button proxies its click so the
// browser file picker is the only chrome (spec §08 → no intermediate dialog).
const fileInputRef = useRef<HTMLInputElement>(null);
// Route the modal coordinator's discard prompt through the in-app confirm
// dialog (docs/architecture/03 → "The coordinator seam").
useEffect(() => {
setConfirm((message) => confirm({ title: 'Discard changes?', message, danger: true }));
}, []);
const handleImportFile = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
// Reset the input so picking the same file again still fires onChange.
e.target.value = '';
if (file) void importWorkspace(file);
};
return (
<div className={styles.app}>
{/* Skip link (WCAG 2.4.1 / GOV.UK): the first focusable element, hidden
until focused, lets keyboard users bypass the header into the work area. */}
<a className={styles.skipLink} href="#main">
Skip to content
</a>
<header className={styles.header}>
<h1 className={styles.title}>Astrolabe</h1>
<span className={styles.version}>v{__APP_VERSION__}</span>
<span className={styles.spacer} />
{/* Header actions establish a hierarchy (Carbon button/usage one
high-emphasis action per region; Carbon UI-shell header global
actions are a right-aligned row of icon-only buttons). The utilities
are IconButtons tooltips carry the full action, accessible names
scope it ("Export workspace" vs the preview pane's per-chart
"Export") then a divider sets off Support (feedback + the one
donation solicitation), given a soft-accent wash and the theme
toggle. */}
<IconButton
label="Datasets"
onClick={() => openModal('datasets')}
aria-keyshortcuts="Meta+K Control+K"
title="Datasets (⌘/Ctrl+K)"
>
<Icon name="dataset" />
</IconButton>
<IconButton
label="Import workspace"
onClick={() => fileInputRef.current?.click()}
title="Import a workspace JSON file"
>
<Icon name="import" />
</IconButton>
<IconButton
label="Export workspace"
onClick={() => exportWorkspace()}
title="Export your workspace to a JSON file"
>
<Icon name="export" />
</IconButton>
<IconButton
label="About"
onClick={() => openModal('about')}
title="About, keyboard shortcuts, and privacy information"
>
<Icon name="info" />
</IconButton>
<span className={styles.headerDivider} aria-hidden="true" />
<Button
variant="soft-accent"
onClick={() => openModal('donate')}
title="Send feedback or support Ukraine's defense"
>
Support
</Button>
<ThemeToggle />
{/* Hidden picker for Import; restricted to JSON (spec §08). */}
<input
ref={fileInputRef}
type="file"
accept="application/json,.json"
className={styles.hiddenInput}
onChange={handleImportFile}
aria-hidden="true"
tabIndex={-1}
/>
</header>
{/* tabIndex -1 makes the landmark a focus target for the skip link. */}
<main id="main" className={styles.panes} tabIndex={-1}>
{hasSnippets ? (
<>
{/* Always-present rail: shows/hides panes and shortcuts to Datasets (§01A). */}
<PaneToggleStrip />
{libraryVisible && (
<section
id="pane-library"
className={styles.pane}
style={sideStyle(libraryWidth)}
aria-label="Snippet library"
>
<SnippetLibrary />
</section>
)}
{/* A resize handle sits between any two adjacent visible panes. With the
editor present it flanks the editor (it absorbs the drag); with the
editor hidden, the library and preview become adjacent and share a
single split handle between them (spec §01A). */}
{libraryVisible && editorVisible && (
<ResizeHandle side="library" label="Resize snippet library" />
)}
{libraryVisible && previewVisible && !editorVisible && (
<PaneSplitHandle label="Resize library and preview" />
)}
{editorVisible && (
<section id="pane-editor" className={styles.paneEditor} aria-label="Spec editor">
<SpecEditor />
</section>
)}
{editorVisible && previewVisible && (
<ResizeHandle side="preview" label="Resize live preview" />
)}
{previewVisible && (
<section
id="pane-preview"
className={styles.pane}
style={sideStyle(previewWidth)}
aria-label="Live preview"
>
<LivePreview />
</section>
)}
</>
) : (
// Empty library → the onboarding canvas takes the full workspace. The
// pane chrome (toggle strip, library list, editor, preview) is hidden:
// with no snippets, Create/Search/Sort/Storage and the pane toggles have
// nothing to act on, so the welcome gets the whole width (spec §02).
<section className={styles.paneOnboarding} aria-label="Getting started">
<Onboarding />
</section>
)}
</main>
{/* The one feature modal (Datasets / Extract / ), rendered from the
registry by the shared shell. At most one open at a time (spec §01C). */}
<ModalShell />
{/* Global confirmation layer sits above the feature-modal shell so a
discard-changes prompt can appear over an open modal. */}
<ConfirmDialog />
{/* Non-blocking notifications (failed saves, etc.) top-right toasts,
layered above the confirm backdrop so a failure stays visible. */}
<Toaster />
</div>
);
}
-129
View File
@@ -1,129 +0,0 @@
/* About & Help modal body — informational content, no interactive controls. */
.about {
display: flex;
flex-direction: column;
gap: var(--space-7);
padding: var(--space-6);
min-width: 0;
}
.section {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.heading {
margin: 0;
font-size: 13px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-secondary);
}
.body {
margin: 0;
font-size: 14px;
line-height: 1.5;
color: var(--text);
}
.version {
font-family: var(--font-mono);
font-size: 13px;
color: var(--text-secondary);
}
/* Inline accent link. Restated as DonateModal's `.email` the two are the same
recipe; a shared link primitive would be the home if a third site appears. */
/* TODO: if inline accent links recur, lift this recipe to a base.css element
baseline or a small primitive rather than copying it a third time. */
.link {
color: var(--accent);
font-weight: 600;
text-decoration: none;
}
.link:hover {
text-decoration: underline;
}
.link:focus-visible {
outline: 2px solid var(--focus);
outline-offset: 2px;
border-radius: var(--radius);
}
/* Shortcuts table */
.shortcuts {
border-collapse: collapse;
width: 100%;
}
.row + .row .keys,
.row + .row .action {
padding-top: var(--space-3);
}
.keys {
width: 1%;
white-space: nowrap;
padding-right: var(--space-5);
vertical-align: top;
}
.kbd {
display: inline-block;
padding: var(--space-1) var(--space-3);
background: var(--layer-01);
/* A passive key-cap badge, not a control — subtle border (borders mark function). */
border: var(--border-width) solid var(--border);
border-radius: var(--radius);
font-family: var(--font-mono);
font-size: 12px;
color: var(--text);
/* No user-agent <kbd> styling — reset it */
font-style: normal;
}
.action {
font-size: 13px;
color: var(--text);
vertical-align: top;
}
/* Acknowledgements — a labelled group above each credit list. */
.ackGroup {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.ackLabel {
margin: 0;
font-size: 13px;
font-weight: 600;
color: var(--text);
}
/* Trailing "— what it's for" gloss after the project links. */
.ackNote {
color: var(--text-secondary);
}
/* Privacy + acknowledgement lists */
.list {
margin: 0;
padding-left: var(--space-5);
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.list li {
font-size: 13px;
line-height: 1.5;
color: var(--text);
}
-198
View File
@@ -1,198 +0,0 @@
/**
* About & Help modal body (spec §01B/§01C).
*
* 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, no AI), and acknowledgements of
* the projects Astrolabe is built on and shaped by.
*/
import type { ReactNode } from 'react';
import { FEEDBACK_EMAIL, feedbackMailtoHref } from '../feedback';
import styles from './AboutModal.module.css';
/** True when the user agent is macOS / iOS — drives the Cmd vs. Ctrl label. */
const isMac = /Mac|iPhone|iPad|iPod/.test(navigator.platform);
const mod = isMac ? '⌘' : 'Ctrl';
/** Keyboard shortcut rows sourced from spec §01D. */
const SHORTCUTS: readonly { keys: string; action: string }[] = [
{ keys: `${mod}+Shift+N`, action: 'Create a new snippet' },
{ keys: `${mod}+K`, action: 'Toggle the Datasets manager' },
{ keys: `${mod}+S`, action: 'Publish the current snippet draft' },
{ keys: `${mod}+,`, action: 'Open editor settings' },
{ keys: 'Esc', action: 'Close the active modal' },
];
/**
* External acknowledgement link. Opens in a new tab so following a credit never
* unloads the workspace mid-edit; `rel` isolates the opened context.
*/
function Ack({ href, children }: { href: string; children: ReactNode }) {
return (
<a className={styles.link} href={href} target="_blank" rel="noopener noreferrer">
{children}
</a>
);
}
export function AboutModal() {
return (
<div className={styles.about}>
{/* Identity */}
<section className={styles.section}>
<h3 className={styles.heading}>Astrolabe</h3>
<p className={styles.body}>
v<span className={styles.version}>{__APP_VERSION__}</span>
</p>
<p className={styles.body}>
A local-first workspace for authoring and organizing Vega-Lite charts. Edit the JSON,
watch it render live, and keep a personal library of snippets.
</p>
<p className={styles.body}>
New to Vega-Lite, or want to go deeper? Read the{' '}
<a className={styles.link} href="/learn/" target="_blank" rel="noopener noreferrer">
deep dives
</a>
.
</p>
</section>
{/* Keyboard shortcuts */}
<section className={styles.section}>
<h3 className={styles.heading}>Keyboard shortcuts</h3>
<table className={styles.shortcuts} aria-label="Keyboard shortcuts">
<tbody>
{SHORTCUTS.map(({ keys, action }) => (
<tr key={keys} className={styles.row}>
<td className={styles.keys}>
<kbd className={styles.kbd}>{keys}</kbd>
</td>
<td className={styles.action}>{action}</td>
</tr>
))}
</tbody>
</table>
</section>
{/* Privacy */}
<section className={styles.section}>
<h3 className={styles.heading}>Privacy</h3>
<p className={styles.body}>
Astrolabe runs in your browser. Your snippets, datasets, and settings are stored locally
on your device there&rsquo;s no server to send them to. Work that has to stay
confidential is safe here.
</p>
<ul className={styles.list}>
<li>No account or sign-in.</li>
<li>
No AI. No model authors, edits, or critiques your charts, and nothing is sent to one.
The chart builder&rsquo;s suggestions are computed locally from your data.
</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&rsquo;s import / export buttons to move your library between devices.
</li>
</ul>
</section>
{/* Feedback */}
<section className={styles.section}>
<h3 className={styles.heading}>Feedback</h3>
<p className={styles.body}>
Found a bug or have an idea? Email{' '}
<a className={styles.link} href={feedbackMailtoHref()}>
{FEEDBACK_EMAIL}
</a>
.
</p>
</section>
{/* Acknowledgements */}
<section className={styles.section}>
<h3 className={styles.heading}>Acknowledgements</h3>
<p className={styles.body}>Astrolabe stands on the work of many open projects.</p>
<div className={styles.ackGroup}>
<h4 className={styles.ackLabel}>Built on</h4>
<ul className={styles.list}>
<li>
<Ack href="https://vega.github.io/vega-lite/">Vega-Lite</Ack>,{' '}
<Ack href="https://vega.github.io/vega/">Vega</Ack>,{' '}
<Ack href="https://github.com/vega/vega-embed">vega-embed</Ack>
<span className={styles.ackNote}> render every chart</span>
</li>
<li>
<Ack href="https://microsoft.github.io/monaco-editor/">Monaco</Ack>
<span className={styles.ackNote}> the JSON editor</span>
</li>
<li>
<Ack href="https://react.dev/">React</Ack>,{' '}
<Ack href="https://github.com/pmndrs/zustand">Zustand</Ack>,{' '}
<Ack href="https://vite.dev/">Vite</Ack>
</li>
</ul>
</div>
<div className={styles.ackGroup}>
<h4 className={styles.ackLabel}>Chart guidance shaped by</h4>
<ul className={styles.list}>
<li>
<Ack href="https://github.com/uwdata/draco">Draco</Ack>,{' '}
<Ack href="https://github.com/vega/voyager">Voyager</Ack>
<span className={styles.ackNote}> encoding rules</span>
</li>
<li>
<Ack href="https://github.com/Financial-Times/chart-doctor">FT Visual Vocabulary</Ack>
, <Ack href="https://www.datawrapper.de/">Datawrapper</Ack>
</li>
<li>
<Ack href="https://github.com/vega/lyra">Lyra</Ack>
<span className={styles.ackNote}> authoring interactions</span>
</li>
</ul>
</div>
<div className={styles.ackGroup}>
<h4 className={styles.ackLabel}>Design informed by</h4>
<ul className={styles.list}>
<li>
<Ack href="https://carbondesignsystem.com/">IBM Carbon</Ack>,{' '}
<Ack href="https://design-system.service.gov.uk/">GOV.UK</Ack>
</li>
<li>
<Ack href="https://www.w3.org/WAI/ARIA/apg/">WAI-ARIA APG</Ack>,{' '}
<Ack href="https://www.nngroup.com/">Nielsen Norman</Ack>
</li>
</ul>
</div>
<div className={styles.ackGroup}>
<h4 className={styles.ackLabel}>Typography</h4>
<ul className={styles.list}>
<li>
<Ack href="https://github.com/IBM/plex">IBM Plex</Ack> and other open typefaces,
self-hosted via <Ack href="https://fontsource.org/">Fontsource</Ack>
</li>
</ul>
</div>
</section>
{/* Sibling project */}
<section className={styles.section}>
<h3 className={styles.heading}>Also from the maker</h3>
<p className={styles.body}>
Astrolabe has a sibling: <Ack href="https://syto.app">Syto</Ack>, a workspace for cleaning
and reshaping data before you chart it. Go take a look.
</p>
</section>
</div>
);
}
-211
View File
@@ -1,211 +0,0 @@
/**
* Theme Builder Axes & grid panel (docs/chart-theming-scope.md §5).
*
* Structured controls over the base `axis` config only grid, ticks, the domain
* line, labels, and title. The 25 per-channel variants (`axisX`, `axisY`,
* `axisBand`, ) stay in the JSON; this is the common surface a brand actually
* tunes. Axis *type* (label/title size and weight) lives in the Type panel.
*/
import type { JsonObject } from '@core/spec-config';
import {
asBoolean,
asNumber,
asString,
type ConfigPath,
countSet,
getConfigValue,
} from '@core/theme-controls';
import { useConfigSetter } from '../stores/CustomThemeStore';
import { Accordion, type AccordionSection, ColorRow, NumberRow, SelectRow } from './ThemeFields';
import type { SelectControlOption } from './SelectControl';
const GRID: ConfigPath = ['axis', 'grid'];
const GRID_COLOR: ConfigPath = ['axis', 'gridColor'];
const GRID_DASH: ConfigPath = ['axis', 'gridDash'];
const GRID_WIDTH: ConfigPath = ['axis', 'gridWidth'];
const DOMAIN_COLOR: ConfigPath = ['axis', 'domainColor'];
const DOMAIN_WIDTH: ConfigPath = ['axis', 'domainWidth'];
const LABEL_COLOR: ConfigPath = ['axis', 'labelColor'];
const LABEL_ANGLE: ConfigPath = ['axis', 'labelAngle'];
const LABEL_PADDING: ConfigPath = ['axis', 'labelPadding'];
const TITLE_COLOR: ConfigPath = ['axis', 'titleColor'];
const TICKS: ConfigPath = ['axis', 'ticks'];
const TICK_COLOR: ConfigPath = ['axis', 'tickColor'];
const TICK_SIZE: ConfigPath = ['axis', 'tickSize'];
const GRID_GREY = '#888888';
// Visibility: tri-state (theme default · shown · hidden) over a boolean — shared
// by grid lines and ticks (both `boolean | undefined` config keys).
type ShowState = '' | 'true' | 'false';
const showOptions: SelectControlOption<ShowState>[] = [
{ value: '', label: 'Theme default' },
{ value: 'true', label: 'Shown' },
{ value: 'false', label: 'Hidden' },
];
const showState = (v: boolean | undefined): ShowState =>
v === undefined ? '' : v ? 'true' : 'false';
// Dash presets, matched by array shape; an unrecognised array reads as no preset
// (the trigger shows "—") so the control never misreports a hand-authored dash.
type DashStyle = '' | 'solid' | 'dotted' | 'dashed' | 'custom';
const dashOptions: SelectControlOption<DashStyle>[] = [
{ value: '', label: 'Theme default' },
{ value: 'solid', label: 'Solid' },
{ value: 'dotted', label: 'Dotted' },
{ value: 'dashed', label: 'Dashed' },
];
const DASH_VALUES: Record<Exclude<DashStyle, '' | 'custom'>, number[]> = {
solid: [],
dotted: [2, 2],
dashed: [6, 3],
};
const dashStyle = (v: unknown): DashStyle => {
if (v === undefined) return '';
if (!Array.isArray(v)) return 'custom';
if (v.length === 0) return 'solid';
if (v.length === 2 && v[0] === 2 && v[1] === 2) return 'dotted';
if (v.length === 2 && v[0] === 6 && v[1] === 3) return 'dashed';
return 'custom';
};
export function AxesControls({ config }: { config: JsonObject }) {
const set = useConfigSetter();
const sections: AccordionSection[] = [
{
id: 'grid',
title: 'Grid',
hint: 'Reference lines behind the marks.',
badge: countSet(config, [GRID, GRID_COLOR, GRID_DASH, GRID_WIDTH]),
children: (
<>
<SelectRow
id="axes-grid"
label="Grid lines"
options={showOptions}
value={showState(asBoolean(getConfigValue(config, GRID)))}
onSelect={(s) => set(GRID, s === '' ? undefined : s === 'true')}
/>
<ColorRow
label="Grid color"
value={asString(getConfigValue(config, GRID_COLOR))}
fallback={GRID_GREY}
onChange={(hex) => set(GRID_COLOR, hex)}
onClear={() => set(GRID_COLOR, undefined)}
/>
<SelectRow
id="axes-grid-dash"
label="Grid style"
options={dashOptions}
value={dashStyle(getConfigValue(config, GRID_DASH))}
onSelect={(s) =>
set(GRID_DASH, s === '' ? undefined : DASH_VALUES[s as keyof typeof DASH_VALUES])
}
/>
<NumberRow
label="Grid width"
value={asNumber(getConfigValue(config, GRID_WIDTH))}
min={0}
unit="px"
onChange={(n) => set(GRID_WIDTH, n)}
/>
</>
),
},
{
id: 'ticks',
title: 'Ticks',
hint: 'The marks along the axis line.',
badge: countSet(config, [TICKS, TICK_COLOR, TICK_SIZE]),
children: (
<>
<SelectRow
id="axes-ticks"
label="Ticks"
options={showOptions}
value={showState(asBoolean(getConfigValue(config, TICKS)))}
onSelect={(s) => set(TICKS, s === '' ? undefined : s === 'true')}
/>
<ColorRow
label="Tick color"
value={asString(getConfigValue(config, TICK_COLOR))}
fallback={GRID_GREY}
onChange={(hex) => set(TICK_COLOR, hex)}
onClear={() => set(TICK_COLOR, undefined)}
/>
<NumberRow
label="Tick size"
value={asNumber(getConfigValue(config, TICK_SIZE))}
min={0}
unit="px"
onChange={(n) => set(TICK_SIZE, n)}
/>
</>
),
},
{
id: 'domain',
title: 'Domain & labels',
hint: 'The axis line, its tick labels, and title.',
badge: countSet(config, [
DOMAIN_COLOR,
DOMAIN_WIDTH,
LABEL_COLOR,
LABEL_ANGLE,
LABEL_PADDING,
TITLE_COLOR,
]),
children: (
<>
<ColorRow
label="Domain line"
value={asString(getConfigValue(config, DOMAIN_COLOR))}
fallback={GRID_GREY}
onChange={(hex) => set(DOMAIN_COLOR, hex)}
onClear={() => set(DOMAIN_COLOR, undefined)}
/>
<NumberRow
label="Domain width"
value={asNumber(getConfigValue(config, DOMAIN_WIDTH))}
min={0}
unit="px"
onChange={(n) => set(DOMAIN_WIDTH, n)}
/>
<ColorRow
label="Label color"
value={asString(getConfigValue(config, LABEL_COLOR))}
fallback={GRID_GREY}
onChange={(hex) => set(LABEL_COLOR, hex)}
onClear={() => set(LABEL_COLOR, undefined)}
/>
<NumberRow
label="Label angle"
value={asNumber(getConfigValue(config, LABEL_ANGLE))}
min={-90}
max={90}
unit="°"
onChange={(n) => set(LABEL_ANGLE, n)}
/>
<NumberRow
label="Label padding"
value={asNumber(getConfigValue(config, LABEL_PADDING))}
min={0}
unit="px"
onChange={(n) => set(LABEL_PADDING, n)}
/>
<ColorRow
label="Title color"
value={asString(getConfigValue(config, TITLE_COLOR))}
fallback={GRID_GREY}
onChange={(hex) => set(TITLE_COLOR, hex)}
onClear={() => set(TITLE_COLOR, undefined)}
/>
</>
),
},
];
return <Accordion sections={sections} idPrefix="axes" />;
}
-107
View File
@@ -1,107 +0,0 @@
/*
* Button shared look for every textual action button (arch 09 §4). Owned here,
* once. Heights come from the control scale in tokens.css; no other button
* heights exist in the app.
*/
.button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
height: var(--control-height);
padding: 0 var(--space-3);
border: var(--border-width) solid transparent;
border-radius: var(--radius);
background: transparent;
color: var(--text);
font: inherit;
font-size: 13px;
font-weight: 600;
line-height: 1;
white-space: nowrap;
cursor: pointer;
transition:
background var(--dur-fast) var(--ease),
color var(--dur-fast) var(--ease);
}
.lg {
height: var(--control-height-lg);
padding: 0 var(--space-4);
}
.button:disabled {
cursor: default;
opacity: 0.45;
}
/* --- primary: filled accent; darkens on hover (filled buttons darken, they
don't fill arch 09 §4) --- */
.primary {
background: var(--accent);
color: var(--accent-contrast);
}
.primary:hover:not(:disabled) {
background: var(--accent-hover);
}
/* --- secondary: bordered with a --bg fill the same field-on-layer treatment
as inputs/selects (arch 09 §4: a bordered control on a gray panel goes
white, never a darker gray); hover steps one elevation above the surface
(see --control-hover-fill in Button.tsx header) --- */
.secondary {
border-color: var(--border-strong);
background: var(--bg);
}
.secondary:hover:not(:disabled) {
background: var(--control-hover-fill, var(--layer-01));
}
/* --- ghost: borderless utility; recedes until hover --- */
.ghost:hover:not(:disabled) {
background: var(--control-hover-fill, var(--layer-01));
}
/* A ghost acting as an open disclosure trigger holds the pressed fill. */
.ghost[aria-expanded='true'] {
background: var(--layer-02);
}
/* --- soft-accent: tinted-but-quiet solicitation (e.g. Donate) --- */
.softAccent {
background: var(--accent-soft);
color: var(--accent-hover);
}
.softAccent:hover:not(:disabled) {
background: var(--accent-soft-hover);
color: var(--accent-hover);
}
/* --- danger: filled error for destructive confirm actions --- */
.danger {
background: var(--support-error);
color: var(--on-status);
}
.danger:hover:not(:disabled) {
filter: brightness(0.92);
}
/* --- danger-outline: secondary geometry, red label; fills solid red on
hover/focus so the destructive intent is signalled before it acts --- */
.dangerOutline {
border-color: var(--border-strong);
background: var(--bg);
color: var(--support-error);
}
.dangerOutline:hover:not(:disabled),
.dangerOutline:focus-visible {
background: var(--support-error);
border-color: transparent;
color: var(--on-status);
}
-79
View File
@@ -1,79 +0,0 @@
import { afterEach, beforeEach, describe, expect, test } from 'vitest';
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { Button } from './Button';
import { IconButton } from './IconButton';
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
function render(node: React.ReactNode) {
act(() => root.render(node));
const button = container.querySelector('button');
if (!button) throw new Error('no <button> rendered');
return button;
}
describe('Button', () => {
test('defaults to type="button" so it never submits an enclosing form', () => {
expect(render(<Button>Save</Button>).getAttribute('type')).toBe('button');
expect(render(<Button type="submit">Save</Button>).getAttribute('type')).toBe('submit');
});
test('each variant resolves to a distinct class string', () => {
// Whatever the CSS-module hashing, the variants must produce different
// classes so the arch 09 §4 taxonomy is actually applied, not ignored.
const classes = (
['primary', 'secondary', 'ghost', 'soft-accent', 'danger', 'danger-outline'] as const
).map((variant) => render(<Button variant={variant}>X</Button>).className);
expect(new Set(classes).size).toBe(classes.length);
});
test('size and call-site classes compose onto the base class', () => {
// Capture the string before the next render — re-rendering reuses the node.
const mdClass = render(<Button>X</Button>).className;
const lg = render(
<Button size="lg" className="layout">
X
</Button>,
);
expect(lg.className).not.toBe(mdClass);
expect(lg.classList.contains('layout')).toBe(true);
});
});
describe('IconButton', () => {
test('label provides the accessible name and the default tooltip', () => {
const btn = render(<IconButton label="Close" />);
expect(btn.getAttribute('aria-label')).toBe('Close');
expect(btn.getAttribute('title')).toBe('Close');
});
test('an explicit title overrides the tooltip but not the accessible name', () => {
const btn = render(<IconButton label="Datasets" title="Datasets (⌘/Ctrl+K)" />);
expect(btn.getAttribute('aria-label')).toBe('Datasets');
expect(btn.getAttribute('title')).toBe('Datasets (⌘/Ctrl+K)');
});
test('defaults to type="button" and composes size/call-site classes', () => {
const md = render(<IconButton label="X" />);
expect(md.getAttribute('type')).toBe('button');
const mdClass = md.className;
const sm = render(<IconButton label="X" size="sm" className="reveal" />);
expect(sm.className).not.toBe(mdClass);
expect(sm.classList.contains('reveal')).toBe(true);
});
});
-74
View File
@@ -1,74 +0,0 @@
/**
* Button the shared action-button primitive (arch 09 §4).
*
* Every textual action button in the app is one of these. The component owns the
* base look (Button.module.css) so instances can't drift; call sites compose
* `className` for layout-specific additions only (flex, margins), never to
* restyle the control.
*
* Variants are the arch 09 §4 taxonomy:
* - **primary** filled `--accent`; at most one per region (emphasis hierarchy).
* - **secondary** bordered, `--bg` fill (the field-on-layer treatment).
* - **ghost** borderless; utilities in toolbars/headers that should recede.
* (A transparent border still holds the box size so hover doesn't shift
* neighbours.)
* - **soft-accent** ghost on an `--accent-soft` wash; low-emphasis solicitation.
* - **danger** filled `--support-error`; destructive confirm actions.
* - **danger-outline** secondary geometry with a red label, filling solid red
* on hover/focus (arch 10 a destructive control signals danger before it
* acts). For destructive actions that sit among peers (a detail view's
* Delete); the filled `danger` stays reserved for the confirm step.
*
* Sizes are the control scale (tokens.css): `md` = `--control-height` (32px, the
* default toolbars, forms, dialog action rows), `lg` = `--control-height-lg`
* (40px standalone primary CTAs).
*
* Hover on secondary/ghost fills one elevation step above the surface (arch 09
* §4). The step is resolved by the `--control-hover-fill` custom property:
* defaults to `--layer-01` (controls on the `--bg` canvas); elevated surfaces
* (the header, modal cards) set `--control-hover-fill: var(--layer-02)` once and
* every Button/IconButton on them steps up automatically.
*/
import type { ComponentPropsWithRef } from 'react';
import styles from './Button.module.css';
type ButtonVariant =
| 'primary'
| 'secondary'
| 'ghost'
| 'soft-accent'
| 'danger'
| 'danger-outline';
export interface ButtonProps extends ComponentPropsWithRef<'button'> {
variant?: ButtonVariant;
size?: 'md' | 'lg';
}
const VARIANT_CLASS: Record<ButtonVariant, string> = {
primary: styles.primary,
secondary: styles.secondary,
ghost: styles.ghost,
'soft-accent': styles.softAccent,
danger: styles.danger,
'danger-outline': styles.dangerOutline,
};
export function Button({
variant = 'secondary',
size = 'md',
className,
type = 'button',
...rest
}: ButtonProps) {
return (
<button
type={type}
className={[styles.button, VARIANT_CLASS[variant], size === 'lg' && styles.lg, className]
.filter(Boolean)
.join(' ')}
{...rest}
/>
);
}
File diff suppressed because it is too large Load Diff
@@ -1,569 +0,0 @@
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { createDataset } from '@core/dataset';
import { useChartBuilderStore } from '../stores/ChartBuilderStore';
import { useDatasetStore } from '../stores/DatasetStore';
import { useSnippetStore } from '../stores/SnippetStore';
import { ChartBuilderModal } from './ChartBuilderModal';
// The builder preview embeds a real Vega chart in an effect; stub the renderer so
// these tests stay pure React/DOM checks. `renderSpec` is a vi.fn so a test can make
// it reject (e.g. the canvas-too-large path); the mocked `ChartTooLargeError` is the
// same class the component imports, so its `instanceof` check matches. The class is
// declared inside the factory because vi.mock is hoisted above module-scope code.
vi.mock('../services/chart-renderer', () => {
class ChartTooLargeError extends Error {
heightPx: number;
limitPx: number;
constructor(heightPx: number, limitPx: number) {
super('too large');
this.name = 'ChartTooLargeError';
this.heightPx = heightPx;
this.limitPx = limitPx;
}
}
return {
renderSpec: vi.fn(() =>
Promise.resolve({
destroy() {},
resize() {},
inspectData: () => null,
onDataChange: () => () => {},
}),
),
ChartTooLargeError,
};
});
// React 19 wants this flag set for act() to drive effects without warnings.
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const T = new Date('2026-06-01T00:00:00Z');
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
useChartBuilderStore.getState().reset();
useDatasetStore.getState().reset();
useSnippetStore.getState().reset();
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
describe('ChartBuilderModal', () => {
test('renders without an infinite update loop when the config has warnings (regression)', async () => {
// Two numeric columns → default mark Point (clean). Switching to Bar makes it
// "two measures on a non-scatter" → a NON-EMPTY warnings array — the exact
// condition that previously looped because the warnings selector returned a
// fresh array of objects on every render. The fix derives warnings via useMemo
// over the stable `config` reference instead.
const ds = createDataset({
name: 'Nums',
data: [
{ a: 1, b: 2 },
{ a: 3, b: 4 },
],
format: 'json',
source: 'inline',
now: T,
});
useDatasetStore.getState().add(ds);
// `add` reassigns a collision-free id (it is the id authority), so read it back.
const id = useDatasetStore.getState().datasets[0].id;
useChartBuilderStore.getState().init(id);
useChartBuilderStore.getState().setMark('bar');
expect(useChartBuilderStore.getState().config.mark).toBe('bar');
// If the component looped, this act() would throw "Maximum update depth exceeded".
await act(async () => {
root.render(<ChartBuilderModal />);
await Promise.resolve();
});
expect(container.textContent).toContain('Nums'); // the dataset picker names the data
expect(container.textContent).toContain('scatter'); // the guidance hint rendered
});
test('a guidance hint offers a one-click fix that resolves it (actionable hints, §06)', async () => {
const ds = createDataset({
name: 'Nums',
data: [
{ a: 1, b: 2 },
{ a: 3, b: 4 },
],
format: 'json',
source: 'inline',
now: T,
});
useDatasetStore.getState().add(ds);
const id = useDatasetStore.getState().datasets[0].id;
useChartBuilderStore.getState().init(id);
useChartBuilderStore.getState().setMark('bar'); // two measures on a bar → scatter hint
await act(async () => {
root.render(<ChartBuilderModal />);
await Promise.resolve();
});
// The hint renders a [Switch to Point] button (not just prose).
const fixButton = Array.from(container.querySelectorAll('button')).find(
(b) => b.textContent === 'Switch to Point',
);
expect(fixButton).toBeDefined();
expect(container.textContent).toContain('scatter');
await act(async () => {
fixButton!.click();
await Promise.resolve();
});
// Applying it switches the mark and the hint re-derives away.
expect(useChartBuilderStore.getState().config.mark).toBe('point');
expect(container.textContent).not.toContain('scatter');
});
test('shows the canvas-limit message when the chart resolves too large to render', async () => {
vi.useFakeTimers();
const { renderSpec, ChartTooLargeError } = await import('../services/chart-renderer');
vi.mocked(renderSpec).mockRejectedValueOnce(new ChartTooLargeError(200_000, 16_383));
const ds = createDataset({
name: 'Big',
data: [
{ a: 1, b: 'x' },
{ a: 2, b: 'y' },
],
format: 'json',
source: 'inline',
now: T,
});
useDatasetStore.getState().add(ds);
const id = useDatasetStore.getState().datasets[0].id;
useChartBuilderStore.getState().init(id);
await act(async () => {
root.render(<ChartBuilderModal />);
await Promise.resolve();
});
// Drive the debounced render so renderSpec runs and rejects with the limit error.
await act(async () => {
await vi.advanceTimersByTimeAsync(400);
});
expect(container.textContent).toContain('larger than the browser can draw on a canvas');
expect(container.textContent).toContain('200,000'); // the measured height
vi.useRealTimers();
});
test('a complete filter row reaches the renderer as a top-level transform (1C)', async () => {
vi.useFakeTimers();
const { renderSpec } = await import('../services/chart-renderer');
vi.mocked(renderSpec).mockClear();
const ds = createDataset({
name: 'Sales',
data: [
{ region: 'N', revenue: 100 },
{ region: 'S', revenue: 50 },
],
format: 'json',
source: 'inline',
now: T,
});
useDatasetStore.getState().add(ds);
const id = useDatasetStore.getState().datasets[0].id;
const store = useChartBuilderStore.getState();
store.init(id);
store.addFilter();
const fid = useChartBuilderStore.getState().config.filters![0].id;
store.setFilterField(fid, 'revenue');
store.updateFilter(fid, { op: 'gt', value: '60' });
await act(async () => {
root.render(<ChartBuilderModal />);
await Promise.resolve();
});
await act(async () => {
await vi.advanceTimersByTimeAsync(400); // drive the debounced preview render
});
expect(container.querySelector('button[aria-label^="Filter operator"]')).toBeTruthy();
const calls = vi.mocked(renderSpec).mock.calls;
const lastSpec = calls[calls.length - 1][1] as { transform?: unknown };
expect(lastSpec.transform).toEqual([{ filter: { field: 'revenue', gt: 60 } }]);
vi.useRealTimers();
});
test('the data preview discloses the dataset rows on demand (1D)', async () => {
const ds = createDataset({
name: 'Sales',
data: [{ region: 'North', revenue: 100 }],
format: 'json',
source: 'inline',
now: T,
});
useDatasetStore.getState().add(ds);
const id = useDatasetStore.getState().datasets[0].id;
useChartBuilderStore.getState().init(id);
await act(async () => {
root.render(<ChartBuilderModal />);
await Promise.resolve();
});
const toggle = Array.from(container.querySelectorAll('button')).find((b) =>
b.textContent?.includes('Preview rows'),
);
expect(toggle).toBeTruthy();
expect(container.querySelector('table')).toBeNull(); // collapsed by default
await act(async () => {
toggle!.click();
await Promise.resolve();
});
expect(container.querySelector('table')).toBeTruthy();
expect(container.textContent).toContain('region');
expect(container.textContent).toContain('North');
});
test('an invalid expression-mode filter surfaces an inline parser error (1E)', async () => {
const ds = createDataset({
name: 'S',
data: [{ a: 1 }],
format: 'json',
source: 'inline',
now: T,
});
useDatasetStore.getState().add(ds);
const id = useDatasetStore.getState().datasets[0].id;
const store = useChartBuilderStore.getState();
store.init(id);
store.addFilter();
const fid = useChartBuilderStore.getState().config.filters![0].id;
store.setFilterMode(fid, 'expression');
store.updateFilter(fid, { expr: 'datum.a *' });
await act(async () => {
root.render(<ChartBuilderModal />);
await Promise.resolve();
});
// Polite, not assertive: live per-keystroke validation uses role="status" with a
// status glyph, never an assertive alert (council: APG Alert / WCAG 2.2.4).
const messages = Array.from(container.querySelectorAll('[role="status"]'));
const errorMsg = messages.find((n) => /nexpected|Invalid/.test(n.textContent ?? ''));
expect(errorMsg).toBeTruthy();
// The expression input is linked to its message and flagged invalid.
const exprInput = container.querySelector('input[aria-label="Filter expression"]');
expect(exprInput?.getAttribute('aria-invalid')).toBe('true');
expect(exprInput?.getAttribute('aria-describedby')).toBe(errorMsg?.id);
});
test('the Vega expression reference shows only when an expression is in play (1E)', async () => {
const ds = createDataset({
name: 'S',
data: [{ a: 1 }],
format: 'json',
source: 'inline',
now: T,
});
useDatasetStore.getState().add(ds);
const id = useDatasetStore.getState().datasets[0].id;
useChartBuilderStore.getState().init(id);
await act(async () => {
root.render(<ChartBuilderModal />);
await Promise.resolve();
});
const refLink = () =>
Array.from(container.querySelectorAll('a')).find((a) =>
a.textContent?.includes('Vega expression'),
);
expect(refLink()).toBeUndefined(); // no expression yet → no reference link
await act(async () => {
useChartBuilderStore.getState().addCalculate(); // a calculated field is an expression
await Promise.resolve();
});
expect(refLink()).toBeDefined();
expect(refLink()!.getAttribute('href')).toContain('vega.github.io');
});
test('clicking a field opens the channel chooser; picking a channel assigns it (field-first, 2B)', async () => {
const ds = createDataset({
name: 'Shop',
data: [{ region: 'E', sales: 5 }],
format: 'json',
source: 'inline',
now: T,
});
useDatasetStore.getState().add(ds);
const id = useDatasetStore.getState().datasets[0].id;
const store = useChartBuilderStore.getState();
store.init(id);
store.setChannelColumn('x', null);
store.setChannelColumn('y', null);
await act(async () => {
root.render(<ChartBuilderModal />);
await Promise.resolve();
});
const fieldButton = Array.from(container.querySelectorAll('button')).find((b) =>
b.textContent?.includes('region'),
);
expect(fieldButton).toBeDefined();
await act(async () => {
fieldButton!.click();
await Promise.resolve();
});
// Unarmed, the click opens an explicit channel chooser (portaled to <body>)
// rather than silently filling the first empty seat (council 2026-06-12).
expect(useChartBuilderStore.getState().config.encodings.x).toBeNull();
const option = Array.from(document.body.querySelectorAll('button')).find((b) =>
b.textContent?.includes('Columns (X)'),
);
expect(option).toBeDefined();
await act(async () => {
option!.click();
await Promise.resolve();
});
expect(useChartBuilderStore.getState().config.encodings.x).toEqual({
field: 'region',
type: 'nominal',
});
});
test('an armed channel short-circuits the chooser: the field assigns directly (2B)', async () => {
const ds = createDataset({
name: 'Shop',
data: [{ region: 'E', sales: 5 }],
format: 'json',
source: 'inline',
now: T,
});
useDatasetStore.getState().add(ds);
const id = useDatasetStore.getState().datasets[0].id;
const store = useChartBuilderStore.getState();
store.init(id);
store.setChannelColumn('x', null);
store.setChannelColumn('y', null);
store.focusChannel('y');
await act(async () => {
root.render(<ChartBuilderModal />);
await Promise.resolve();
});
// The armed state announces itself at the shelf.
expect(container.textContent).toContain('Assigning to Y');
const fieldButton = Array.from(container.querySelectorAll('button')).find((b) =>
b.textContent?.includes('region'),
);
await act(async () => {
fieldButton!.click();
await Promise.resolve();
});
expect(useChartBuilderStore.getState().config.encodings.y).toEqual({
field: 'region',
type: 'nominal',
});
expect(useChartBuilderStore.getState().activeChannel).toBeNull();
});
test('opening a SelectControl lands focus on the selected option, not the first (regression)', async () => {
const ds = createDataset({
name: 'Sales',
data: [{ day: '2026-01-01', v: 1 }],
format: 'json',
source: 'inline',
now: T,
});
useDatasetStore.getState().add(ds);
const id = useDatasetStore.getState().datasets[0].id;
const store = useChartBuilderStore.getState();
store.init(id);
store.setChannelColumn('x', 'day'); // temporal → the pill offers Granularity
store.setChannelTimeUnit('x', 'month'); // "Month" sits mid-list, after "None (raw)"
await act(async () => {
root.render(<ChartBuilderModal />);
await Promise.resolve();
});
const trigger = container.querySelector<HTMLButtonElement>(
'button[aria-label^="Granularity for"]',
);
expect(trigger).toBeTruthy();
await act(async () => {
trigger!.click();
await Promise.resolve();
});
// A selector list ('[aria-current="true"], button') would return the first
// button in document order — the "None (raw)" option — instead of the selection.
const focused = document.activeElement as HTMLElement;
expect(focused.getAttribute('aria-current')).toBe('true');
expect(focused.textContent).toContain('Month');
});
test('Colour can be switched to a constant value (the Property model, 2A/2B)', async () => {
const ds = createDataset({
name: 'Shop',
data: [{ region: 'E', sales: 5 }],
format: 'json',
source: 'inline',
now: T,
});
useDatasetStore.getState().add(ds);
const id = useDatasetStore.getState().datasets[0].id;
useChartBuilderStore.getState().init(id);
await act(async () => {
root.render(<ChartBuilderModal />);
await Promise.resolve();
});
// The empty Colour slot offers a "Use a constant" ghost button (Colour is
// first in Marks).
const constButton = Array.from(container.querySelectorAll('button')).find(
(b) => b.textContent === 'Use a constant',
);
expect(constButton).toBeDefined();
await act(async () => {
constButton!.click();
await Promise.resolve();
});
expect(useChartBuilderStore.getState().config.encodings.color?.value).toBeDefined();
// A colour picker renders for the constant.
expect(container.querySelector('input[type="color"]')).not.toBeNull();
});
// The intent front door's core logic (which chip lights, what each applies, gating)
// is covered in core/store; these check only the parts that live in the component —
// the toolbar's roving tabindex and arrow navigation (a hand-rolled handler, not a
// shared primitive), and that a click reshapes the chart.
describe('intent front door (the "What do you want to show?" strip)', () => {
// Two categories + one date + two measures → every intent applies; enough shape to
// light a default and to exercise an applied intent (Heatmap needs two categories).
const seedSuperstore = () => {
const ds = createDataset({
name: 'Superstore',
data: [
{ region: 'E', segment: 'A', date: '2026-01-01', sales: 5, profit: 1 },
{ region: 'W', segment: 'B', date: '2026-02-01', sales: 9, profit: 3 },
],
format: 'json',
source: 'inline',
now: T,
});
useDatasetStore.getState().add(ds);
const id = useDatasetStore.getState().datasets[0].id;
useChartBuilderStore.getState().init(id);
return id;
};
const chips = (): HTMLButtonElement[] =>
Array.from(container.querySelectorAll<HTMLButtonElement>('[role="toolbar"] button'));
test('renders one tab stop and lights the active intent (roving tabindex)', async () => {
seedSuperstore();
await act(async () => {
root.render(<ChartBuilderModal />);
await Promise.resolve();
});
const all = chips();
expect(all.length).toBe(7); // every intent shows (Tableau Show Me: never hidden)
// Exactly one chip is in the tab order; the rest are roving (-1).
expect(all.filter((b) => b.tabIndex === 0)).toHaveLength(1);
// The smart default for a category+count shape is Compare, so its chip is pressed.
const pressed = all.filter((b) => b.getAttribute('aria-pressed') === 'true');
expect(pressed).toHaveLength(1);
expect(pressed[0].textContent).toBe('Compare');
});
test('an inapplicable intent is disabled and names its reason', async () => {
// One category, one measure, no date and no second measure → Correlation/Time/
// Heatmap/Part-to-whole cannot apply.
const ds = createDataset({
name: 'Thin',
data: [{ region: 'E', sales: 5 }],
format: 'json',
source: 'inline',
now: T,
});
useDatasetStore.getState().add(ds);
const id = useDatasetStore.getState().datasets[0].id;
useChartBuilderStore.getState().init(id);
await act(async () => {
root.render(<ChartBuilderModal />);
await Promise.resolve();
});
const correlation = chips().find((b) => b.textContent === 'Correlation')!;
expect(correlation.getAttribute('aria-disabled')).toBe('true');
expect(correlation.getAttribute('aria-label')).toMatch(/needs two number columns/);
});
test('clicking an enabled chip reshapes the chart to that intent', async () => {
seedSuperstore();
await act(async () => {
root.render(<ChartBuilderModal />);
await Promise.resolve();
});
const heatmap = chips().find((b) => b.textContent === 'Heatmap')!;
await act(async () => {
heatmap.click();
await Promise.resolve();
});
expect(useChartBuilderStore.getState().config.mark).toBe('rect');
expect(useChartBuilderStore.getState().config.encodings.color).toEqual({
type: 'quantitative',
aggregate: 'count',
});
});
test('ArrowRight moves focus along the toolbar without applying (focus-only)', async () => {
seedSuperstore();
await act(async () => {
root.render(<ChartBuilderModal />);
await Promise.resolve();
});
const all = chips();
const start = all.findIndex((b) => b.tabIndex === 0);
const markBefore = useChartBuilderStore.getState().config.mark;
await act(async () => {
all[start].focus();
all[start].dispatchEvent(
new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }),
);
await Promise.resolve();
});
// Focus moved to the next chip; the chart is untouched (arrows navigate, Enter applies).
expect(document.activeElement).toBe(all[(start + 1) % all.length]);
expect(useChartBuilderStore.getState().config.mark).toBe(markBefore);
});
});
});
File diff suppressed because it is too large Load Diff
-91
View File
@@ -1,91 +0,0 @@
/* ChartExport — per-chart export disclosure in the Live Preview header (spec §08). */
.wrap {
position: relative;
display: inline-flex;
}
/* The trigger is the shared Button primitive (ghost; arch 09 §4). */
/* The disclosed panel portaled to <body>, positioned `fixed` (top/right set
inline) so it escapes the panes' overflow clipping. Mirrors SettingsPopover. */
.pop {
position: fixed;
z-index: 1000;
display: flex;
flex-direction: column;
min-width: 264px;
max-width: min(320px, 92vw);
padding: var(--space-3);
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);
}
.groupTitle {
margin: var(--space-3) 0 var(--space-1);
padding: 0 var(--space-2);
font-size: 11px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-secondary);
}
/* The first group heading sits flush with the panel top. */
.groupTitle:first-child {
margin-top: 0;
}
/* Action rows — full-width, left-aligned ghost buttons. */
.action {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--space-3);
width: 100%;
padding: var(--space-2) var(--space-2);
border: var(--border-width) solid transparent;
border-radius: var(--radius);
background: transparent;
color: var(--text);
font: inherit;
font-size: 13px;
text-align: left;
cursor: pointer;
transition: background var(--dur-fast) var(--ease);
}
.action:hover:not(:disabled) {
background: var(--layer-02);
}
.action:focus-visible {
outline: 2px solid var(--focus);
outline-offset: -1px;
}
.action:disabled {
color: var(--text-placeholder);
cursor: not-allowed;
}
/* The monospace extension hint, trailing the JSON action. */
.ext {
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-secondary);
}
.action:disabled .ext {
color: inherit;
}
.hint {
margin: var(--space-2) 0 0;
padding: 0 var(--space-2);
font-size: 11px;
line-height: 1.4;
color: var(--text-secondary);
}
-87
View File
@@ -1,87 +0,0 @@
/**
* ChartExport image-export failure vs not-ready (arch 05 §7 fail-loud).
*
* `getImageUrl` (injected by LivePreview) returns `null` ONLY when no view is
* live the not-ready case and rejects on a real rasterize/serialize failure.
* These two outcomes must land as DIFFERENT toasts: the not-ready one tells the
* user to wait (no `detail`), the failure one carries diagnostic `detail` so the
* problem can be reported. Burying the rejection behind the not-ready copy is the
* regression this guards.
*/
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { useNotificationStore } from '../stores/NotificationStore';
import { usePopoverStore } from '../stores/PopoverStore';
import { useSnippetStore } from '../stores/SnippetStore';
import { useDatasetStore } from '../stores/DatasetStore';
import { ChartExport, type ChartExportProps } from './ChartExport';
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
useNotificationStore.getState().clear();
usePopoverStore.setState({ openId: null });
useSnippetStore.getState().reset();
useDatasetStore.getState().reset();
// A non-empty draft makes `hasSpec` true so the export trigger is enabled.
useSnippetStore.setState({ draftText: '{"mark":"point"}' });
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
useNotificationStore.getState().clear();
usePopoverStore.setState({ openId: null });
useSnippetStore.getState().reset();
useDatasetStore.getState().reset();
});
/** Mount, open the export popover (portaled to <body>), and click "Download PNG".
* `downloadImage` awaits `getImageUrl`, so the caller flushes microtasks after. */
async function mountAndDownloadPng(getImageUrl: ChartExportProps['getImageUrl']) {
act(() => {
root.render(<ChartExport chartReady getImageUrl={getImageUrl} />);
});
act(() => usePopoverStore.getState().show('chart-export'));
const pngBtn = Array.from(document.body.querySelectorAll('button')).find(
(b) => b.textContent?.trim() === 'Download PNG',
);
if (!pngBtn) throw new Error('Download PNG button not found — popover did not render');
await act(async () => {
pngBtn.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await Promise.resolve();
});
}
describe('ChartExport image-export error handling', () => {
test('a rejecting getImageUrl surfaces the failure with diagnostic detail', async () => {
await mountAndDownloadPng(vi.fn().mockRejectedValue(new TypeError('tainted canvas')));
const toasts = useNotificationStore.getState().notifications;
expect(toasts).toHaveLength(1);
expect(toasts[0].kind).toBe('error');
// A real failure carries its diagnostic detail (not the "wait, it's not ready" copy).
expect(toasts[0].detail).toBe('TypeError: tainted canvas');
expect(toasts[0].message).not.toMatch(/ready yet/i);
});
test('a null getImageUrl reports not-ready, with no diagnostic detail', async () => {
await mountAndDownloadPng(vi.fn().mockResolvedValue(null));
const toasts = useNotificationStore.getState().notifications;
expect(toasts).toHaveLength(1);
expect(toasts[0].kind).toBe('error');
expect(toasts[0].message).toMatch(/ready yet/i);
// Nothing for the user to report — the not-ready case omits detail.
expect(toasts[0].detail).toBeUndefined();
});
});

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