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
334 changed files with 1453 additions and 66606 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 *)"
]
}
}
-277
View File
@@ -1,277 +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 #15) 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 contract >
`docs/architecture/` patterns > local cleanup**. 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.
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.
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.
### Output
16. **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 (this is a contract; change deliberately).
- **`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 contract.
- **`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 contract; **`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 contract — _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).
-122
View File
@@ -1,122 +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 contract (what the app does, acceptance points).
This is a **contract**. Only change it when product behavior genuinely changes, and do so
deliberately — never as a casual "fill a doc gap" edit. A how-detail does NOT belong here.
- **`docs/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) — **contract; change deliberately** |
| 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.
-166
View File
@@ -1,166 +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).
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
-16
View File
@@ -1,16 +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/
-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
}
-169
View File
@@ -1,169 +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 **spec-driven rebuild** on an architecture adapted from its sibling project Syto.
The authoritative behavioral contract is **`docs/spec/`** (sections 0010). Implement _to
the spec_; 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).
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/ # Authoritative behavioral specification (0010) — the WHAT
├── 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 is the contract** — when in doubt, read `docs/spec/`. If the spec is wrong or
silent, raise it; change the spec deliberately rather than drifting from it.
- **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.
### 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.
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`.
-76
View File
@@ -1,76 +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/)** — authoritative behavioral specification (sections 0010):
the **what**. This is the contract; implement to it.
- **[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-driven rebuild** — the behavior is fixed in `docs/spec/`; the
architecture is adapted from Syto. Implement to 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. Arbitrate findings that needed session context:
either accept them, or overrule them **and** record the missing rationale where the
reviewer looked for it — an overruled finding without a writing-down will recur. Then
commit only when invited.
+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.
-33
View File
@@ -1,33 +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 — fully local, offline-capable, no account.
> Astrolabe is a **spec-driven rebuild** on an architecture adapted from its sibling
> project Syto. The authoritative behavioral contract lives in [`docs/spec/`](docs/spec/).
> 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
```
## Status
**M0 — Skeleton.** Toolchain green (typecheck, tests, build, PWA). The three-pane shell
renders; features land milestone by milestone per the implementation plan (MVP at end of M1).
## License
TBD.
-113
View File
@@ -1,113 +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. The only outbound requests are user-created
URL-dataset fetches.
### 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.
## Technical Philosophy
### Spec-Driven, Clean Implementation
The behavioral contract lives in `docs/spec/`. Astrolabe is a deliberate rebuild on a
robust architecture (adapted from Syto): we implement _to the spec_, not by porting old
code. When the spec and convenience conflict, the spec wins or the spec changes — never
silent drift.
### 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>
-553
View File
@@ -1,553 +0,0 @@
# Astrolabe — Incremental Implementation Plan
> A spec-driven rebuild of Astrolabe on Syto's architecture. The authoritative
> behavioral contract is `docs/spec/` (sections 0010). This document sequences
> the build into the **quickest path to a usable MVP**, then layers the rest.
>
> **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 every milestone. 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
| # | 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 |
| **M5** | Settings + Import/Export ✅ | Preferences + workspace backup/transfer | §07, §08, §09C |
| **M6** | Shell polish ✅ | Resize/toggle panes, routing, shortcuts, toasts, a11y, offline | §01, §10 |
**MVP boundary = end of M1** (a genuinely usable single-user chart authoring loop).
M1.5 makes it _look right_; M2 makes it _robust_; M3M6 make it _complete_.
Ship/dogfood after M1, iterate.
---
## M0 · Skeleton ✅ (done)
Vite + React + Zustand + TypeScript + Vitest (happy-dom) + vite-plugin-pwa.
`src/core``src/app` split, `useAppStore`, design tokens, three-pane placeholder
shell, first core module (`format-detection`) with tests.
**Verified:** `npm run typecheck`, `npm test`, `npm run build` (PWA SW generated).
---
## M1 · MVP core loop → _the quickest usable Astrolabe_
**Goal:** select/create a snippet, edit its spec JSON, watch a live Vega-Lite
preview, and have it survive reload. Single source kind: inline-data specs only
(datasets come in M3). No draft/published yet — edits save directly.
**Core (`src/core/`)**
- `snippet.ts` — the Snippet type (spec §09A) + factory (`createSnippet`,
default sample bar-chart template, auto-generated date/time name).
- `rendering.ts``prepareSpecForRender(spec, { fitMode })` skeleton; in M1 it's
near pass-through (reference resolution is a no-op until M3, fit-mode is M2).
Establish the "transform a copy, never mutate stored spec" contract now.
**Infrastructure (`src/app/infrastructure/`)**
- `idb.ts` — thin IndexedDB wrapper (open, get/put/delete/getAll by store).
_(see [Architecture 02 · Persistence](architecture/02-persistence.md))_
- `snippet-store.ts` — persist snippets (object store `snippets`).
**App**
- `stores/SnippetStore.ts``useSnippetStore` with `snippets`, `activeSnippetId`,
selector-derived `activeSnippet`; load-on-startup; create/select/delete/update actions
(debounced auto-save of edits, spec §03B). Seed one sample snippet on first run.
_(Superseded later: an empty library now shows the onboarding canvas instead of a
placeholder seed — spec §02 → First-Run & Empty Workspace.)_
- `components/SnippetLibrary.tsx` — list + "Create New" pinned item + select/delete.
- `components/SpecEditor.tsx` — Monaco JSON editor bound to active snippet's spec;
debounced write-back to the store. (Worker wiring via Vite `?worker` imports —
mine vega-editor's Monaco setup.)
- `components/LivePreview.tsx` — render current spec via `vega-embed` (actions:
false), debounced; clean empty pane when no/blank spec; basic error text.
- Fill the three panes in `App.tsx` with these.
**Tests (core-first)**
- `snippet.test.ts` — factory defaults, sample template validity, unique naming.
- `rendering.test.ts` — copy-not-mutate invariant; pass-through shape.
- A store test for create/select/delete/auto-save reducer logic (logic extracted
from the component so it's testable without DOM).
**Manual checks**
- Fresh load shows the onboarding canvas (welcome + Create + live example gallery);
Create or an example's Add lands you in the editor with a rendered chart. _(M1 originally
seeded a sample snippet; replaced by the onboarding canvas — spec §02.)_
- Type in the editor → preview updates after the debounce; bad JSON → editor keeps
working, preview shows an error, recovers when fixed.
- Reload → snippets and selection persist.
---
## M1.5 · Visual design foundation ✅ (done) → _make the MVP look like itself_
**Goal:** apply our design language so the running MVP looks deliberate, and every
later milestone builds on settled tokens instead of placeholders. The expensive part
(the design decisions) is already done — this milestone is _application_, not
invention. See [Architecture 09 · Visual Design Language](architecture/09-visual-design.md)
and the companion `visual-specimen.html`.
**Styles**
- Port the settled specimen tokens into `styles/tokens.css` (IBM-Plex type scale,
8px-based spacing, role-based color, square chrome, motion); light + dark themes
via `[data-theme]`.
- Self-host **IBM Plex Sans + Mono** in `styles/base.css` via `@fontsource`
(offline/PWA — never a CDN).
**App**
- Restyle the four M1 surfaces against the tokens: App shell, SnippetLibrary,
SpecEditor (Monaco theme follows `[data-theme]`), LivePreview. Tokens only — no
raw hexes, no hardcoded hues in components.
- Establish the reusable component conventions (buttons, fields, list rows, status,
focus ring) that M2M6 reuse.
- **Header theme toggle** (pulled forward from M5): a one-click light⇄dark control,
persisted via the `ui.theme` settings key (a minimal forward-compatible
`settings-store` adapter the full M5 UserSettings store will absorb). Hydrated
before first paint (no FOUC). Justified: the theme system was already complete,
so dogfooding dark mode through M2M4 beat waiting for the full settings UI.
**Core**
- Align `src/core/vega-themes.ts`: chart `Config` per theme + a categorical
`range.category` palette (clone `carbon-design-system/carbon-charts` for the
sequence — see Architecture 09 §8).
**Tests**
- Light: the design is mostly visual — a token/theme smoke check, trust the eye.
**Manual checks**
- The real app looks deliberate in both themes; theme flip repaints UI + chart.
- Keyboard focus ring visible; text/UI contrast passes AA in light and dark.
- No placeholder styling remains on the M1 surfaces.
**Verified:** `typecheck` + `test` (incl. `vega-themes.test.ts`) +
`build` (Plex woff2, all script subsets, bundled & precached via the PWA
`globPatterns`). Both themes screenshotted via the real
app (chrome + Monaco + chart all repaint on theme flip); focus ring visible.
Notes from the build-out: the placeholder `'experimental'` theme was renamed to
`'dark'` (the settled name); the swappable `[data-accent]` layer landed with
deep teal as the robust default (no switcher UI until M5); Monaco's `fontFamily` is
set to Plex Mono explicitly since it can't read the CSS token.
---
## M2 · Editor robustness ✅ (done)
**Goal:** the editor becomes trustworthy — draft vs published, schema-aware
assistance, and the fit-mode rendering contract.
**Core**
- `rendering.ts` — implement **fit-mode** transform (Original/Width/Height/Full →
Vega-Lite `"container"`), recursing into layered/concat/child specs (spec §04
Rendering Contract, step 2).
- `vega-lite-schema.ts` — provide the Vega-Lite JSON schema for Monaco's
validation/autocomplete. _Delivered early in M1.5 as
`infrastructure/monaco-schema.ts` (bundled schema, offline, `markdownDescription`
hover docs); no further work needed in M2._
**App**
- Snippet gains `spec` (published) + `draftSpec` (working) per §09A; editing
touches `draftSpec` only.
- `SpecEditor` header: Draft/Published toggle; **Publish** (promotes draft, recomputes
dataset refs — refs land in M3) + **Revert** (confirm dialog).
- Library list item: draft-vs-published **status indicator**.
- Monaco wired with the Vega-Lite schema → squiggles + autocomplete; inline error
surface in the editor pane (§03E).
- Preview **Fit control** (4 modes), persisted (`previewFitMode`).
**Tests**
- Fit-mode transforms for each mode incl. nested specs; copy-not-mutate.
- Draft/publish/revert reducer logic; "has unpublished changes" derivation.
**Manual checks**
- Edit draft, see status flip to "draft"; Publish → status clears; Revert →
draft restored with confirmation.
- Invalid spec shows inline error; autocomplete suggests Vega-Lite properties.
- Each fit mode resizes the chart as specified; choice survives reload.
**Verified:** `typecheck` + `test` (`rendering` fit-mode incl.
nested layer/concat/facet specs, `SnippetStore` draft/publish/revert/editorView,
`settings-store` `previewFitMode` round-trip) + `build` (PWA, 41 precache
entries) + `eslint` clean. Implementation notes: editing now writes the
**draft** only (`commitDraft` no longer touches `spec`); `publish`/`revert` live
in `SnippetStore`, with a `bufferEpoch` counter so programmatic buffer reloads
(select/create/revert) refresh Monaco without fighting the cursor mid-typing.
The Draft/Published view is a store-level `editorView`; the published view is
read-only and the preview renders whichever version is shown (`selectShownText`).
The editor (§03E) and preview (§04) share one render error via a small
`PreviewStore`. `previewFitMode` was pulled into `AppStore` + the settings
adapter, hydrated/persisted by a new `orchestration/preferences.ts` mirroring the
theme slice. Publish/Revert **success toasts** stay deferred to M6 (TODO
breadcrumbs at the call sites), matching the existing delete-toast convention.
Fit-mode rendering needed a layout fix: vega-embed brands the embed host with its
own `.vega-embed { display: inline-block }` (injected at runtime, wins the
cascade), which shrink-wrapped the host so `width: "container"` collapsed (Height
survived only via the old `min-height: 100%`). Fix: embed into a static-class
inner host (React never reconciles its className, so Vega's runtime classes
survive) inside a React-owned frame that carries the fit-sizing class via
two-class selectors that out-specify `.vega-embed`. All four fit modes
user-verified in the running app.
---
## M3 · Datasets ✅ (done)
**Goal:** named, reusable data that snippets reference by name; preview resolves
the reference.
**Core**
- `profiling.ts` — row/column counts, column names, **per-column type inference**
(number/text/date/boolean). _(see [Architecture 06 · Type Inference](architecture/06-type-inference.md))_
- `rendering.ts` — implement **dataset reference resolution** (§04 Rendering
Contract, step 1): `{data:{name}}` → inline values / raw text+format / URL+format,
recursing into sub-specs; "dataset not found" error.
- `dataset.ts` — Dataset type (§09B); name uniqueness helpers; rename-propagation
into referencing specs. _(see [Architecture 07 · Naming & Relationships](architecture/07-naming-and-relationships.md))_
**Infrastructure**
- `dataset-store.ts` — separate high-capacity IndexedDB store (§09E).
**App**
- `stores/DatasetStore.ts` + Datasets **modal** (list/detail panes, create form,
edit, delete, copy-reference) via the modal registry/coordinator.
- Snippet `datasetRefs` maintained on publish; library shows dataset icon +
Linked Datasets; dataset detail shows Linked Snippets (bidirectional name link, §09F).
- **Extract-to-Dataset** flow from the editor (§03F).
- URL datasets fetched once on add and **snapshotted** locally (profiled like inline;
render from the snapshot, refreshable on demand — see §05 / §04 rendering contract).
**Tests**
- Reference resolution per source/format incl. nested; not-found error.
- Profiling/type inference across mixed columns, nulls, booleans.
- Rename propagation; name-uniqueness + import-style auto-suffix.
**Manual checks**
- Create a dataset, reference it by name in a snippet → preview renders.
- Extract inline data → spec rewritten to a reference, dataset appears, links show both ways.
- Delete/rename a referenced dataset behaves per spec.
---
## M4 · Chart Builder ✅ (done)
**Goal:** no-JSON chart composition from a dataset → a new snippet.
> **Enhancement push (post-M4).** The forward plan now lives in
> [`docs/exploration/chart-builder-enhancement-scope.md`](exploration/chart-builder-enhancement-scope.md) — it merges
> the Tier-B backlog ([`chart-builder-research.md`](exploration/chart-builder-research.md) §8) with the
> Lyra interaction review ([`lyra-review.md`](exploration/lyra-review.md)) and sets a **Tier-C** target.
> Shipped beyond the Tier-B floor so far: per-channel aggregate/bin/`timeUnit`, sort/stack;
> **actionable hints** (one-click warning fixes); and a builder UX/perf batch (near-fullscreen
> modal, canvas preview + canvas max-dimension guard, data-aware default pre-population). See
> the scope doc §4 for the sequenced plan and current status.
**Core**
- `chart-builder.ts` — pure spec assembler: (mark ∈ Bar/Line/Point/Area/Circle) +
channels (X/Y/Color/Size) with field types (Quantitative/Nominal/Ordinal/Temporal)
- optional width/height → complete Vega-Lite spec with tooltips + named data ref
(§06 Output). Field-type defaults from inferred column type.
**App**
- Chart Builder **modal** (config pane + live preview pane), launched from a
selected dataset; default pre-population (first col→X, second→Y); validation
(≥1 channel); Create Snippet → new linked snippet becomes active.
**Tests**
- Spec assembly: mark/channel/type permutations, unmapped channels omitted,
width/height inclusion, field-type derivation, validation gate.
**Manual checks**
- Build a bar chart from a dataset in a few clicks; preview live-updates;
Create → new snippet opens and renders.
**Council****FT Visual Vocabulary** + **Datawrapper** are now **seated** (chart-choice
canon): this is where Astrolabe stops being a pass-through JSON editor and starts making
chart-shaped suggestions/defaults, so "_which chart, and why_" becomes a decision the app
owns — the one thing Carbon's data-viz styling doesn't cover. Rather than a styling-only
seating, we ran a full **research-first** pass (FT + Datawrapper + the formal engines
**Draco** and **Voyager**), recorded in [`docs/exploration/chart-builder-research.md`](exploration/chart-builder-research.md),
and chose the **Tier B "smart + guarded"** design: smart default mark for the data shape,
valid-type-locked field-type menus, Size-channel discipline, and non-blocking guidance.
The convergent rules and citations live in that doc; the spec (§06) was amended to match.
**Verified:** `typecheck` + `test` green — the pure `chart-builder.ts` assembler
(`chart-builder.test.ts`: mark/channel/type permutations, unmapped-channel omission,
validation gate). The Chart Builder modal is wired through the registry, launched from a
selected dataset (`DatasetsModal``openModal('chartBuilder')`). Data-aware
cardinality/extent profiling + chart warnings landed (A3/A4).
---
## M4.5 · Snippet-library consolidation ✅ (done)
**Why out of band:** a spec-vs-implementation audit after M4 found §02 features that no
later milestone owned — the **Selected-Snippet Metadata Panel** (inline name + comment
editing, timestamps, linked datasets) and the **Duplicate** operation. Without them a
snippet could only ever carry its auto-generated date-time name (no rename, no annotation,
no copy), a sharp edge for a _snippet manager_. Closed before M5 since the spec text
already existed and the work was core-first and cheap.
**Core**
- `snippet.ts``duplicateSnippet(source, {now,id})`: independent copy carrying both spec
versions, comment, tags, and dataset refs; "(copy)" name; fresh identity/timestamps;
cloned mutable members.
**App**
- `SnippetStore``renameSnippet`, `setComment` (both advance `modified` per §02 → Sort,
no editor-buffer touch), `duplicateActiveSnippet` (flushes the live buffer first, prepends
the copy, makes it active).
- `SnippetLibrary` — the metadata panel below the list: Name + Comment auto-save (debounced
while typing, flushed on blur), read-only Created/Modified, Linked Datasets list, and
Duplicate / Delete. Duplicate raises a success toast (the copy isn't self-evident, unlike
Create); §02-compliant.
**Also fixed (§03C divergence):** the preview debounced _every_ change, so a snippet
load / Draft↔Published switch incurred a 300 ms blank instead of the spec's **immediate**
render. `LivePreview` now renders immediately on `bufferEpoch`/`editorView` change and
debounces only keystroke (`shownText`-only) changes.
**Deferred at the time to M5/M6 (per §02):** Search, Sort controls + persistence, two
distinct empty-state messages, Storage Monitor — all now delivered in M6.
**Verified:** `typecheck` + `test` (`snippet` duplicate factory,
`SnippetStore` rename/comment/duplicate, a `SnippetLibrary` render test guarding the
auto-save effect against a render loop) + `eslint` clean + `build` (PWA, 41 precache
entries).
---
## M5 · Settings + Import/Export ✅ (done)
**Goal:** preferences and whole-workspace backup/transfer.
**Core**
- `settings.ts` — UserSettings shape + defaults + load-with-fallback (§07, §09C);
unknown/missing values fall back silently.
- `import-normalize.ts` — accept envelope / bare array / single snippet / foreign
shapes; field mapping (`content`→spec, `draft`→draftSpec, `createdAt`→created);
tag `"imported"`; merge rules (append, id-collision reassign, dataset-name
auto-suffix, datasets-before-snippets) (§08).
- `export-envelope.ts` — build the `{version, exportedAt, exportedBy, snippets, datasets}` envelope.
**Infrastructure**
- `settings-store.ts` (localStorage): **extend** the minimal M1.5 adapter (which
already persists `ui.theme`) to the full UserSettings record; `ux-prefs` for sort
- panel layout (§09D).
**App**
- **Distributed settings** — not a modal (design review, see spec §07 + arch 10). Each
cluster is a per-pane disclosure popover that applies **live**: Editor settings in the
editor toolbar, render debounce in the preview, date format in the library; theme stays
the header toggle. A shared `SettingsPopover` primitive (gear + non-modal popover, APG
disclosure) backs all three. Wire render-debounce + editor options + date-format through.
- Header **Import**/**Export** (direct file dialog / download, no modal).
- Date formatting util (smart/iso/custom) used by the library list + metadata panel.
**Tests**
- Import normalization across all accepted shapes; merge/collision/rename logic;
quota-overage messaging path. Envelope round-trip (export→import idempotence).
- Settings load-with-fallback for partial/unknown records.
**Manual checks**
- Change theme/debounce/date-format → takes effect; Cancel reverts; Reset confirms.
- Export → reimport into a populated workspace merges without overwrite; renames reported.
**Verified:** `typecheck` + `test` green — `import-normalize` (accepted shapes + merge/
collision/rename), `export-envelope` (round-trip), `settings` (load-with-fallback),
`UserSettingsStore`, `ux-prefs`. Distributed per-pane settings shipped as `SettingsPopover`
disclosures (editor toolbar, preview, library) applying live; header Import/Export wired
through `services/transfer.ts` (→ `normalizeImport` / envelope build), no modal.
---
## M6 · Shell polish & non-functional ✅ (done)
**Goal:** the workspace feels finished and meets §10.
**Scope note — desktop/tablet, not phone.** Astrolabe is a desktop and (at best)
tablet tool; phones are out of scope. So the touch/installable surface we target is
**iPad add-to-home-screen**, not iPhone — which is why the one PNG icon is a 180×180
`apple-touch-icon` and we don't chase phone-specific viewport/layout work.
- **Panes:** ~~drag-resize handles with min widths; widths persist~~ ✅ (pulled
forward after M2). ~~Per-pane show/hide **toggle strip** + visibility persist +
proportional redistribution on hide (§01A, §09D)~~ ✅.
- **Routing:** ~~URL hash view-state (`#snippet-<id>`, `#datasets/...`) with Back/Forward;
restore on load (§01E)~~ ✅.
- **Shortcuts:** ~~Cmd/Ctrl+Shift+N / +K / +S / +, / Esc via a single key router
(§01D)~~ ✅.
- **Toasts:** ~~success/error/warning/info, stacking, auto-dismiss, reduced-motion;
appear/disappear with a brief fade (§01F)~~ ✅ (fade-out two-phase dismiss landed last).
- **Library search / sort / empty states** (§02, §09D): ~~live search across
name/comment/draft spec; Sort by Modified/Created/Name/Size with a disclosure +
flip-on-reselect, persisted to `astrolabe:ux-prefs`; the two distinct empty states~~ ✅
(council-guided — see [arch 10](architecture/10-interaction-and-feedback.md)).
- **Storage monitor** for the snippet tier ~~(§02)~~ ✅ (`role="meter"` fill bar,
escalating ok/warning/critical at 0.8/0.95).
- **Live-preview busy indicator** (§04, §10): ~~non-blocking overlay + `aria-busy` for
renders past ~1s~~ ✅.
- **Import atomicity** (§08): ~~roll back on storage-quota failure so no partial import is
committed; actionable error~~ ✅ (effective at the service boundary; true cross-record
IDB-transaction atomicity would require exposing a raw transaction from `db.ts` — see
[arch 02](architecture/02-persistence.md)).
- **A11y:** modal focus trap + return, labelled icon buttons, contrast in both themes (§10) — ✅ in place.
- **About** and **Support** modals ~~(§01)~~ ✅. The project solicits nothing for
itself — **Support** offers feedback to the author and redirects donations to Ukraine's
defense (`savelife.in.ua`, the Come Back Alive foundation), where the author forwards any
project donations anyway.
- **Offline/installable:** the manifest ships a full SVG icon set (favicon / maskable /
monochrome) + a 180×180 `apple-touch-icon.png` (iPad add-to-home-screen) + `theme_color`,
and the SW precaches the shell — the app is **installable**. ✅ Manual verification in a
running/installed app passed (checklist: [manual-verification.md](manual-verification.md)).
- **Council** — ~~seat **web.dev** for the PWA/offline/storage surfaces none of the seated
members cover: the service-worker **update-available** prompt (`registerType: 'prompt'`),
storage **persistence** (`navigator.storage.persist()`), and the quota **estimate**
(`StorageManager.estimate()`)~~ ✅ seated + backfilled (update-prompt toast + `persist()`
request; estimate already wired). ~~**⏳ Remaining gap:** manifest ships no icons → not yet
installable~~ ✅ SVG icon set added + wired (favicon / maskable / monochrome). See
[`/council`](../.claude/skills/council/SKILL.md) and
[arch 10](architecture/10-interaction-and-feedback.md).
**Manual checks:** ✅ keyboard-only run-through; reload restores view from URL;
offline reload works; install as standalone; reduced-motion honored — all verified in a
running/installed app, plus visual verification of the library/monitor/modals/busy-indicator
surfaces.
---
## Cross-cutting, do-as-you-go
- **Build to the design language:** the foundation lands in M1.5; from M2 on, every
new component uses the [Architecture 09](architecture/09-visual-design.md) tokens
and conventions — no placeholder styling, no raw hues. Staying on it is the
do-as-you-go part.
- **i18n** (optional, deferred): if translation is wanted, split a portable i18n
registry (no React) from the app-layer bindings, mirroring the `core``app`
boundary. M1M6 can ship English-only with date formatting locale-aware (§10).
Don't retrofit later if avoidable — keep user-facing strings centralized from M1.
- **Versioning:** simplified semver `0.x.y`, `package.json``__APP_VERSION__`
(already wired). **Not yet released publicly** — the working version stays pre-1.0 through
M0M6; the **first public release will be `1.0.0`**, cut on the maintainer's signal (don't
auto-bump in the meantime).
- **Docs trio:** keep `SOUL.md` / `AGENTS.md` / `CLAUDE.md` current as the app grows.
---
## Open items (carried from the exploration memos)
Deferred features whose reasoning lives in `docs/exploration/`; pulled here so the backlog
is in the maintained plan, not the archive.
**Chart theming** (`exploration/chart-theming-scope.md`):
- **Google Fonts opt-in CDN tier** — keyless catalog, opt-in only.
- **Theme↔font pairing metadata** — a suggestion nicety.
- **Built-in expressive theme preset gallery** — e.g. "Editorial", "Terminal", "Sketch".
- **Color panel swatch reorder** — the remaining slice-4b control (reorder a materialized
scheme's swatches).
**Chart Builder** (`exploration/chart-builder-enhancement-scope.md`):
- ~~**Open-in-builder (3C, strict hydration)**~~ ✅ shipped (2026-06-18): an editor-toolbar
**Open in builder** action reopens a builder-representable snippet to **edit it in place**
(`parseChartSpec` is the strict inverse of `buildChartSpec`, gated by re-assemble +
deep-compare). Only dataset-referencing specs hydrate (the builder's data model). See the
scope doc status log.
- **Builder starter examples (3B)** — a small set of curated starters, one per covered FT
intent, **authored in the builder dialect so they reopen via 3C**. Reshaped by 3C's
data-model edge: a builder-openable starter must reference a dataset, so 3B ships paired
sample datasets (or is reframed) — decide its shape now that the gate is live. Distinct from
the shipped onboarding gallery (`core/examples.ts``Onboarding.tsx`), which seeds an
inline-data snippet straight into the editor (Monaco-only, never the builder).
- ~~**Transform-aware data inspector** — show resolved post-transform rows~~ ✅ shipped: an
Input | Resolved data inspector below the Live Preview and Chart Builder charts, with a
draggable height divider (spec §04; arch 05 → "data inspector rides the boundary").
- **Field-chip drag-and-drop** — click/keyboard-first shipped; drag deferred.
- **Calculated-field autocomplete popup** — Monaco-style completion for expressions.
---
## Architecture reference
The **how** behind each milestone is documented self-containedly in
[`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) |
-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?
-69
View File
@@ -1,69 +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. 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. |
| 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.
-528
View File
@@ -1,528 +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.
---
## 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 };
}
// Defaults must match the authoritative spec §07 table exactly — that is the
// contract; this is just where it's 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.
-636
View File
@@ -1,636 +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/extract). */
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.
-516
View File
@@ -1,516 +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.
### 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,728 +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 the chart's input
vs. resolved rows — spec §04) reads runtime rows through the handle, never the raw view:
`RenderHandle.inspectData()` returns the input + resolved tables (`{ input, resolved }`,
or `null` when no chart is up), wrapping the view exactly like `toImageURL`. It works in
two layers:
- **Enumerate + pick (`view.getState` + `core/result-data`).** A compiled Vega dataflow
holds many named datasets; Vega-Lite names them by convention — `source_<n>` per parsed
source, `data_<n>` per transform stage. The pure `pickSourceDataset` / `pickResultDataset`
choose the **most-upstream source** (the input) and **most-downstream output** (what the
marks draw), skipping dataflow internals (`marks`, `root`, layout, selection `*_store`s).
A spec with no transforms resolves both to the same table. The picking is pure (in `core`,
unit-tested); only the enumeration touches the view.
- **Read lazily.** `getState` serializes the datasets it lists, so it is **only called while
the panel is open** — a collapsed inspector costs nothing, which is why the panel reads on
demand rather than on every render.
Limitation: one name per direction can't represent a multi-view spec (layer/concat/facet
produce several `data_<n>`); the most-downstream/upstream ones are returned, and a full
dataset selector is left as a future option.
---
## 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), `AppStore` (fit mode/theme), and `PreviewStore` (shared error). The
**Chart Builder modal** needs a preview of a _different_ spec source (its config), so it
does **not** reuse `LivePreview`; it runs its own small debounced render over the same
`chart-renderer.renderSpec` + `prepareSpecForRender`, with **local** error state (never the
shared `PreviewStore`, which would cross-talk with the editor). Two preview surfaces, one
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.
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 one error field the preview reads:
| Stage | Failure | Surfaced as |
| -------------------------------- | -------------------------------------- | ---------------------- |
| Parse | Invalid JSON | "Invalid JSON: …" |
| Prepare (`prepareSpecForRender`) | Referenced dataset missing/unfetchable | "Dataset not found: …" |
| Embed (`vega-embed`) | Vega-Lite compile / data error | "Rendering error: …" |
```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;
usePreviewStore.getState().setError(null);
return;
}
let parsed: unknown;
try {
parsed = JSON.parse(text);
} catch (e) {
usePreviewStore.getState().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);
usePreviewStore.getState().setError(null); // success clears any prior error
} catch (e) {
usePreviewStore
.getState()
.setError(
`Rendering error: ${(e as Error).message}. ` +
`Check your JSON syntax and that the spec is valid Vega-Lite.`,
);
}
}
```
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 (the underlying reason plus a hint
to check JSON/Vega-Lite validity), never a raw stack trace dump.
- **Do** distinguish the failing stage in the message (Invalid JSON vs Dataset
not found vs Rendering error).
- **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 | `PreviewStore.error` |
| 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,506 +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 Extracting referenced names from a spec (pure — `src/core/spec-refs.ts`)
A Vega-Lite spec can reference named data in several places: the top-level
`data`, per-layer `data`, `data` inside `spec`/`facet`/`hconcat`/`vconcat`, and a
lookup transform's `from.data`. A spec may also define its OWN inline datasets via
a top-level `datasets` map — those are self-defined, not library references.
Rather than enumerate Vega-Lite's grammar, we walk the spec recursively and
collect every `{ data: { name } }` — but **prune two keys**: never recurse into a
`data` object's payload (its `values`/rows) or the top-level `datasets` map,
because those hold user data, not nested specs. Without the prune, a data _row_
carrying a field literally named `data: { name: "x" }` is misread as a reference.
This is pure, deterministic, and the most heavily unit-tested function here.
```ts
// src/core/spec-refs.ts
type Json = unknown;
/** Collects every library dataset name referenced by `{ data: { name } }`, excluding self-defined ones. */
export function extractDatasetRefs(spec: Json): string[] {
const root = typeof spec === 'string' ? safeParse(spec) : spec;
const selfDefined = selfDefinedNames(root); // names from the spec's own top-level `datasets`
const names = new Set<string>();
const walk = (node: Json): void => {
if (Array.isArray(node)) {
for (const item of node) walk(item);
return;
}
if (node && typeof node === 'object') {
const obj = node as Record<string, Json>;
const data = obj.data as Record<string, Json> | undefined;
if (data && typeof data === 'object' && typeof data.name === 'string') {
if (!selfDefined.has(data.name)) names.add(data.name);
}
// Prune: a `data` payload and the `datasets` map hold user data, not refs.
for (const key of Object.keys(obj)) {
if (key === 'data' || key === 'datasets') continue;
walk(obj[key]);
}
}
};
walk(root);
return [...names];
}
function safeParse(s: string): Json {
try {
return JSON.parse(s);
} catch {
return null; // an unparseable draft simply has no resolvable refs
}
}
```
```ts
// src/core/spec-refs.ts — thin wrapper, run on every draft change and on publish
/** The list stored on snippet.datasetRefs. Sorted + de-duped for stable diffs. */
export function recomputeDatasetRefs(spec: Json): string[] {
return extractDatasetRefs(spec).sort();
}
```
> 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.
- Recompute and store `datasetRefs` on **every draft change and on publish**
but only through the parse-gated, debounced auto-save (`commitDraft`) and the
programmatic extract/revert rewrites, never on raw keystrokes. That keeps the
links in step with the edited draft while never recomputing from a
transiently-invalid spec.
- Prune the **same two keys** (`data`, `datasets`) in all three ref walks —
extraction here, `renameDatasetInSpec`, and the renderer's `resolveDatasetRefs`
(`src/core/rendering.ts`). They must agree on what counts as a reference; if one
descends into data payloads and another doesn't, extraction and rendering
disagree and a row field named `data` either gets counted, rewritten, or throws
`DatasetNotFoundError`.
**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 "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.
---
## 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 behavioral contract is 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,314 +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 contract 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.
---
## 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
-529
View File
@@ -1,529 +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) |
**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) |
**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 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,711 +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 → editor + preview) | No | Clears automatically when the cause is fixed | `PreviewStore`, surfaced in `SpecEditor` + `LivePreview` |
| **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.
- **The same failure can light up two channels.** An unrenderable spec shows the _same_
message inline in both the editor (§03E) and the preview (§04) — one producer
(`PreviewStore`), two subscribers. That's intentional, not duplication.
## 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 — 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 — one live region per shared message.** When the same error feeds two surfaces
(the §1 "one producer, two subscribers" case — render errors via `PreviewStore`), exactly
**one** subscriber is the live region (`role="alert"` on the editor, where focus is); the
other shows the text visually with no live role. Two live regions would announce the same
message twice.
**Resolved — 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.)_
---
## 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)".
- 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).
-54
View File
@@ -1,54 +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.
## 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.
## 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) |
Inside a `:::progression`, each `##` heading is a stage: heading → tab label, prose → note,
the following fenced `vega-lite` block → spec. Inline data repeats per fenced block — there
is no shared-data construct.
## The pipeline
`lessons/*.md``import.meta.glob` (in `LearnPage`) → `parseLesson` (`core/lesson-parse`) →
`LessonBlock[]``LearnPage` block dispatch → `Markdown` | `SpecProgression` | `LandingChart`.
`SpecProgression` renders each stage's spec with `formatSpec` (`core/json-format`) and
highlights what the stage changed with `changedLines` (`core/spec-diff`, an LCS line-diff).
## 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.
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).
-17
View File
@@ -1,17 +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.
@@ -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.
-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.
-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.
-130
View File
@@ -1,130 +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.
## 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.
-102
View File
@@ -1,102 +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.
- An **example gallery** of a few simple snippets showcasing distinct Vega-Lite capabilities (e.g. a bar chart, a time-series line, a scatter plot, a stacked area, a donut, a binned histogram). Each example shows a **live preview** of the chart and a one-line description.
- **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).
-87
View File
@@ -1,87 +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.
## 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. Inline Error Surface
When the spec cannot be parsed or cannot be rendered, the editor pane shows the problem clearly while keeping the user in place to fix it.
- When the spec is invalid JSON, or is valid JSON but fails to render as Vega-Lite (including an unresolved dataset reference), a clear, readable error message appears in the editor pane, near the editor area.
- The error message is plainly legible (monospaced, distinct from normal content) and conveys what went wrong.
- The editor remains fully usable while an error is shown, so the user can edit to fix it; the error clears automatically once a subsequent edit renders successfully.
- This is the editor-side error affordance only; how a valid spec is drawn lives in _Live Preview_.
## F. Extract Inline Data to a Dataset
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 authoritative data contract: an implementer recreating the app should store equivalent records with these fields and meanings. Types are given abstractly (string, number, boolean, ISO-timestamp string, string[], object, "JSON value") so they map onto any stack. "JSON value" means any valid JSON shape — object, array, string, number, boolean, or null.
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.
-32
View File
@@ -1,32 +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.
## 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) |
-30
View File
@@ -1,30 +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
- **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`.
## 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 (this file, etc.) are not part of the TS project —
// run them through the untyped ruleset only.
{
files: ['**/*.js'],
extends: [tseslint.configs.disableTypeChecked],
languageOptions: { globals: { ...globals.node } },
},
);
+77 -15
View File
@@ -1,15 +1,77 @@
<!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 — a local Vega-Lite studio</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/landing/main.tsx"></script>
</body>
</html>
<!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">
<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 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>
-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>
-9296
View File
File diff suppressed because it is too large Load Diff
-73
View File
@@ -1,73 +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",
"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",
"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",
"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"
}
}
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

+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);
}
-186
View File
@@ -1,186 +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), 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, organizing, and previewing Vega-Lite charts. Edit
JSON, see the chart update live, and keep a personal library of snippets with no
account, no server, and full offline support.
</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 entirely in your browser. Your snippets, datasets, and settings are stored
locally and never leave your machine.
</p>
<ul className={styles.list}>
<li>No account, no sign-in, no server-side storage.</li>
<li>The app runs no analytics or tracking no cookies, no telemetry, no profiling.</li>
<li>
The only outbound network requests are ones you create: URL-sourced datasets you add
yourself.
</li>
<li>After the first load, the app works fully 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,564 +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 }),
),
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();
});
});
-280
View File
@@ -1,280 +0,0 @@
/**
* Chart export the per-chart "Export" affordance in the Live Preview header
* (spec §08 Per-chart export). Distinct from the header's *workspace* Export
* (whole-library backup): this gets *one* chart out its spec to the clipboard or
* a `.vl.json` file, or its rendered image as PNG/SVG.
*
* It lives in the preview header on purpose: the image formats need the live Vega
* `view` (held by LivePreview), and "export this chart" reads most naturally beside
* the chart you're looking at. The spec actions export the *shown* text (draft or
* published), so every format matches what's on screen.
*
* A few options ride along, because export has real choices to make:
* - **Resolution** (PNG) a device-pixel-ratio-aware multiplier, so the default
* "1×" is already Retina-crisp (a naive 1× export looks soft on a 2× display).
* - **Background** the chart config is transparent (to show the pane colour),
* which would make a naive export transparent; default to the theme colour, with
* White and Transparent on offer.
* - **Referenced data** (shown only when the spec references saved datasets)
* inline the data so the exported spec renders standalone, or keep the reference.
*
* Widget: a **disclosure**, not an ARIA menu like `SettingsPopover`. The container
* is a labelled `group` of option controls + action `<button>`s (Tab/Shift+Tab move
* between them); the trigger carries `aria-expanded` + `aria-controls`; Esc closes
* and restores focus; an outside click closes. It shares the single-open popover
* registry, and is portaled to `<body>` (the panes clip their overflow).
*/
import { useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import { useShallow } from 'zustand/react/shallow';
import {
chartExportFilename,
inlineReferencedDatasets,
referencedDatasetNames,
} from '@core/chart-export';
import { DatasetNotFoundError } from '@core/rendering';
import { copyText, downloadJson, downloadUrl } from '../infrastructure/file-transfer';
import { usePopover } from '../hooks/usePopover';
import { useDatasetStore } from '../stores/DatasetStore';
import { notify } from '../stores/NotificationStore';
import { selectActiveSnippet, selectShownText, useSnippetStore } from '../stores/SnippetStore';
import { Button } from './Button';
import { Icon } from './Icon';
import { SegmentedControl, type SegmentedOption } from './SegmentedControl';
import { SettingRow } from './SettingsPopover';
import styles from './ChartExport.module.css';
/** Shared registry id (single popover open at a time across the panes). */
const POP_ID = 'chart-export';
/** Focus the first action button or option radio on open. */
const INITIAL_FOCUS = ['button, [role="radio"]'] as const;
type ScaleChoice = '1' | '2' | '3';
type BackgroundChoice = 'theme' | 'white' | 'transparent';
/** PNG resolution multipliers (× device pixel ratio — see ImageExportOptions). */
const SCALE_OPTIONS: ReadonlyArray<SegmentedOption<ScaleChoice>> = [
{ value: '1', label: '1×', title: '1× — matches your screen (Retina-aware)' },
{ value: '2', label: '2×', title: '2× — double resolution, for print or zoom' },
{ value: '3', label: '3×', title: '3× — triple resolution' },
];
const BACKGROUND_OPTIONS: ReadonlyArray<SegmentedOption<BackgroundChoice>> = [
{ value: 'theme', label: 'Theme', title: 'Match the current themes background' },
{ value: 'white', label: 'White', title: 'White background' },
{ value: 'transparent', label: 'None', title: 'Transparent background' },
];
const REFDATA_OPTIONS: ReadonlyArray<SegmentedOption<'inline' | 'ref'>> = [
{ value: 'inline', label: 'Inline', title: 'Embed the data so the file renders standalone' },
{
value: 'ref',
label: 'Keep refs',
title: 'Keep the dataset reference (renders only in Astrolabe)',
},
];
/** Resolve a background choice to a CSS colour, or null for transparent. The
* "theme" colour is read live from the page so it always tracks the active theme. */
function resolveBackground(choice: BackgroundChoice): string | null {
if (choice === 'transparent') return null;
if (choice === 'white') return '#ffffff';
const bg = getComputedStyle(document.documentElement).getPropertyValue('--bg').trim();
return bg || '#ffffff';
}
export interface ChartExportProps {
/** Whether a chart is currently rendered gates the image (PNG/SVG) actions.
* The spec actions only need text, so they ignore this. */
chartReady: boolean;
/** Rasterize/serialize the live view to a downloadable URL, or null if no view
* is live (the not-ready case). Rejects if rasterizing/serializing fails a real
* error the caller reports, not a not-ready state (caller owns the Vega view). */
getImageUrl: (
format: 'png' | 'svg',
options: { scale: number; background: string | null },
) => Promise<string | null>;
}
export function ChartExport({ chartReady, getImageUrl }: ChartExportProps) {
const { open, toggle, close, triggerRef, setPopNode } = usePopover({
id: POP_ID,
align: 'right',
initialFocus: INITIAL_FOCUS,
});
// Primitive reads only (no fresh objects) so the header doesn't re-render needlessly.
const name = useSnippetStore((s) => selectActiveSnippet(s)?.name ?? 'chart');
const shownText = useSnippetStore(selectShownText);
const datasets = useDatasetStore(useShallow((s) => s.datasets));
const hasSpec = shownText.trim() !== '';
// Export options, persisted while the component is mounted (across opens).
const [scale, setScale] = useState<ScaleChoice>('1');
const [background, setBackground] = useState<BackgroundChoice>('theme');
const [inline, setInline] = useState(true);
// Which saved datasets the shown spec references — drives the inline-data option.
const refs = useMemo(() => referencedDatasetNames(shownText), [shownText]);
/** The spec text to export inlined for portability when chosen and refs exist.
* Returns null after surfacing an error (a referenced dataset is missing). */
const buildSpecText = (): string | null => {
if (!inline || refs.length === 0) return shownText;
try {
return inlineReferencedDatasets(shownText, datasets);
} catch (e) {
const missing = e instanceof DatasetNotFoundError ? ` "${e.datasetName}"` : '';
notify({
kind: 'error',
title: 'Couldnt inline data',
message: `A referenced dataset${missing} isnt in your library. Add it, or choose “Keep refs”.`,
});
return null;
}
};
const copySpec = async () => {
close();
const text = buildSpecText();
if (text == null) return;
try {
await copyText(text);
notify({
kind: 'success',
title: 'Spec copied',
message: 'The charts Vega-Lite spec is on your clipboard as JSON.',
});
} catch {
notify({
kind: 'error',
title: 'Couldnt copy',
message: 'Your browser blocked clipboard access. Select and copy the spec from the editor.',
});
}
};
const downloadSpec = () => {
close();
const text = buildSpecText();
if (text == null) return;
const filename = chartExportFilename(name, 'vl.json');
downloadJson(filename, text);
notify({ kind: 'success', title: 'Spec downloaded', message: `Saved ${filename}.` });
};
const downloadImage = async (format: 'png' | 'svg') => {
close();
let url: string | null;
try {
url = await getImageUrl(format, {
scale: Number(scale),
background: resolveBackground(background),
});
} catch (err) {
// The view is live but rasterizing/serializing it threw — a real failure
// (e.g. a tainted canvas or an SVG the browser won't serialize), not a
// not-ready state. Surface it with its detail rather than burying it.
notify({
kind: 'error',
title: 'Couldnt export image',
message: `Astrolabe couldnt turn the chart into ${format.toUpperCase()}. This can happen with very large charts or content the browser wont serialize.`,
detail: err instanceof Error ? `${err.name}: ${err.message}` : String(err),
});
return;
}
if (!url) {
notify({
kind: 'error',
title: 'Couldnt export image',
message: 'The chart isnt ready yet. Wait for it to finish rendering, then try again.',
});
return;
}
const filename = chartExportFilename(name, format);
downloadUrl(filename, url);
notify({ kind: 'success', title: 'Chart exported', message: `Saved ${filename}.` });
};
return (
<div className={styles.wrap}>
<Button
ref={triggerRef}
variant="ghost"
aria-expanded={open}
aria-controls={POP_ID}
disabled={!hasSpec}
title={hasSpec ? 'Export this chart' : 'Select a snippet to export'}
onClick={toggle}
>
<Icon name="export" />
<span>Export</span>
</Button>
{open &&
createPortal(
<div
ref={setPopNode}
id={POP_ID}
className={styles.pop}
role="group"
aria-label="Export chart"
>
<h4 className={styles.groupTitle}>Spec</h4>
{refs.length > 0 && (
<SettingRow label="Referenced data">
<SegmentedControl
label="Referenced data"
options={REFDATA_OPTIONS}
value={inline ? 'inline' : 'ref'}
onChange={(v) => setInline(v === 'inline')}
/>
</SettingRow>
)}
<button type="button" className={styles.action} onClick={() => void copySpec()}>
Copy spec
</button>
<button type="button" className={styles.action} onClick={downloadSpec}>
Download JSON <span className={styles.ext}>.vl.json</span>
</button>
<h4 className={styles.groupTitle}>Image</h4>
<SettingRow label="Resolution">
<SegmentedControl
label="PNG resolution"
options={SCALE_OPTIONS}
value={scale}
onChange={setScale}
/>
</SettingRow>
<SettingRow label="Background">
<SegmentedControl
label="Background"
options={BACKGROUND_OPTIONS}
value={background}
onChange={setBackground}
/>
</SettingRow>
<button
type="button"
className={styles.action}
disabled={!chartReady}
onClick={() => void downloadImage('png')}
>
Download PNG
</button>
<button
type="button"
className={styles.action}
disabled={!chartReady}
onClick={() => void downloadImage('svg')}
>
Download SVG
</button>
{!chartReady && <p className={styles.hint}>Render the chart to export an image.</p>}
</div>,
document.body,
)}
</div>
);
}
@@ -1,82 +0,0 @@
/* Theme Builder Color panel structured controls over the draft config.
The container, section headings, and badges come from the shared Accordion
(ThemeFields); this module styles only the color-specific bits swatch rows,
gradient/scheme previews, and the dropdown-option previews. */
.hint {
margin: 0;
font-size: 12px;
color: var(--text-secondary);
}
.row {
display: flex;
align-items: center;
gap: var(--space-3);
flex-wrap: wrap;
}
.caret {
color: var(--text-secondary);
margin-left: var(--space-3);
}
/* Editable swatch rows (palette colors / gradient stops) wrap into a grid. */
.swatches {
display: flex;
align-items: center;
gap: var(--space-3) var(--space-4);
flex-wrap: wrap;
}
/* One swatch: a ColorField (swatch + hex) with its remove. */
.swatchUnit {
display: inline-flex;
align-items: center;
gap: var(--space-1);
}
/* The remove is quiet until the row is hovered or holds focus. */
.swatchRemove {
opacity: 0;
transition: opacity var(--dur-fast) var(--ease);
}
.swatchUnit:hover .swatchRemove,
.swatchUnit:focus-within .swatchRemove {
opacity: 1;
}
.gradientPreview {
width: 96px;
height: 24px;
flex: 0 0 auto;
border: var(--border-width) solid var(--border-strong);
}
/* Dropdown-option previews: a swatch strip (categorical) / gradient bar. */
.optStrip {
display: inline-flex;
width: 84px;
}
.optSwatch {
flex: 1 1 0;
height: 14px;
min-width: 4px;
}
.optGradient {
display: inline-block;
width: 84px;
height: 14px;
border: var(--border-width) solid var(--border);
}
/* Mini current-value preview inside a SelectControl trigger. */
.triggerPreview {
display: inline-flex;
margin-right: var(--space-2);
}
.triggerPreview .optSwatch {
width: 6px;
flex: 0 0 6px;
height: 14px;
}
-237
View File
@@ -1,237 +0,0 @@
/**
* Color panel behavioural wiring through the live modal. The pure config
* transforms are covered in core/theme-controls.test.ts; these confirm the
* controls read the draft config and write back through `mutateDraftConfig`
* (scheme pick, materialize, swatch add/remove, mark color, the JSON gate).
* vega-embed is mocked (the gallery is integration-heavy).
*/
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import type { JsonObject } from '@core/spec-config';
import { useCustomThemeStore } from '../stores/CustomThemeStore';
import { usePopoverStore } from '../stores/PopoverStore';
import { ThemeBuilderModal } from './ThemeBuilderModal';
vi.mock('../services/chart-renderer', () => ({
renderSpec: vi.fn(() =>
Promise.resolve({
destroy() {},
resize() {},
toImageURL: () => Promise.resolve(''),
inspectData: () => null,
}),
),
}));
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
vi.useFakeTimers();
useCustomThemeStore.getState().reset();
usePopoverStore.getState().close(); // the open-popover registry is global; isolate tests
container = document.createElement('div');
document.body.appendChild(container);
act(() => {
root = createRoot(container);
});
});
afterEach(() => {
act(() => root.unmount());
container.remove();
vi.useRealTimers();
});
const render = () => act(() => root.render(<ThemeBuilderModal />));
/** Open a draft seeded with `config` (block body so `act` returns void). */
const open = (config: JsonObject) =>
act(() => {
useCustomThemeStore.getState().createTheme('Brand', config);
});
const draftConfig = () => useCustomThemeStore.getState().draftConfig;
const range = () => (draftConfig()?.range ?? {}) as Record<string, unknown>;
/** Drive an input's value through the native setter so React's value tracker
* registers the change and fires onChange (a bare `input.value =` doesn't). */
function setNativeValue(el: HTMLInputElement, value: string) {
// eslint-disable-next-line @typescript-eslint/unbound-method -- invoked immediately via .call
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')!.set!;
setter.call(el, value);
el.dispatchEvent(new Event('input', { bubbles: true }));
}
/** A button anywhere in the document (popovers portal to <body>) by exact label. */
const button = (label: string) =>
[...document.querySelectorAll('button')].find((b) => b.textContent === label);
/** The trigger of a SelectControl by its `label`-derived aria-label prefix. */
const picker = (labelPrefix: string) =>
[...container.querySelectorAll('button')].find((b) =>
b.getAttribute('aria-label')?.startsWith(labelPrefix),
);
describe('ColorControls', () => {
test('materialize expands a named scheme into an editable array', () => {
render();
open({ range: { category: 'tableau10' } });
expect(range().category).toBe('tableau10');
act(() => button('Materialize to edit')!.click());
expect(Array.isArray(range().category)).toBe(true);
expect((range().category as string[]).length).toBe(10);
});
test('add and remove palette colors', () => {
render();
open({ range: { category: ['#111111', '#222222'] } });
act(() => button('Add color')!.click());
expect((range().category as string[]).length).toBe(3);
const remove = container.querySelector<HTMLButtonElement>(
'[aria-label="Remove Palette color 1"]',
)!;
act(() => remove.click());
expect(range().category).toEqual(['#222222', '#888888']);
});
test('removing the last swatch deletes range.category', () => {
render();
open({ range: { category: ['#111111'] } });
const remove = container.querySelector<HTMLButtonElement>(
'[aria-label="Remove Palette color 1"]',
)!;
act(() => remove.click());
expect('category' in range()).toBe(false);
});
test('setting a default mark color writes mark.color; clear removes it', () => {
render();
open({});
const input = container.querySelector<HTMLInputElement>(
'input[aria-label="Default mark color"]',
)!;
act(() => setNativeValue(input, '#ff0000'));
expect((draftConfig()?.mark as Record<string, unknown>).color).toBe('#ff0000');
act(() => button('Clear')!.click());
expect(draftConfig()?.mark).toBeUndefined();
});
test('typing in a swatch hex field updates that color', () => {
render();
open({ range: { category: ['#111111', '#222222'] } });
const hex = container.querySelector<HTMLInputElement>(
'input[aria-label="Palette color 1 hex value"]',
)!;
act(() => setNativeValue(hex, '#abcdef'));
expect((range().category as string[])[0]).toBe('#abcdef');
});
test('a sequential scheme materializes to editable stops written to heatmap and ramp', () => {
render();
// Seed the other families as arrays so the only "Materialize" button is the
// sequential one (categorical/diverging show "Add color"/"Add stop" instead).
open({
range: {
category: ['#111111'],
heatmap: 'viridis',
ramp: 'viridis',
diverging: ['#aa0000', '#0000aa'],
},
});
act(() => button('Materialize to edit')!.click());
expect(Array.isArray(range().heatmap)).toBe(true);
expect(range().ramp).toEqual(range().heatmap);
const remove = container.querySelector<HTMLButtonElement>(
'[aria-label="Remove Sequential stop 1"]',
)!;
const before = (range().heatmap as string[]).length;
act(() => remove.click());
expect((range().heatmap as string[]).length).toBe(before - 1);
expect(range().ramp).toEqual(range().heatmap);
});
test('picking a scheme writes range.category as a Vega scheme object', () => {
render();
open({});
act(() => picker('Categorical color scheme')!.click());
act(() => button('Category 10')!.click());
// The `{ scheme }` object — a bare scheme-name string is rejected at render.
expect(range().category).toEqual({ scheme: 'category10' });
});
test('a sequential pick sets both heatmap and ramp to the scheme object', () => {
render();
open({});
act(() => picker('Sequential color scheme')!.click());
act(() => button('Viridis')!.click());
expect(range().heatmap).toEqual({ scheme: 'viridis' });
expect(range().ramp).toEqual({ scheme: 'viridis' });
});
test('invalid JSON disables the controls', () => {
render();
open({});
act(() => useCustomThemeStore.getState().updateDraft({ configText: '{oops' }));
expect(container.textContent).toContain('Fix the JSON below to use these controls');
expect(button('Materialize to edit')).toBeUndefined();
});
test('the Type tab exposes the font-apply control', () => {
render();
open({});
const typeTab = [...container.querySelectorAll('button')].find(
(b) => b.getAttribute('role') === 'tab' && b.textContent === 'Type',
)!;
act(() => typeTab.click());
expect(picker('Font family')).toBeTruthy();
});
test('the raw JSON is collapsed by default and toggles open', () => {
render();
open({});
expect(container.querySelector('#theme-config')).toBeNull();
const toggle = container.querySelector<HTMLButtonElement>(
'button[aria-controls="theme-config"]',
)!;
expect(toggle.getAttribute('aria-expanded')).toBe('false');
act(() => toggle.click());
expect(container.querySelector('#theme-config')).toBeTruthy();
});
test('a parse error forces the JSON open', () => {
render();
open({});
expect(container.querySelector('#theme-config')).toBeNull();
act(() => useCustomThemeStore.getState().updateDraft({ configText: '{oops' }));
expect(container.querySelector('#theme-config')).toBeTruthy();
expect(
container
.querySelector('button[aria-controls="theme-config"]')!
.getAttribute('aria-expanded'),
).toBe('true');
});
});
-412
View File
@@ -1,412 +0,0 @@
/**
* Theme Builder Color panel (docs/chart-theming-scope.md §5).
*
* Structured controls over the draft config's color surface: the categorical
* palette (`range.category`), the default single-series mark color
* (`mark.color`), and the sequential/diverging gradients (`range.heatmap`+`ramp`
* and `range.diverging`). Each writes through `mutateDraftConfig` so the JSON
* and the gallery follow; reads come from the parsed draft config, so a JSON
* hand-edit reflects straight back into the controls.
*
* Color model (§5): every family holds either a named Vega scheme as the
* range-scheme object `{ scheme: name }` (compact, the quick path) or an explicit
* color array (custom tuning). Picking a scheme from the preview-bearing dropdown
* writes that object (a bare string is rejected by Vega at render); "Materialize"
* expands it to an editable array of swatches categorical, sequential, and
* diverging alike.
* Each swatch pairs the native color picker with a hex text field, so a value
* can be read, copied, and retyped anywhere.
*/
import type { ReactNode } from 'react';
import type { JsonObject } from '@core/spec-config';
import {
type ConfigPath,
countSet,
getConfigValue,
schemeColors,
schemesByKind,
} from '@core/theme-controls';
import { Button } from './Button';
import { ColorField } from './ColorField';
import { Icon } from './Icon';
import { IconButton } from './IconButton';
import { SelectControl, type SelectControlOption } from './SelectControl';
import { useConfigSetter } from '../stores/CustomThemeStore';
import { Accordion, type AccordionSection } from './ThemeFields';
import styles from './ColorControls.module.css';
const CATEGORY: ConfigPath = ['range', 'category'];
const MARK_COLOR: ConfigPath = ['mark', 'color'];
const HEATMAP: ConfigPath = ['range', 'heatmap'];
const RAMP: ConfigPath = ['range', 'ramp'];
const DIVERGING: ConfigPath = ['range', 'diverging'];
/** Vega-Lite's default mark color — shown as the starting value when unset. */
const VEGA_DEFAULT_MARK = '#4c78a8';
/** Seeded into a new swatch and the materialize-from-default paths. */
const NEW_SWATCH = '#888888';
const DEFAULT_CATEGORICAL = 'tableau10';
const DEFAULT_SEQUENTIAL = 'viridis';
const DEFAULT_DIVERGING = 'redblue';
/** Stops a materialized gradient starts with (continuous schemes are sampled). */
const GRADIENT_STOPS = 7;
const asArray = (v: unknown): string[] | null =>
Array.isArray(v) && v.every((c) => typeof c === 'string') ? v : null;
const asString = (v: unknown): string | null => (typeof v === 'string' ? v : null);
/**
* A named scheme is stored in a `range` family as Vega's range-scheme object
* `{ scheme: name }`. A bare scheme-name string compiles but is rejected by Vega
* at render ("Unrecognized scale range value: …"), blanking the chart so reads
* accept either the object or a legacy/hand-authored bare string, while writes
* (`schemeRange`) always use the object form.
*/
const asScheme = (v: unknown): string | null => {
if (typeof v === 'string') return v;
if (v && typeof v === 'object' && typeof (v as { scheme?: unknown }).scheme === 'string') {
return (v as { scheme: string }).scheme;
}
return null;
};
const schemeRange = (name: string): { scheme: string } => ({ scheme: name });
const gradientCss = (colors: string[]): string =>
colors.length ? `linear-gradient(90deg, ${colors.join(', ')})` : 'transparent';
// Dropdown previews, built once: a swatch strip for categorical, a gradient bar
// for the continuous families.
const swatchStrip = (colors: string[]): ReactNode => (
<span className={styles.optStrip}>
{colors.slice(0, 12).map((c, i) => (
<span key={i} className={styles.optSwatch} style={{ background: c }} />
))}
</span>
);
const gradientBar = (colors: string[]): ReactNode => (
<span className={styles.optGradient} style={{ background: gradientCss(colors) }} />
);
const CATEGORICAL_OPTIONS: SelectControlOption<string>[] = schemesByKind('categorical').map(
(s) => ({
value: s.name,
label: s.label,
preview: swatchStrip(schemeColors(s.name)),
}),
);
const SEQUENTIAL_OPTIONS: SelectControlOption<string>[] = schemesByKind('sequential').map((s) => ({
value: s.name,
label: s.label,
preview: gradientBar(schemeColors(s.name, 12)),
}));
const DIVERGING_OPTIONS: SelectControlOption<string>[] = schemesByKind('diverging').map((s) => ({
value: s.name,
label: s.label,
preview: gradientBar(schemeColors(s.name, 12)),
}));
/** A ColorField swatch with a hover/focus-revealed remove, for editable lists. */
function SwatchRow({
color,
label,
onChange,
onRemove,
}: {
color: string;
label: string;
onChange: (hex: string) => void;
onRemove?: () => void;
}) {
return (
<span className={styles.swatchUnit}>
<ColorField value={color} label={label} onChange={onChange} hex />
{onRemove && (
<IconButton
size="sm"
label={`Remove ${label}`}
className={styles.swatchRemove}
onClick={onRemove}
>
<Icon name="close" />
</IconButton>
)}
</span>
);
}
export function ColorControls({ config }: { config: JsonObject }) {
const set = useConfigSetter();
// Sequential color lives in two slots (heatmaps + continuous legends); keep them together.
const setSeq = (value: unknown) => {
set(HEATMAP, value);
set(RAMP, value);
};
const catValue = getConfigValue(config, CATEGORY);
const catArray = asArray(catValue);
const catScheme = asScheme(catValue);
const markColor = asString(getConfigValue(config, MARK_COLOR));
const seqValue = getConfigValue(config, HEATMAP);
const seqArray = asArray(seqValue);
const seqScheme = asScheme(seqValue);
const divValue = getConfigValue(config, DIVERGING);
const divArray = asArray(divValue);
const divScheme = asScheme(divValue);
/** Stops to drive a gradient preview for a family in any of its states. */
const previewStops = (array: string[] | null, scheme: string | null): string[] =>
array ?? (scheme ? schemeColors(scheme, 9) : []);
const sections: AccordionSection[] = [
{
id: 'categorical',
title: 'Categorical palette',
hint: 'Series colors — assigned to discrete categories in order.',
badge: countSet(config, [CATEGORY]),
children: (
<>
<div className={styles.row}>
<SelectControl
id="color-categorical-scheme"
label="Categorical color scheme"
heading="Color scheme"
options={CATEGORICAL_OPTIONS}
value={catScheme ?? undefined}
onSelect={(name) => set(CATEGORY, schemeRange(name))}
triggerContent={
<>
<span className={styles.triggerPreview} aria-hidden="true">
{(catArray ?? (catScheme ? schemeColors(catScheme) : []))
.slice(0, 6)
.map((c, i) => (
<span key={i} className={styles.optSwatch} style={{ background: c }} />
))}
</span>
<span>
{catArray ? `Custom (${catArray.length})` : (catScheme ?? 'Theme default')}
</span>
<span className={styles.caret} aria-hidden="true">
</span>
</>
}
triggerTitle="Pick a named palette"
/>
{catArray ? (
<Button variant="ghost" onClick={() => set(CATEGORY, [...catArray, NEW_SWATCH])}>
Add color
</Button>
) : (
<Button
variant="ghost"
onClick={() => set(CATEGORY, schemeColors(catScheme ?? DEFAULT_CATEGORICAL))}
>
Materialize to edit
</Button>
)}
</div>
{catArray && (
<div className={styles.swatches}>
{catArray.map((color, i) => (
<SwatchRow
key={i}
color={color}
label={`Palette color ${i + 1}`}
onChange={(hex) =>
set(
CATEGORY,
catArray.map((c, j) => (j === i ? hex : c)),
)
}
onRemove={() =>
set(
CATEGORY,
catArray.length === 1 ? undefined : catArray.filter((_, j) => j !== i),
)
}
/>
))}
</div>
)}
</>
),
},
{
id: 'markColor',
title: 'Default mark color',
hint: 'Single-series fill — bars, points, and lines with no color encoding.',
badge: countSet(config, [MARK_COLOR]),
children: (
<>
<div className={styles.row}>
<SwatchRow
color={markColor || VEGA_DEFAULT_MARK}
label="Default mark color"
onChange={(hex) => set(MARK_COLOR, hex)}
/>
{markColor ? (
<Button variant="ghost" onClick={() => set(MARK_COLOR, undefined)}>
Clear
</Button>
) : (
<span className={styles.hint}>Unset Vega default ({VEGA_DEFAULT_MARK})</span>
)}
</div>
</>
),
},
{
id: 'sequential',
title: 'Sequential gradient',
hint: 'Continuous color — heatmaps and quantitative legends.',
badge: countSet(config, [HEATMAP]),
children: (
<>
<div className={styles.row}>
<span
className={styles.gradientPreview}
aria-hidden="true"
style={{ background: gradientCss(previewStops(seqArray, seqScheme)) }}
/>
<SelectControl
id="color-sequential-scheme"
label="Sequential color scheme"
heading="Sequential scheme"
options={SEQUENTIAL_OPTIONS}
value={seqScheme ?? undefined}
onSelect={(name) => setSeq(schemeRange(name))}
triggerContent={
<>
<span>
{seqArray ? `Custom (${seqArray.length})` : (seqScheme ?? 'Theme default')}
</span>
<span className={styles.caret} aria-hidden="true">
</span>
</>
}
/>
{seqArray ? (
<Button variant="ghost" onClick={() => setSeq([...seqArray, NEW_SWATCH])}>
Add stop
</Button>
) : (
<Button
variant="ghost"
onClick={() =>
setSeq(schemeColors(seqScheme ?? DEFAULT_SEQUENTIAL, GRADIENT_STOPS))
}
>
Materialize to edit
</Button>
)}
{(seqArray || seqScheme) && (
<Button variant="ghost" onClick={() => setSeq(undefined)}>
Clear
</Button>
)}
</div>
{seqArray && (
<div className={styles.swatches}>
{seqArray.map((color, i) => (
<SwatchRow
key={i}
color={color}
label={`Sequential stop ${i + 1}`}
onChange={(hex) => setSeq(seqArray.map((c, j) => (j === i ? hex : c)))}
onRemove={() =>
setSeq(seqArray.length === 1 ? undefined : seqArray.filter((_, j) => j !== i))
}
/>
))}
</div>
)}
</>
),
},
{
id: 'diverging',
title: 'Diverging gradient',
hint: 'Two-ended color — values around a meaningful midpoint.',
badge: countSet(config, [DIVERGING]),
children: (
<>
<div className={styles.row}>
<span
className={styles.gradientPreview}
aria-hidden="true"
style={{ background: gradientCss(previewStops(divArray, divScheme)) }}
/>
<SelectControl
id="color-diverging-scheme"
label="Diverging color scheme"
heading="Diverging scheme"
options={DIVERGING_OPTIONS}
value={divScheme ?? undefined}
onSelect={(name) => set(DIVERGING, schemeRange(name))}
triggerContent={
<>
<span>
{divArray ? `Custom (${divArray.length})` : (divScheme ?? 'Theme default')}
</span>
<span className={styles.caret} aria-hidden="true">
</span>
</>
}
/>
{divArray ? (
<Button variant="ghost" onClick={() => set(DIVERGING, [...divArray, NEW_SWATCH])}>
Add stop
</Button>
) : (
<Button
variant="ghost"
onClick={() =>
set(DIVERGING, schemeColors(divScheme ?? DEFAULT_DIVERGING, GRADIENT_STOPS))
}
>
Materialize to edit
</Button>
)}
{(divArray || divScheme) && (
<Button variant="ghost" onClick={() => set(DIVERGING, undefined)}>
Clear
</Button>
)}
</div>
{divArray && (
<div className={styles.swatches}>
{divArray.map((color, i) => (
<SwatchRow
key={i}
color={color}
label={`Diverging stop ${i + 1}`}
onChange={(hex) =>
set(
DIVERGING,
divArray.map((c, j) => (j === i ? hex : c)),
)
}
onRemove={() =>
set(
DIVERGING,
divArray.length === 1 ? undefined : divArray.filter((_, j) => j !== i),
)
}
/>
))}
</div>
)}
</>
),
},
];
return <Accordion sections={sections} idPrefix="color" />;
}
-43
View File
@@ -1,43 +0,0 @@
/* ColorField — token-styled native color swatch (+ optional hex field). */
.field {
display: inline-flex;
align-items: center;
gap: var(--space-2);
}
/* Native color input, sized to a swatch and stripped of its OS chrome. */
.swatch {
width: var(--control-height);
height: var(--control-height);
padding: 0;
border: var(--border-width) solid var(--border-strong);
background: none;
cursor: pointer;
appearance: none;
-webkit-appearance: none;
}
/* Compact: sits inside the 24px-tall Chart Builder constant pill. */
.sm {
width: 28px;
height: 22px;
}
.swatch::-webkit-color-swatch-wrapper {
padding: 2px;
}
.swatch::-webkit-color-swatch {
border: none;
}
.swatch::-moz-color-swatch {
border: none;
}
.hex {
width: 8ch;
height: var(--control-height);
padding: 0 var(--space-2);
font-family: var(--font-mono);
font-size: 12px;
}
-80
View File
@@ -1,80 +0,0 @@
/**
* ColorField the shared color-input primitive (swatch + optional hex field).
* Covers the render shapes and the hex-entry commit/normalize behavior; the
* Theme Builder and Chart Builder integrations are exercised in their own tests.
*/
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { ColorField } from './ColorField';
(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);
act(() => {
root = createRoot(container);
});
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
/** Drive an input's value through the native setter so React's onChange fires. */
function setNativeValue(el: HTMLInputElement, value: string) {
// eslint-disable-next-line @typescript-eslint/unbound-method -- invoked immediately via .call
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')!.set!;
setter.call(el, value);
el.dispatchEvent(new Event('input', { bubbles: true }));
}
const swatch = () => container.querySelector<HTMLInputElement>('input[type="color"]')!;
const hexField = () => container.querySelector<HTMLInputElement>('input[type="text"]');
describe('ColorField', () => {
test('renders just the swatch by default (no hex field)', () => {
act(() => root.render(<ColorField value="#112233" label="Fill" onChange={() => {}} />));
expect(swatch().getAttribute('aria-label')).toBe('Fill');
expect(swatch().value).toBe('#112233');
expect(hexField()).toBeNull();
});
test('falls back to #000000 in the picker for a non-hex value', () => {
act(() => root.render(<ColorField value="steelblue" label="Fill" onChange={() => {}} />));
expect(swatch().value).toBe('#000000');
});
test('the picker reports its raw value on change', () => {
const onChange = vi.fn();
act(() => root.render(<ColorField value="#112233" label="Fill" onChange={onChange} />));
act(() => setNativeValue(swatch(), '#ff0000'));
expect(onChange).toHaveBeenCalledWith('#ff0000');
});
test('hex mode commits a valid hex and normalizes an unprefixed one', () => {
const onChange = vi.fn();
act(() => root.render(<ColorField value="#112233" label="Fill" onChange={onChange} hex />));
const hex = hexField()!;
expect(hex.getAttribute('aria-label')).toBe('Fill hex value');
act(() => setNativeValue(hex, '#abcdef'));
expect(onChange).toHaveBeenLastCalledWith('#abcdef');
act(() => setNativeValue(hex, 'ABCDEF'));
expect(onChange).toHaveBeenLastCalledWith('#abcdef');
});
test('hex mode does not commit an incomplete value', () => {
const onChange = vi.fn();
act(() => root.render(<ColorField value="#112233" label="Fill" onChange={onChange} hex />));
act(() => setNativeValue(hexField()!, '#abc'));
expect(onChange).not.toHaveBeenCalled();
});
});
-90
View File
@@ -1,90 +0,0 @@
/**
* ColorField the app's color input (arch 09 §5, component primitives).
*
* A token-styled native color swatch bound to a hex value, optionally paired
* with a copyable/editable hex text field. The single place the `type="color"`
* chrome reset and the hex-entry behavior live: the Theme Builder swatches
* (`hex`) and the Chart Builder constant-colour binding (`size="sm"`, no hex)
* both use it. Removal is the caller's concern it differs per context (a
* per-swatch button vs. the pill's own remove) so this renders only the
* input(s).
*/
import { useState } from 'react';
import styles from './ColorField.module.css';
/** True for a `#rrggbb` string — what the native picker accepts. */
function isHexColor(c: string): boolean {
return /^#[0-9a-f]{6}$/i.test(c);
}
/** `#rrggbb` (lowercased) from loose input, or null if not six hex digits. */
function normalizeHexColor(raw: string): string | null {
const v = raw.trim().replace(/^#/, '');
return /^[0-9a-f]{6}$/i.test(v) ? `#${v.toLowerCase()}` : null;
}
export interface ColorFieldProps {
value: string;
onChange: (hex: string) => void;
/** Accessible name for the swatch; the hex field derives its name from it. */
label: string;
/** Also render the editable, copyable hex text field beside the swatch. */
hex?: boolean;
/** `md` (32px, default) for forms; `sm` (compact) to sit inside a pill. */
size?: 'md' | 'sm';
/** Extra class merged onto the swatch (layout only — e.g. a pill's margin). */
className?: string;
}
export function ColorField({
value,
onChange,
label,
hex,
size = 'md',
className,
}: ColorFieldProps) {
// (hex field) Local text so a partially-typed hex isn't rejected mid-keystroke;
// commits on a valid value, reverts to the committed color on blur. Resync to a
// value changed from the outside (picker, materialize, scheme) but not to this
// field's own commit — the render-time adjustment is React's alternative to a
// sync effect.
const [text, setText] = useState(value);
const [synced, setSynced] = useState(value);
if (hex && value !== synced) {
setSynced(value);
if (normalizeHexColor(text) !== value) setText(value);
}
const swatch = (
<input
type="color"
className={`${styles.swatch} ${size === 'sm' ? styles.sm : ''} ${className ?? ''}`}
aria-label={label}
value={isHexColor(value) ? value : '#000000'}
onChange={(e) => onChange(e.target.value)}
/>
);
if (!hex) return swatch;
return (
<span className={styles.field}>
{swatch}
<input
type="text"
className={styles.hex}
aria-label={`${label} hex value`}
spellCheck={false}
value={text}
onChange={(e) => {
setText(e.target.value);
const norm = normalizeHexColor(e.target.value);
if (norm) onChange(norm);
}}
onBlur={() => setText(value)}
/>
</span>
);
}
@@ -1,56 +0,0 @@
.backdrop {
position: fixed;
inset: 0;
z-index: 1000; /* above the future feature-modal layer (discard-over-modal) */
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-5);
background: rgb(0 0 0 / 0.5);
}
.dialog {
width: 100%;
max-width: 28rem;
display: flex;
flex-direction: column;
gap: var(--space-4);
padding: var(--space-6);
background: var(--layer-01);
border: var(--border-width) solid var(--border);
border-radius: var(--radius);
/* Minimal shadow, reserved for true overlays (doc §09). */
box-shadow: 0 2px 12px rgb(0 0 0 / 0.3);
/* The card sits on --layer-01, so its controls' hover fill steps up (the
collision that once left Cancel looking dead arch 09 §4). */
--control-hover-fill: var(--layer-02);
}
.title {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--text);
}
.message {
margin: 0;
font-size: 14px;
line-height: 1.4;
color: var(--text-secondary);
}
.actions {
display: flex;
justify-content: flex-end;
gap: var(--space-3);
margin-top: var(--space-2);
}
/* The actions are shared Buttons (secondary / primary|danger, lg). These two
classes are pure markers the focus trap's initial-focus selectors. */
.cancel {
}
.confirm {
}
-61
View File
@@ -1,61 +0,0 @@
import { useConfirmStore } from '../stores/ConfirmStore';
import { useFocusTrap } from '../hooks/useFocusTrap';
import { Button } from './Button';
import styles from './ConfirmDialog.module.css';
/**
* Renders the active confirmation request from ConfirmStore (one global
* instance, mounted once at the app root). The in-app replacement for
* `window.confirm` see docs/architecture/03 "Confirmation & alert dialogs".
*
* Dismissal follows Carbon's transactional-modal rule: the user must pick an
* action. Escape and the Cancel button resolve `false`; a backdrop click does
* NOT dismiss (unlike passive feature modals) so a destructive choice is never
* made by an accidental outside click. For `danger` requests, focus defaults to
* Cancel so a stray Enter can't destroy anything.
*/
export function ConfirmDialog() {
const request = useConfirmStore((s) => s.request);
const resolve = useConfirmStore((s) => s.resolve);
// Focus Cancel first for destructive prompts, the primary action otherwise.
const initialFocus = request?.danger ? `.${styles.cancel}` : `.${styles.confirm}`;
const trapRef = useFocusTrap<HTMLDivElement>(request !== null, initialFocus);
if (!request) return null;
const { title, message, confirmLabel, cancelLabel, danger } = request;
return (
<div className={styles.backdrop} onKeyDown={(e) => e.key === 'Escape' && resolve(false)}>
<div
ref={trapRef}
className={styles.dialog}
role="alertdialog"
aria-modal="true"
aria-labelledby="confirm-title"
aria-describedby="confirm-message"
>
<h2 id="confirm-title" className={styles.title}>
{title}
</h2>
<p id="confirm-message" className={styles.message}>
{message}
</p>
<div className={styles.actions}>
<Button size="lg" className={styles.cancel} onClick={() => resolve(false)}>
{cancelLabel ?? 'Cancel'}
</Button>
<Button
size="lg"
variant={danger ? 'danger' : 'primary'}
className={styles.confirm}
onClick={() => resolve(true)}
>
{confirmLabel ?? 'Confirm'}
</Button>
</div>
</div>
</div>
);
}
@@ -1,68 +0,0 @@
/* Data inspector the resolved-data disclosure below the chart (spec §04).
Mirrors the builder's source-preview styling (tokens only) so the two read as
one family. In the live preview it is the preview pane's last child, set off
from the chart body by a top border. */
.inspector {
flex: 0 0 auto;
display: flex;
flex-direction: column;
gap: var(--space-2);
padding: var(--space-2) var(--space-4);
border-top: var(--border-width) solid var(--border);
background: var(--bg);
}
/* Resizable-height mode (live preview): the inspector is given an explicit height
by the pane's divider; the DataTable's own fill mode makes its rows fill the
space left under the bar. */
.inspector.fill {
overflow: hidden;
}
/* The header row: disclosure toggle, the Input|Resolved switch, the row count. */
.bar {
display: flex;
align-items: center;
gap: var(--space-3);
flex: 0 0 auto;
}
.toggle {
display: inline-flex;
align-items: center;
gap: var(--space-2);
border: none;
background: transparent;
color: var(--text-secondary);
font: inherit;
font-size: 12px;
cursor: pointer;
padding: var(--space-1) var(--space-2);
border-radius: var(--radius);
}
.toggle:hover {
background: var(--layer-01);
}
.toggle:focus-visible {
outline: 2px solid var(--focus);
outline-offset: 1px;
}
.caret {
font-size: 10px;
color: var(--text-placeholder);
}
.meta {
color: var(--text-placeholder);
}
.stateNote {
margin: 0;
font-size: 11px;
color: var(--text-secondary);
padding: var(--space-1) var(--space-2);
}
-152
View File
@@ -1,152 +0,0 @@
/**
* Data inspector input vs. resolved views (spec §04).
*
* `DataInspectorPanel` is prop-driven (rows come via `getData`, not the live
* view), so these cover the behaviour without the render pipeline: the lazy read,
* the Input | Resolved switch, the per-view empty/guidance states, truncation, the
* re-read on `renderEpoch`, and the toggle. `DataInspector` is the thin
* AppStore-bound wrapper.
*/
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import type { InspectedData } from '../services/chart-renderer';
import { useAppStore } from '../stores/AppStore';
import { DataInspector, DataInspectorPanel } from './DataInspector';
(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();
vi.clearAllMocks();
});
const toggle = () => container.querySelector<HTMLButtonElement>('button[aria-expanded]')!;
const text = () => container.textContent ?? '';
const viewButton = (label: string) =>
Array.from(container.querySelectorAll<HTMLButtonElement>('[role="radio"]')).find(
(b) => b.textContent === label,
)!;
const render = (props: Partial<Parameters<typeof DataInspectorPanel>[0]> = {}) =>
act(() => {
root.render(
<DataInspectorPanel
open
onToggle={() => {}}
getData={() => null}
renderEpoch={0}
{...props}
/>,
);
});
describe('DataInspectorPanel', () => {
test('collapsed by default: no table, no view switch, and getData is not read (lazy)', () => {
const getData = vi.fn(() => null);
render({ open: false, getData });
expect(toggle().getAttribute('aria-expanded')).toBe('false');
expect(container.querySelector('table')).toBeNull();
expect(container.querySelector('[role="radiogroup"]')).toBeNull();
expect(getData).not.toHaveBeenCalled();
});
test('open with no live chart: guidance to render one', () => {
render({ getData: () => null });
expect(text()).toContain('Render a chart');
expect(container.querySelector('table')).toBeNull();
});
test('defaults to the Resolved view and renders its rows', () => {
const data: InspectedData = {
input: [{ region: 'West', sales: '1204' }],
resolved: [{ region: 'West', total: 1204 }],
};
render({ getData: () => data });
// Resolved is the default — its column ("total"), not the input's ("sales").
const headers = Array.from(container.querySelectorAll('th')).map((th) => th.textContent);
expect(headers).toEqual(['region', 'total']);
});
test('switching to Input shows the source rows', () => {
const data: InspectedData = {
input: [
{ region: 'West', sales: '1204' },
{ region: 'East', sales: '980' },
],
resolved: [{ region: 'West', total: 1204 }],
};
render({ getData: () => data });
act(() => viewButton('Input').click());
const headers = Array.from(container.querySelectorAll('th')).map((th) => th.textContent);
expect(headers).toEqual(['region', 'sales']);
expect(container.querySelectorAll('tbody tr')).toHaveLength(2);
expect(text()).toContain('2 rows');
});
test('resolved empty: names the empty-transform signal', () => {
render({ getData: () => ({ input: [{ a: 1 }], resolved: [] }) });
expect(text()).toContain('left nothing to draw');
});
test('input empty: names the empty source', () => {
render({ getData: () => ({ input: [], resolved: [] }) });
act(() => viewButton('Input').click());
expect(text()).toContain('source data has no rows');
});
test('caps the table and reports the total', () => {
const resolved = Array.from({ length: 120 }, (_, i) => ({ i }));
render({ getData: () => ({ input: [], resolved }) });
expect(container.querySelectorAll('tbody tr')).toHaveLength(50);
expect(text()).toContain('first 50 of 120');
});
test('re-reads getData when renderEpoch changes', () => {
const getData = vi.fn((): InspectedData => ({ input: [], resolved: [{ a: 1 }] }));
render({ getData, renderEpoch: 0 });
const before = getData.mock.calls.length;
render({ getData, renderEpoch: 1 });
expect(getData.mock.calls.length).toBeGreaterThan(before);
});
test('applies an explicit height when given (resizable mode)', () => {
render({ getData: () => ({ input: [], resolved: [{ a: 1 }] }), heightPx: 240 });
const panel = container.firstElementChild as HTMLElement;
expect(panel.style.height).toBe('240px');
});
test('toggle reports the next open state', () => {
const onToggle = vi.fn();
render({ open: false, onToggle });
act(() => toggle().click());
expect(onToggle).toHaveBeenCalledWith(true);
});
});
describe('DataInspector', () => {
test('binds the panel to the persisted AppStore open state', () => {
act(() => useAppStore.getState().setDataInspectorOpen(false));
act(() => {
root.render(<DataInspector getData={() => null} renderEpoch={0} />);
});
expect(toggle().getAttribute('aria-expanded')).toBe('false');
act(() => useAppStore.getState().setDataInspectorOpen(true));
expect(toggle().getAttribute('aria-expanded')).toBe('true');
act(() => toggle().click());
expect(useAppStore.getState().dataInspectorOpen).toBe(false);
});
});
-176
View File
@@ -1,176 +0,0 @@
/**
* Data inspector input vs. resolved rows (spec §04).
*
* Shows the chart's data with a toggle between two views of the same rendered
* view: **Input** (the parsed source rows, before the spec's transforms) and
* **Resolved** (the rows the chart draws, after filters / calculated fields /
* aggregation). Seeing input output side by side is how you answer "why is my
* chart empty/wrong" look at what the transforms did to the data. Both tables
* come from the live Vega view via the renderer's `RenderHandle.inspectData()`
* accessor, passed in as `getData` so this component never touches the view (the
* embedding boundary, arch 05).
*
* `DataInspectorPanel` is the reusable shape (an APG disclosure, mirroring the
* builder's source-rows preview); `DataInspector` binds it to the persisted
* preview-pane open state for the live-preview pane. The data is read lazily
* only while expanded because listing the view's datasets serializes them (see
* `RenderHandle.inspectData`), so a collapsed inspector costs nothing.
*/
import { useMemo, useState } from 'react';
import type { InspectedData } from '../services/chart-renderer';
import { useAppStore } from '../stores/AppStore';
import { DataTable } from './DataTable';
import { SegmentedControl, type SegmentedOption } from './SegmentedControl';
import styles from './DataInspector.module.css';
/** Rows shown before truncating — matches the builder's source-preview cap (spec §06). */
const ROW_LIMIT = 50;
type DataView = 'input' | 'resolved';
/** The two views, in input → output order (the natural reading direction). */
const VIEW_OPTIONS: ReadonlyArray<SegmentedOption<DataView>> = [
// `title` doubles as the accessible name (WCAG 2.5.3 label-in-name; leads with
// the visible label).
{ value: 'input', label: 'Input', title: 'Input — the source rows before the specs transforms' },
{
value: 'resolved',
label: 'Resolved',
title: 'Resolved — the rows the chart draws, after its transforms',
},
];
interface DataInspectorPanelProps {
/** Whether the panel is expanded. */
open: boolean;
/** Toggle the expanded state. */
onToggle: (open: boolean) => void;
/**
* Reads the input + resolved rows from the live view, or null when there is no
* chart to inspect. Must be stable across renders (the read is memoized on
* `renderEpoch`).
*/
getData: () => InspectedData | null;
/** Bumps whenever a render settles, so the open table re-reads the new rows. */
renderEpoch: number;
/**
* Explicit panel height (px) the live-preview pane sets this from its
* resizable divider so the table fills the allotted space. Omitted (the builder)
* leaves the table at its default capped height.
*/
heightPx?: number;
/** id of the panel root, for a splitter's `aria-controls` to point at. */
id?: string;
}
/**
* The reusable disclosure: a toggle bar (APG disclosure `aria-expanded`,
* conditional content, matching the builder's source-rows preview) over an
* Input | Resolved view switch and the chosen table. Expanded states, each named
* (council: GOV.UK / NN/g say what happened and the next step):
* - no live chart guidance to render one;
* - the chosen view's table is empty → say which side and why (the resolved side's
* transforms produced nothing; the input side's source is empty);
* - rows the grid.
*/
export function DataInspectorPanel({
open,
onToggle,
getData,
renderEpoch,
heightPx,
id,
}: DataInspectorPanelProps) {
const [view, setView] = useState<DataView>('resolved');
// Read both tables only while open; re-read when a render settles. `renderEpoch`
// is an intentional refresh trigger — not read in the body (getData is stable and
// always reads the latest view), so exhaustive-deps sees it as unnecessary.
// eslint-disable-next-line react-hooks/exhaustive-deps
const data = useMemo(() => (open ? getData() : null), [open, renderEpoch, getData]);
const rows = data === null ? null : data[view];
return (
<div
id={id}
className={`${styles.inspector} ${heightPx !== undefined ? styles.fill : ''}`}
style={heightPx !== undefined ? { height: heightPx } : undefined}
>
<div className={styles.bar}>
<button
type="button"
className={styles.toggle}
aria-expanded={open}
onClick={() => onToggle(!open)}
>
<span className={styles.caret} aria-hidden="true">
{open ? '▾' : '▸'}
</span>
Data
</button>
{open && (
<>
<SegmentedControl
label="Data view"
options={VIEW_OPTIONS}
value={view}
onChange={setView}
/>
{rows !== null && rows.length > 0 && (
<span className={styles.meta}>
{rows.length.toLocaleString()} {rows.length === 1 ? 'row' : 'rows'}
</span>
)}
</>
)}
</div>
{open &&
(rows === null ? (
<p className={styles.stateNote}>Render a chart to inspect its data.</p>
) : rows.length === 0 ? (
<p className={styles.stateNote}>
{view === 'resolved'
? 'No rows — the specs filters or transforms left nothing to draw.'
: 'The source data has no rows.'}
</p>
) : (
<DataTable
columns={Object.keys(rows[0])}
rows={rows.slice(0, ROW_LIMIT)}
total={rows.length}
ariaLabel="Data rows"
fill={heightPx !== undefined}
/>
))}
</div>
);
}
interface DataInspectorProps {
/** Reads the input + resolved rows from the live preview's view (stable). */
getData: () => InspectedData | null;
/** Bumps on each settled render so the open table refreshes. */
renderEpoch: number;
/** Panel height (px) from the preview pane's resizable divider (only when open). */
heightPx?: number;
/** id of the panel root, for the divider's `aria-controls`. */
id?: string;
}
/** The live-preview data inspector: the panel bound to the persisted open state. */
export function DataInspector({ getData, renderEpoch, heightPx, id }: DataInspectorProps) {
const open = useAppStore((s) => s.dataInspectorOpen);
const setOpen = useAppStore((s) => s.setDataInspectorOpen);
return (
<DataInspectorPanel
open={open}
onToggle={setOpen}
getData={getData}
renderEpoch={renderEpoch}
heightPx={heightPx}
id={id}
/>
);
}
-62
View File
@@ -1,62 +0,0 @@
/* Shared read-only data table the data inspector and the Chart Builder's
source-row preview both render through this, so the look stays in one place.
Tokens only. */
.wrap {
max-height: 200px;
overflow: auto;
border: var(--border-width) solid var(--border);
border-radius: var(--radius);
}
.wrap:focus-visible {
outline: 2px solid var(--focus);
outline-offset: -1px;
}
/* Resizable-height mode (the live-preview inspector): fill the allotted space
instead of the default cap, so the divider grows the visible table. */
.wrapFill {
flex: 1 1 auto;
max-height: none;
min-height: 0;
}
.table {
border-collapse: collapse;
width: 100%;
font-family: var(--font-mono);
font-size: 11px;
line-height: 1.4;
}
.table th,
.table td {
text-align: left;
padding: var(--space-1) var(--space-2);
border-bottom: var(--border-width) solid var(--border);
white-space: nowrap;
max-width: 180px;
overflow: hidden;
text-overflow: ellipsis;
}
.table thead th {
position: sticky;
top: 0;
z-index: 1;
background: var(--layer-01);
font-weight: 600;
color: var(--text-secondary);
}
.table tbody tr:last-child td {
border-bottom: none;
}
.note {
margin: 0;
font-size: 11px;
color: var(--text-secondary);
padding: var(--space-1) var(--space-2);
}
-68
View File
@@ -1,68 +0,0 @@
/**
* DataTable a read-only, scrollable table of rows, the shared shape behind the
* data inspector (input/resolved rows) and the Chart Builder's source-row preview.
*
* Columns and any per-column header adornment are the caller's: the builder passes
* its declared column list with a type chip in each header; the inspector passes the
* row keys with plain names. This owns the table structure, the "first N of M" note,
* and the styling, so the two surfaces never drift. Capping is the caller's policy
* it passes the rows to show plus the true `total`.
*/
import type { ReactNode } from 'react';
import { cellText } from '@core/dataset';
import styles from './DataTable.module.css';
interface DataTableProps {
/** Column names, in display order. Cells read each row by these keys. */
columns: readonly string[];
/** The rows to render — already limited to what should be shown. */
rows: readonly Record<string, unknown>[];
/** The true row count; when it exceeds `rows`, a "first N of M" note is shown. */
total?: number;
/** Custom header content per column (e.g. a type chip); defaults to the name. */
renderHeader?: (column: string) => ReactNode;
/** Accessible name for the scroll region. */
ariaLabel: string;
/** Fill the available height instead of the default capped height (resizable panes). */
fill?: boolean;
}
export function DataTable({ columns, rows, total, renderHeader, ariaLabel, fill }: DataTableProps) {
return (
<>
<div
className={`${styles.wrap} ${fill ? styles.wrapFill : ''}`}
tabIndex={0}
role="group"
aria-label={ariaLabel}
>
<table className={styles.table}>
<thead>
<tr>
{columns.map((col) => (
<th key={col} scope="col">
{renderHeader ? renderHeader(col) : col}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, ri) => (
<tr key={ri}>
{columns.map((col) => (
<td key={col}>{cellText(row[col])}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
{total != null && total > rows.length && (
<p className={styles.note}>
Showing the first {rows.length} of {total.toLocaleString()} rows.
</p>
)}
</>
);
}
-438
View File
@@ -1,438 +0,0 @@
/* Datasets manager — two-pane body inside the modal shell (spec §05 → Layout). */
.manager {
display: grid;
grid-template-columns: 260px 1fr;
min-height: 0;
height: 100%;
}
/* --- List pane --- */
.listPane {
display: flex;
flex-direction: column;
min-height: 0;
border-right: var(--border-width) solid var(--border);
}
/* A shared primary Button (lg); locally just pinned with a margin. */
.newButton {
flex: 0 0 auto;
margin: var(--space-4);
}
.list {
list-style: none;
margin: 0;
padding: 0;
flex: 1 1 auto;
min-height: 0;
overflow: auto;
border-top: var(--border-width) solid var(--border);
}
.empty {
color: var(--text-secondary);
font-size: 13px;
line-height: 1.4;
padding: var(--space-5) var(--space-4);
}
.item {
display: flex;
align-items: center;
gap: var(--space-3);
padding: 0 var(--space-4);
border-left: 2px solid transparent;
transition: background var(--dur-fast) var(--ease);
}
.item + .item {
border-top: var(--border-width) solid var(--border);
}
.item:hover {
background: var(--layer-01);
}
.itemActive {
background: var(--layer-01);
border-left-color: var(--accent);
}
.itemMain {
flex: 1 1 auto;
min-width: 0;
display: flex;
flex-direction: column;
gap: var(--space-1);
appearance: none;
border: none;
background: none;
padding: var(--space-3) 0;
font: inherit;
color: inherit;
text-align: left;
cursor: pointer;
}
.itemName {
font-size: 13px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.itemMeta {
font-size: 11px;
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Usage badge — count of referencing snippets (spec §05 → List item). */
.badge {
flex: 0 0 auto;
min-width: 18px;
height: 18px;
padding: 0 var(--space-2);
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 600;
color: var(--text-secondary);
background: var(--layer-02);
border-radius: var(--radius);
}
/* --- Detail pane --- */
.detailPane {
min-height: 0;
overflow: auto;
}
.detailEmpty {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--text-secondary);
font-size: 14px;
padding: var(--space-6);
text-align: center;
}
.detail,
.form {
display: flex;
flex-direction: column;
gap: var(--space-5);
padding: var(--space-5);
}
.detailHead {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-4);
flex-wrap: wrap;
}
.detailName {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--text);
word-break: break-word;
}
.detailActions,
.formActions {
display: flex;
gap: var(--space-3);
flex-wrap: wrap;
}
.formActions {
justify-content: flex-end;
}
/* Actions are shared Buttons (secondary / primary / danger-outline). */
.comment {
margin: 0;
font-size: 13px;
line-height: 1.4;
color: var(--text-secondary);
}
/* URL dataset provenance: the source address + last-fetched time (spec §05). */
.sourceMeta {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: var(--space-2) var(--space-4);
margin: 0;
font-size: 12px;
color: var(--text-secondary);
}
.sourceLink {
color: var(--accent);
text-decoration: none;
overflow-wrap: anywhere;
}
.sourceLink:hover {
text-decoration: underline;
}
.sourceLink:focus-visible {
outline: 2px solid var(--focus);
outline-offset: 2px;
}
.section {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.sectionTitle {
margin: 0;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--text-secondary);
}
.stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(80px, 1fr));
gap: var(--space-4);
margin: 0;
}
.stats div {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.stats dt {
font-size: 11px;
color: var(--text-secondary);
}
.stats dd {
margin: 0;
font-size: 14px;
font-weight: 500;
color: var(--text);
}
/* Two-up multi-column flow (min 220px per column, so a narrow pane falls back
to one): a wide schema reads down-then-over and a 25-field dataset no longer
buries the preview table below a full-screen scroll (NN/g #8;
docs/ux-second-pass.md resolution 2026-06-13). */
.columns {
list-style: none;
margin: 0;
padding: 0;
columns: 220px 2;
column-gap: 0;
column-rule: var(--border-width) solid var(--border);
border: var(--border-width) solid var(--border);
}
.column {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
padding: var(--space-2) var(--space-3);
break-inside: avoid;
/* Hairline between rows; the negative margin tucks each column's first border
under the container border (a `+`-sibling border would double there). */
border-top: var(--border-width) solid var(--border);
margin-top: calc(-1 * var(--border-width));
}
.columnName {
font-size: 13px;
font-family: var(--font-mono);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.columnType {
flex: 0 0 auto;
font-size: 11px;
color: var(--text-secondary);
background: var(--layer-01);
padding: var(--space-1) var(--space-2);
border-radius: var(--radius);
}
.timestamps {
display: flex;
flex-wrap: wrap;
gap: var(--space-4);
font-size: 11px;
color: var(--text-secondary);
}
.preview {
margin: 0;
max-height: 220px;
overflow: auto;
padding: var(--space-3);
background: var(--layer-01);
border: var(--border-width) solid var(--border);
border-radius: var(--radius);
font-family: var(--font-mono);
font-size: 12px;
line-height: 1.5;
white-space: pre;
}
/* Tabular preview — a scrollable grid with a sticky header (spec §05 → Preview). */
.previewTableWrap {
max-height: 260px;
overflow: auto;
border: var(--border-width) solid var(--border);
border-radius: var(--radius);
}
.previewTable {
border-collapse: collapse;
width: 100%;
font-family: var(--font-mono);
font-size: 12px;
line-height: 1.4;
}
.previewTable th,
.previewTable td {
text-align: left;
padding: var(--space-2) var(--space-3);
border-bottom: var(--border-width) solid var(--border);
white-space: nowrap;
max-width: 240px;
overflow: hidden;
text-overflow: ellipsis;
}
.previewTable thead th {
position: sticky;
top: 0;
z-index: 1;
background: var(--layer-01);
font-weight: 600;
color: var(--text-secondary);
}
.previewTable tbody tr:last-child td {
border-bottom: none;
}
.muted {
margin: 0;
font-size: 13px;
color: var(--text-secondary);
}
.linked {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.linkButton {
appearance: none;
border: none;
background: none;
padding: 0;
font: inherit;
font-size: 13px;
color: var(--accent);
cursor: pointer;
text-align: left;
}
.linkButton:hover {
text-decoration: underline;
}
.linkButton:focus-visible {
outline: 2px solid var(--focus);
outline-offset: 2px;
}
/* --- Create / edit form --- */
.field {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.label {
font-size: 12px;
font-weight: 500;
color: var(--text-secondary);
}
.input,
.textarea {
width: 100%;
padding: var(--space-3);
font-size: 13px;
}
.textarea {
font-family: var(--font-mono);
resize: vertical;
min-height: 160px;
}
.detected {
display: flex;
align-items: center;
gap: var(--space-3);
margin-top: calc(-1 * var(--space-2));
}
.detectedBadge {
font-size: 11px;
font-weight: 600;
color: var(--text);
background: var(--layer-02);
padding: var(--space-1) var(--space-3);
border-radius: var(--radius);
}
.detectedHint {
font-size: 11px;
color: var(--text-secondary);
}
.formError {
margin: 0;
font-size: 13px;
color: var(--support-error);
}
/* The inline-fallback recovery under a failed URL fetch left-aligned, not
stretched, so it reads as a secondary recovery beneath the error message. */
.fallback {
align-self: flex-start;
margin-top: calc(-1 * var(--space-2));
}
-574
View File
@@ -1,574 +0,0 @@
/**
* Datasets manager the modal body (spec §05).
*
* A two-pane manager rendered inside the shared modal shell (App provides the
* backdrop, header, close, and focus trap). Left: a "New Dataset" action plus the
* dataset list, newest-modified first, each row carrying a source/rows/format/size
* meta line and a usage badge. Right: the selected dataset's detail, the
* create/edit form, or an empty prompt.
*
* State lives in DatasetStore; the bidirectional snippetdataset link is derived
* by scanning SnippetStore (docs/architecture/07 §4), so usage counts and Linked
* Snippets stay reactive without a stored back-pointer.
*/
import { useMemo, useState } from 'react';
import { useShallow } from 'zustand/react/shallow';
import {
cellText,
datasetReference,
tabularRows,
type DataSource,
type Dataset,
} from '@core/dataset';
import { detectFormat, detectFormatFromUrl, type DataFormat } from '@core/format-detection';
import { datasetUsageCounts, snippetsReferencingDataset } from '@core/relationships';
import { humanizeBytes } from '@core/storage-estimate';
import { closeModal, openModal, resnapshot } from '../modals/ModalCoordinator';
import { fetchRemoteData } from '../infrastructure/remote-data';
import { remoteFetchErrorMessage } from '../services/remote-data-errors';
import { confirm } from '../stores/ConfirmStore';
import { notify } from '../stores/NotificationStore';
import {
selectCanSave,
selectSelectedDataset,
useDatasetStore,
byModifiedDesc,
} from '../stores/DatasetStore';
import { useSnippetStore } from '../stores/SnippetStore';
import { SegmentedControl, type SegmentedOption } from './SegmentedControl';
import { Button } from './Button';
import { Icon } from './Icon';
import styles from './DatasetsModal.module.css';
/** Display label for a format (spec §05 → List item: JSON / CSV / TSV / TopoJSON). */
function formatLabel(format: DataFormat): string {
return format === 'topojson' ? 'TopoJSON' : format.toUpperCase();
}
const SOURCE_OPTIONS: ReadonlyArray<SegmentedOption<DataSource>> = [
{ value: 'inline', label: 'Inline' },
{ value: 'url', label: 'URL' },
];
/** Rows shown in the tabular preview before truncating (spec §05 → Detail Panel). */
const PREVIEW_ROW_LIMIT = 50;
export function DatasetsModal() {
const datasets = useDatasetStore(useShallow((s) => s.datasets));
const view = useDatasetStore((s) => s.view);
const selected = useDatasetStore(selectSelectedDataset);
const snippets = useSnippetStore(useShallow((s) => s.snippets));
const select = useDatasetStore((s) => s.select);
const startCreate = useDatasetStore((s) => s.startCreate);
const usage = datasetUsageCounts(snippets);
const ordered = [...datasets].sort(byModifiedDesc);
const handleNew = () => {
startCreate();
resnapshot(); // baseline the discard check to the freshly-opened empty form
};
return (
<div className={styles.manager}>
<div className={styles.listPane}>
<Button variant="primary" size="lg" className={styles.newButton} onClick={handleNew}>
<Icon name="add" /> New Dataset
</Button>
<ul className={styles.list}>
{ordered.length === 0 && (
<li className={styles.empty}>
No datasets yet create one to reuse data across snippets.
</li>
)}
{ordered.map((d) => (
<DatasetListItem
key={d.id}
dataset={d}
active={d.id === selected?.id}
usage={usage.get(d.name.toLowerCase()) ?? 0}
onSelect={() => select(d.id)}
/>
))}
</ul>
</div>
<div className={styles.detailPane}>
{view === 'new' || view === 'edit' ? (
<DatasetFormView editing={view === 'edit'} />
) : selected ? (
<DatasetDetail dataset={selected} snippets={snippets} />
) : (
<div className={styles.detailEmpty}>Select a dataset or create a new one.</div>
)}
</div>
</div>
);
}
function DatasetListItem({
dataset,
active,
usage,
onSelect,
}: {
dataset: Dataset;
active: boolean;
usage: number;
onSelect: () => void;
}) {
// Meta line: source ("URL" prefix), row count when known, format label, size.
// A fetched URL snapshot reads like an inline dataset (rows + size); an unfetched
// URL reference shows "not fetched" in place of figures it doesn't have yet.
const unfetched = dataset.source === 'url' && dataset.data == null;
const parts: string[] = [];
if (dataset.source === 'url') parts.push('URL');
if (unfetched) parts.push('not fetched');
else if (dataset.rowCount !== null) parts.push(`${dataset.rowCount} rows`);
parts.push(formatLabel(dataset.format));
if (!unfetched) parts.push(humanizeBytes(dataset.size));
return (
<li className={`${styles.item} ${active ? styles.itemActive : ''}`}>
<button
type="button"
className={styles.itemMain}
aria-current={active || undefined}
onClick={onSelect}
>
<span className={styles.itemName}>{dataset.name}</span>
<span className={styles.itemMeta}>{parts.join(' · ')}</span>
</button>
{usage > 0 && (
<span className={styles.badge} title={`Used by ${usage} snippet${usage === 1 ? '' : 's'}`}>
{usage}
</span>
)}
</li>
);
}
function DatasetDetail({
dataset,
snippets,
}: {
dataset: Dataset;
snippets: ReadonlyArray<{ id: string; name: string; datasetRefs: string[] }>;
}) {
const startEdit = useDatasetStore((s) => s.startEdit);
const remove = useDatasetStore((s) => s.remove);
const refreshDataset = useDatasetStore((s) => s.refreshDataset);
const selectSnippet = useSnippetStore((s) => s.selectSnippet);
const [copied, setCopied] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const linked = snippetsReferencingDataset(snippets, dataset.name);
// Tabular data (CSV/TSV/JSON-array, inline or fetched) previews as a table of the
// first rows under the profiled columns; non-tabular payloads fall back to text.
// Memoized on the record so a large CSV isn't re-parsed on unrelated re-renders.
const previewRows = useMemo(
() => tabularRows(dataset.data, dataset.format, PREVIEW_ROW_LIMIT),
[dataset],
);
const handleEdit = () => {
startEdit();
resnapshot();
};
// Re-fetch a URL dataset's source and re-snapshot it. The visible result (updated
// rows + "Fetched" time) is the confirmation, so success raises no toast; only a
// failure surfaces one (spec §05 → Actions; docs/architecture/10 → Toast copy).
const handleRefresh = async () => {
if (!dataset.url) return;
setRefreshing(true);
try {
const { text } = await fetchRemoteData(dataset.url);
refreshDataset(dataset.id, { text });
} catch (err) {
notify({
kind: 'error',
title: "Couldn't refresh dataset",
message: remoteFetchErrorMessage(err, 'retry'),
});
} finally {
setRefreshing(false);
}
};
const handleCopy = async () => {
const text = JSON.stringify(datasetReference(dataset.name), null, 2);
try {
await navigator.clipboard.writeText(text);
// Lightweight local feedback; the success-toast wiring is deferred to M6
// with the other success toasts (spec §05 → Actions).
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch {
notify({
kind: 'error',
title: "Couldn't copy",
message: 'Your browser blocked clipboard access. Select and copy the reference manually.',
});
}
};
const handleDelete = async () => {
const ok = await confirm({
title: 'Delete dataset',
message: `Delete "${dataset.name}"? This cannot be undone.`,
confirmLabel: 'Delete',
danger: true,
});
if (!ok) return;
const removedName = dataset.name;
remove(dataset.id);
// Confirm the deletion (spec §05). The message names which dataset went
// (council toast-copy rule, docs/architecture/10 → Toast copy).
notify({
kind: 'success',
title: 'Dataset deleted',
message: `"${removedName}" was permanently removed.`,
});
};
return (
<div className={styles.detail}>
<div className={styles.detailHead}>
<h3 className={styles.detailName}>{dataset.name}</h3>
<div className={styles.detailActions}>
<Button onClick={() => void handleCopy()}>{copied ? 'Copied' : 'Copy Reference'}</Button>
{/* The clipboard write is invisible, so the success is confirmed inline
("Copied") rather than by a toast (docs/architecture/10 Toast copy).
This polite live region announces it to assistive tech, which the
button's visual label swap alone would not reliably do. */}
<span role="status" className="visually-hidden">
{copied ? 'Reference copied to clipboard' : ''}
</span>
{dataset.source === 'url' && (
<Button onClick={() => void handleRefresh()} disabled={refreshing}>
{refreshing ? 'Refreshing…' : 'Refresh'}
</Button>
)}
<Button onClick={handleEdit}>Edit</Button>
{/* Build Chart (spec §05 §06) opens the Chart Builder on this dataset.
Replaces the Datasets modal (one modal at a time, §01C); detail view has
no transient form state, so no discard prompt. */}
<Button onClick={() => openModal('chartBuilder', String(dataset.id))}>Build Chart</Button>
<Button variant="danger-outline" onClick={() => void handleDelete()}>
Delete
</Button>
</div>
</div>
{dataset.comment && <p className={styles.comment}>{dataset.comment}</p>}
{dataset.source === 'url' && (
<p className={styles.sourceMeta}>
<a className={styles.sourceLink} href={dataset.url} target="_blank" rel="noreferrer">
{dataset.url}
</a>
<span>
{dataset.fetchedAt
? `Fetched ${new Date(dataset.fetchedAt).toLocaleString()}`
: 'Not fetched yet'}
</span>
</p>
)}
<section className={styles.section}>
<h4 className={styles.sectionTitle}>Overview</h4>
<dl className={styles.stats}>
<div>
<dt>Rows</dt>
<dd>{dataset.rowCount ?? 'N/A'}</dd>
</div>
<div>
<dt>Columns</dt>
<dd>{dataset.columnCount ?? 'N/A'}</dd>
</div>
<div>
<dt>Format</dt>
<dd>{formatLabel(dataset.format)}</dd>
</div>
<div>
<dt>Size</dt>
<dd>{dataset.data == null ? 'N/A' : humanizeBytes(dataset.size)}</dd>
</div>
</dl>
{dataset.columnTypes.length > 0 && (
<ul className={styles.columns}>
{dataset.columnTypes.map((col) => (
<li key={col.name} className={styles.column}>
<span className={styles.columnName}>{col.name}</span>
<span className={styles.columnType}>{col.type}</span>
</li>
))}
</ul>
)}
<div className={styles.timestamps}>
<span>Created {new Date(dataset.created).toLocaleString()}</span>
<span>Modified {new Date(dataset.modified).toLocaleString()}</span>
</div>
</section>
<section className={styles.section}>
<h4 className={styles.sectionTitle}>Preview</h4>
{previewRows ? (
<>
{/* A scrollable region needs a tab stop + name so keyboard-only users can
reach and scroll it when the preview overflows (WCAG 2.1.1; WAI-ARIA APG
a focusable `region` with an accessible name). */}
<div
className={styles.previewTableWrap}
role="region"
aria-label={`Data preview for ${dataset.name}`}
tabIndex={0}
>
<table className={styles.previewTable}>
<thead>
<tr>
{dataset.columns.map((col, ci) => (
<th key={ci} scope="col">
{col}
</th>
))}
</tr>
</thead>
<tbody>
{previewRows.map((row, ri) => (
<tr key={ri}>
{dataset.columns.map((col, ci) => (
<td key={ci}>{cellText(row[col])}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
{dataset.rowCount != null && dataset.rowCount > previewRows.length && (
<p className={styles.muted}>
Showing the first {previewRows.length} of {dataset.rowCount} rows.
</p>
)}
</>
) : (
<pre className={styles.preview}>{previewText(dataset)}</pre>
)}
</section>
<section className={styles.section}>
<h4 className={styles.sectionTitle}>Linked Snippets</h4>
{linked.length === 0 ? (
<p className={styles.muted}>No snippets reference this dataset yet.</p>
) : (
<ul className={styles.linked}>
{linked.map((s) => (
<li key={s.id}>
<button
type="button"
className={styles.linkButton}
onClick={() => {
selectSnippet(s.id);
void closeModal();
}}
>
{s.name}
</button>
</li>
))}
</ul>
)}
</section>
</div>
);
}
/** A truncated rendering of the data: raw for csv/tsv, pretty JSON otherwise. */
function previewText(dataset: Dataset): string {
const MAX = 2000;
// No snapshot yet (an unfetched URL reference) — there is nothing to preview.
if (dataset.data == null) {
return dataset.source === 'url' ? 'Not fetched yet — use Refresh to load the data.' : '';
}
let text: string;
if (dataset.format === 'csv' || dataset.format === 'tsv') {
// CSV/TSV payloads are raw text; fall back to JSON for any non-string value.
text = typeof dataset.data === 'string' ? dataset.data : JSON.stringify(dataset.data);
} else {
try {
text = JSON.stringify(dataset.data, null, 2);
} catch {
text = typeof dataset.data === 'string' ? dataset.data : '[unserializable data]';
}
}
return text.length > MAX ? `${text.slice(0, MAX)}\n…` : text;
}
function DatasetFormView({ editing }: { editing: boolean }) {
const form = useDatasetStore((s) => s.form);
const formError = useDatasetStore((s) => s.formError);
const selected = useDatasetStore(selectSelectedDataset);
const updateForm = useDatasetStore((s) => s.updateForm);
const cancelForm = useDatasetStore((s) => s.cancelForm);
const save = useDatasetStore((s) => s.save);
const commitUrlSnapshot = useDatasetStore((s) => s.commitUrlSnapshot);
// Save stays disabled until a name and valid data/URL are present (spec §05).
const canSave = useDatasetStore(selectCanSave);
// URL datasets fetch on save (snapshot model): `fetching` drives the button's
// busy state; `fetchError` holds a failed fetch's message + the inline fallback.
const [fetching, setFetching] = useState(false);
const [fetchError, setFetchError] = useState<string | null>(null);
// Live format/source hint from the current input (spec §05 → Auto-detection).
const detected =
form.source === 'url'
? { format: detectFormatFromUrl(form.input.trim()), confidence: 'url' as const }
: detectFormat(form.input);
const handleSave = async () => {
// Inline saves are synchronous; only URL datasets touch the network.
if (form.source !== 'url') {
if (save()) resnapshot(); // committed — re-baseline so a later close won't prompt
return;
}
const url = form.input.trim();
// A metadata-only edit (same URL, snapshot already present) needs no re-fetch.
const metaOnly =
editing &&
selected?.source === 'url' &&
selected.data != null &&
url === (selected.url ?? '');
if (metaOnly) {
if (save()) resnapshot();
return;
}
// Create, changed URL, or inline→URL: fetch once, then snapshot + commit.
setFetchError(null);
setFetching(true);
try {
const { text } = await fetchRemoteData(url);
if (commitUrlSnapshot({ text })) resnapshot();
} catch (err) {
setFetchError(remoteFetchErrorMessage(err));
} finally {
setFetching(false);
}
};
// Recovery from a failed fetch: switch to an inline paste, keeping name + comment.
const handlePasteInline = () => {
setFetchError(null);
updateForm({ source: 'inline', input: '' });
};
const handleCancel = () => {
cancelForm();
resnapshot();
};
return (
<div className={styles.form}>
<h3 className={styles.detailName}>{editing ? 'Edit dataset' : 'New dataset'}</h3>
<label className={styles.field}>
<span className={styles.label}>Name</span>
<input
type="text"
className={styles.input}
value={form.name}
onChange={(e) => updateForm({ name: e.target.value })}
placeholder="e.g. Sales 2024"
/>
</label>
<div className={styles.field}>
<span className={styles.label}>Source</span>
<SegmentedControl
label="Dataset source"
options={SOURCE_OPTIONS}
value={form.source}
onChange={(source) => {
setFetchError(null);
updateForm({ source });
}}
/>
</div>
<label className={styles.field}>
<span className={styles.label}>{form.source === 'url' ? 'URL' : 'Data'}</span>
{form.source === 'url' ? (
<input
type="url"
className={styles.input}
value={form.input}
onChange={(e) => {
setFetchError(null);
updateForm({ input: e.target.value });
}}
placeholder="https://example.com/data.csv"
/>
) : (
<textarea
className={styles.textarea}
value={form.input}
onChange={(e) => updateForm({ input: e.target.value })}
placeholder="Paste JSON, CSV, or TSV…"
rows={10}
spellCheck={false}
/>
)}
</label>
{form.input.trim() !== '' && (
<div className={styles.detected}>
<span className={styles.detectedBadge}>
{detected.format ? formatLabel(detected.format) : 'Unrecognized'}
</span>
{form.source !== 'url' && 'confidence' in detected && (
<span className={styles.detectedHint}>{detected.confidence} confidence</span>
)}
</div>
)}
<label className={styles.field}>
<span className={styles.label}>Comment (optional)</span>
<input
type="text"
className={styles.input}
value={form.comment}
onChange={(e) => updateForm({ comment: e.target.value })}
placeholder="Notes about this dataset"
/>
</label>
{(formError || fetchError) && (
<p className={styles.formError} role="alert">
{formError ?? fetchError}
</p>
)}
{/* A failed fetch is recoverable by pasting the data inline (the user's choice
when CORS or offline blocks the URL) offered as a direct one-click path. */}
{fetchError && (
<Button className={styles.fallback} onClick={handlePasteInline}>
Paste data inline instead
</Button>
)}
<div className={styles.formActions}>
<Button onClick={handleCancel} disabled={fetching}>
Cancel
</Button>
<Button variant="primary" disabled={!canSave || fetching} onClick={() => void handleSave()}>
{fetching ? 'Fetching…' : editing ? 'Save changes' : 'Create dataset'}
</Button>
</div>
</div>
);
}
-80
View File
@@ -1,80 +0,0 @@
/* Support modal body — two ways to give back: feedback, or a donation to Ukraine. */
.donate {
display: flex;
flex-direction: column;
gap: var(--space-5);
padding: var(--space-6);
min-width: 0;
}
.section {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.body {
margin: 0;
font-size: 14px;
line-height: 1.5;
color: var(--text);
}
/* Feedback row: the address (as a mailto link) beside a copy button. */
.contact {
display: flex;
align-items: center;
gap: var(--space-3);
flex-wrap: wrap;
}
.email {
font-size: 14px;
font-weight: 600;
color: var(--accent);
text-decoration: none;
}
.email:hover {
text-decoration: underline;
}
.email:focus-visible {
outline: 2px solid var(--focus);
outline-offset: 2px;
border-radius: var(--radius);
}
.actions {
display: flex;
}
/* Primary CTA styled as a button even though it's an <a> so it matches
the app's design language. Uses accent color as in ExtractModal .primary. */
.primary {
display: inline-flex;
align-items: center;
justify-content: center;
height: var(--control-height-lg);
padding: 0 var(--space-6);
background: var(--accent);
border: var(--border-width) solid transparent;
border-radius: var(--radius);
color: var(--accent-contrast);
font: inherit;
font-size: 13px;
font-weight: 600;
text-decoration: none;
cursor: pointer;
transition: background var(--dur-fast) var(--ease);
}
.primary:hover {
background: var(--accent-hover);
}
.primary:focus-visible {
outline: 2px solid var(--focus);
outline-offset: 2px;
}

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