Format entire codebase with Prettier (mechanical, no behavior change)

This commit is contained in:
2026-06-05 01:43:28 +03:00
parent 939950b136
commit 0c7297624e
32 changed files with 1597 additions and 832 deletions
+19 -5
View File
@@ -46,7 +46,7 @@ Review all changes in scope. If changes span multiple patterns below, apply all
fix whitespace/formatting (trailing newlines etc.) — Prettier owns that. fix whitespace/formatting (trailing newlines etc.) — Prettier owns that.
6. **Code Comments**: Comments should not duplicate what the code already says. Remove 6. **Code Comments**: Comments should not duplicate what the code already says. Remove
parroting comments. Ensure comments capture non-obvious *why* — design decisions, parroting comments. Ensure comments capture non-obvious _why_ — design decisions,
constraints, gotchas. Flag missing comments where a reader would reasonably ask "why is this constraints, gotchas. Flag missing comments where a reader would reasonably ask "why is this
done this way?" done this way?"
@@ -58,7 +58,7 @@ Review all changes in scope. If changes span multiple patterns below, apply all
8. **Pre-existing & out-of-scope issues — leave a breadcrumb.** For anything you notice but 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 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 that are out of scope), mark it with a `// TODO:` at the relevant code site explaining
*what* could be improved and *why* (13 lines). **If an observation is important enough to _what_ could be improved and _why_ (13 lines). **If an observation is important enough to
mention in the summary, it is important enough to deserve a `// TODO:` at the code location** 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. — otherwise the next reader has no way to recover the context.
@@ -104,25 +104,31 @@ Review all changes in scope. If changes span multiple patterns below, apply all
## Pattern A: New Functionality ## Pattern A: New Functionality
### Testing ### Testing
- Unit tests for new `src/core/` logic (test the core hardest). - Unit tests for new `src/core/` logic (test the core hardest).
- Lighter component/interaction tests for new UI. - Lighter component/interaction tests for new UI.
- Tests pass before proceeding. - Tests pass before proceeding.
### Documentation ### Documentation
Update relevant docs if the feature is significant: Update relevant docs if the feature is significant:
- **`docs/spec/`** — if product behavior changed (this is a contract; change deliberately). - **`docs/spec/`** — if product behavior changed (this is a contract; change deliberately).
- **`docs/architecture/`** — if a new pattern, navigation map, or decision rule emerged. - **`docs/architecture/`** — if a new pattern, navigation map, or decision rule emerged.
- **`docs/IMPLEMENTATION-PLAN.md`** — mark milestone progress. - **`docs/IMPLEMENTATION-PLAN.md`** — mark milestone progress.
Use the `/doc-update` skill for session-discovered gaps. The list is not exclusive. Use the `/doc-update` skill for session-discovered gaps. The list is not exclusive.
### Dependencies ### Dependencies
If `package.json` changed: If `package.json` changed:
- Flag each new dependency; explain what it does and why it's needed. - Flag each new dependency; explain what it does and why it's needed.
- Could a small custom implementation avoid it? Note the trade-off. - Could a small custom implementation avoid it? Note the trade-off.
- Prefer dependencies that solve genuinely hard problems (parsing, rendering) over those that - Prefer dependencies that solve genuinely hard problems (parsing, rendering) over those that
save boilerplate. save boilerplate.
### Alignment Check ### Alignment Check
- **SOUL.md** — philosophy (must not violate without good reason). - **SOUL.md** — philosophy (must not violate without good reason).
- **`docs/spec/`** — behavioral contract. - **`docs/spec/`** — behavioral contract.
- **`docs/architecture/`** — the relevant pattern doc. - **`docs/architecture/`** — the relevant pattern doc.
@@ -132,14 +138,17 @@ If `package.json` changed:
## Pattern B: Bug Fixes ## Pattern B: Bug Fixes
### Testing ### Testing
- Add a regression test that reproduces the bug and verifies the fix. - Add a regression test that reproduces the bug and verifies the fix.
- Interaction test if the bug affected UI behavior. - Interaction test if the bug affected UI behavior.
### Documentation ### Documentation
Usually not required unless the bug revealed incorrect docs, or the fix changes documented Usually not required unless the bug revealed incorrect docs, or the fix changes documented
(spec) behavior. (spec) behavior.
### Alignment Check ### Alignment Check
- **SOUL.md** philosophy; **`docs/spec/`** behavioral contract; **`docs/architecture/`** patterns. - **SOUL.md** philosophy; **`docs/spec/`** behavioral contract; **`docs/architecture/`** patterns.
--- ---
@@ -147,24 +156,29 @@ Usually not required unless the bug revealed incorrect docs, or the fix changes
## Pattern C: Refactoring ## Pattern C: Refactoring
### Impact Analysis ### Impact Analysis
1. **Search for usages** of modified functions/types across the codebase (Grep). 1. **Search for usages** of modified functions/types across the codebase (Grep).
2. **Identify call sites** (components, stores, services, infrastructure, tests). 2. **Identify call sites** (components, stores, services, infrastructure, tests).
3. **Check exports** used by other modules. 3. **Check exports** used by other modules.
4. **Review dependencies** — what the code depends on and what depends on it. 4. **Review dependencies** — what the code depends on and what depends on it.
### Testing ### Testing
- Update existing tests to the new structure; verify all call sites. - Update existing tests to the new structure; verify all call sites.
- Run `npm test` and `npm run typecheck`. - Run `npm test` and `npm run typecheck`.
### Documentation ### Documentation
Update `docs/architecture/` if a pattern, module responsibility, or navigation map changed. Update `docs/architecture/` if a pattern, module responsibility, or navigation map changed.
Update JSDoc/inline comments if signatures or behavior changed. Update JSDoc/inline comments if signatures or behavior changed.
### Alignment Check ### Alignment Check
- **SOUL.md** (simplicity, no parallel systems); **`docs/architecture/`** (consistent with the - **SOUL.md** (simplicity, no parallel systems); **`docs/architecture/`** (consistent with the
documented patterns); **`docs/spec/`** (behavior unchanged unless intended). documented patterns); **`docs/spec/`** (behavior unchanged unless intended).
### Common Refactoring Checks ### Common Refactoring Checks
- Function signatures → all call sites updated. - Function signatures → all call sites updated.
- Type definitions → search type usages. - Type definitions → search type usages.
- Imports → correct after file moves. - Imports → correct after file moves.
@@ -177,9 +191,9 @@ Update JSDoc/inline comments if signatures or behavior changed.
## Reference Documents ## Reference Documents
| Document | Purpose | | Document | Purpose |
| --- | --- | | ------------------------------------------------------------------- | ----------------------------------------- |
| [SOUL.md](../../../SOUL.md) | Project philosophy and core values | | [SOUL.md](../../../SOUL.md) | Project philosophy and core values |
| [AGENTS.md](../../../AGENTS.md) | AI onboarding and project context | | [AGENTS.md](../../../AGENTS.md) | AI onboarding and project context |
| [docs/spec/](../../../docs/spec/) | Behavioral contract — *what* the app does | | [docs/spec/](../../../docs/spec/) | Behavioral contract — _what_ the app does |
| [docs/architecture/](../../../docs/architecture/00-overview.md) | Architecture playbook — *how* it's built | | [docs/architecture/](../../../docs/architecture/00-overview.md) | Architecture playbook — _how_ it's built |
| [docs/IMPLEMENTATION-PLAN.md](../../../docs/IMPLEMENTATION-PLAN.md) | Milestone sequence and scope | | [docs/IMPLEMENTATION-PLAN.md](../../../docs/IMPLEMENTATION-PLAN.md) | Milestone sequence and scope |
+5 -5
View File
@@ -18,7 +18,7 @@ Documentation serves two purposes — know **where to look** and know **what to
are valuable, but at different levels of detail: 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. - **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. - **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. - **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 **Navigation maps** use file/module names (stable) to show flow direction. **Decision
@@ -30,13 +30,13 @@ For documentation organization, see **[CLAUDE.md](../../../CLAUDE.md)** and the
## The three documentation layers (know which one a gap belongs to) ## The three documentation layers (know which one a gap belongs to)
- **`docs/spec/`** — the *what*: behavioral contract (what the app does, acceptance points). - **`docs/spec/`** — the _what_: behavioral contract (what the app does, acceptance points).
This is a **contract**. Only change it when product behavior genuinely changes, and do so 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. 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, - **`docs/architecture/`** — the _how_: the patterns behind each layer (state, persistence,
modals, routing, rendering, inference, relationships). Most navigation maps and decision modals, routing, rendering, inference, relationships). Most navigation maps and decision
rules land here. rules land here.
- **`docs/IMPLEMENTATION-PLAN.md`** — the *when*: milestone sequence and scope. - **`docs/IMPLEMENTATION-PLAN.md`** — the _when_: milestone sequence and scope.
## Process ## Process
@@ -60,7 +60,7 @@ not do Y"). If you can't state it concisely, it may be too implementation-specif
Map each gap to the right document: Map each gap to the right document:
| Gap type | Target document | | Gap type | Target document |
| --- | --- | | ------------------------------------------------- | ----------------------------------------------------------------------------- |
| Product behavior, capabilities, acceptance points | `docs/spec/` (the relevant 0010 section) — **contract; change deliberately** | | Product behavior, capabilities, acceptance points | `docs/spec/` (the relevant 0010 section) — **contract; change deliberately** |
| State / Zustand stores | `docs/architecture/01-state-and-stores.md` | | State / Zustand stores | `docs/architecture/01-state-and-stores.md` |
| Persistence, IndexedDB, localStorage, migrations | `docs/architecture/02-persistence.md` | | Persistence, IndexedDB, localStorage, migrations | `docs/architecture/02-persistence.md` |
+1 -1
View File
@@ -26,7 +26,7 @@ Read the current version from `package.json`. The project uses **simplified semv
pre-1.0**: pre-1.0**:
| Bump | When | Example | | Bump | When | Example |
| --- | --- | --- | | ------------------- | ---------------------------------------------------- | ----------------- |
| **Minor** (`0.x.0`) | New features, UI changes, behavior changes | `0.1.0``0.2.0` | | **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` | | **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) | — | | **Major** (`1.0.0`) | Only when declaring public stability (user decision) | — |
+3 -3
View File
@@ -12,13 +12,13 @@ validation and a live chart preview, and reuses **datasets** across many snippet
local, offline-capable, no account. local, offline-capable, no account.
It is a **spec-driven rebuild** on an architecture adapted from its sibling project Syto. It is a **spec-driven rebuild** on an architecture adapted from its sibling project Syto.
The authoritative behavioral contract is **`docs/spec/`** (sections 0010). Implement *to The authoritative behavioral contract is **`docs/spec/`** (sections 0010). Implement _to
the spec*; do not port legacy code. the spec_; do not port legacy code.
### Technical Stack ### Technical Stack
| Layer | Technology | | Layer | Technology |
|-------|------------| | ------- | ------------------------------------------------------------------------------------ |
| Build | Vite, TypeScript, Vitest (happy-dom) | | Build | Vite, TypeScript, Vitest (happy-dom) |
| UI | React, Zustand, CSS Modules | | UI | React, Zustand, CSS Modules |
| Editor | Monaco (JSON + Vega-Lite schema service) | | Editor | Monaco (JSON + Vega-Lite schema service) |
+16 -5
View File
@@ -3,7 +3,7 @@
## The Problem ## The Problem
People who work with [Vega-Lite](https://vega.github.io/vega-lite/) directly — analysts, 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 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 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 when you close it. Notebooks bury charts in code. BI tools hide the spec behind a GUI and
lock you into an account. lock you into an account.
@@ -17,7 +17,7 @@ account, no server, and full offline use.**
The central artifact is the **snippet**: a saved Vega-Lite specification plus metadata. 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 — 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 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 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. JSON is always the source of truth and always editable.
Reusable **datasets** are stored once and referenced by name from many snippets, so the Reusable **datasets** are stored once and referenced by name from many snippets, so the
@@ -26,38 +26,44 @@ data lives in one place and the specs stay lean.
## Core Values ## Core Values
### 1. Local-Only by Default ### 1. Local-Only by Default
Everything runs in the browser. Snippets, datasets, and settings never leave the machine. Everything runs in the browser. Snippets, datasets, and settings never leave the machine.
No accounts, no uploads, no tracking. The only outbound requests are user-created No accounts, no uploads, no tracking. The only outbound requests are user-created
URL-dataset fetches. URL-dataset fetches.
### 2. Vega-Lite Native, Not Vega-Lite Hidden ### 2. Vega-Lite Native, Not Vega-Lite Hidden
The product domain *is* Vega-Lite. We validate, render, and reason about specs as
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 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 invent a parallel chart abstraction. The chart builder is an on-ramp, not a replacement
for the spec. for the spec.
### 3. Experiment Safely ### 3. Experiment Safely
A snippet carries a stable **published** spec and an editable **draft**. You can tinker 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 freely without losing a known-good version. Auto-save protects in-progress work; publish
promotes it deliberately. promotes it deliberately.
### 4. Beginner On-Ramp, Power-User Ceiling ### 4. Beginner On-Ramp, Power-User Ceiling
The chart builder lets someone produce a chart without writing JSON. The editor — with 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 schema-aware autocomplete and live validation — lets a power user do anything Vega-Lite
can. Neither caps the other. can. Neither caps the other.
### 5. Own Your Data ### 5. Own Your Data
Fully local and offline-capable, with import/export for backup and transfer. Your library 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. is a file you control, not a row in someone's database.
### 6. Predictable, Not Clever ### 6. Predictable, Not Clever
When a behavior could go several ways, pick the one closest to the user's existing mental 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 model (the Vega-Lite editor, JSON tooling, file-based apps). Least surprise beats most
clever. clever.
## What We're Not ## What We're Not
- **Not a BI/dashboarding tool.** A snippet is *one* visualization, not a composed report - **Not a BI/dashboarding tool.** A snippet is _one_ visualization, not a composed report
with cross-filters and layout. Dashboards are a different product. with cross-filters and layout. Dashboards are a different product.
- **Not a data-wrangling tool.** Datasets are stored and referenced, not cleaned or - **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 transformed. (That's [Syto](https://github.com/) territory — Astrolabe's sibling in
@@ -69,29 +75,34 @@ clever.
## Technical Philosophy ## Technical Philosophy
### Spec-Driven, Clean Implementation ### Spec-Driven, Clean Implementation
The behavioral contract lives in `docs/spec/`. Astrolabe is a deliberate rebuild on a 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 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 code. When the spec and convenience conflict, the spec wins or the spec changes — never
silent drift. silent drift.
### Portable Core, Thin Browser Shell ### Portable Core, Thin Browser Shell
`src/core/` is pure and portable — no browser APIs, no UI framework. Spec operations `src/core/` is pure and portable — no browser APIs, no UI framework. Spec operations
(detection, profiling, reference resolution, fit transforms, validation, import (detection, profiling, reference resolution, fit transforms, validation, import
normalization) live there and are tested hardest. The UI is a thin, replaceable shell over normalization) live there and are tested hardest. The UI is a thin, replaceable shell over
that core. that core.
### Leverage Existing Libraries ### Leverage Existing Libraries
Vega-Lite renders. Monaco edits. vega-embed mounts charts. React + Zustand drive the UI. 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 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, focuses on what's unique to Astrolabe: the snippet/dataset model, the rendering contract,
and the workspace that ties it together. and the workspace that ties it together.
### No Parallel Systems ### No Parallel Systems
Each fact lives in one place. A snippet↔dataset link, a setting, a schema — one source of 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 truth, others derived. If you're writing the same logic twice, one should import or be
generated from the other. generated from the other.
### Test the Core, Trust the UI ### Test the Core, Trust the UI
High coverage on the portable engine (where a bug corrupts data or breaks rendering); 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). lighter coverage on components (where a bug is a cosmetic annoyance).
+41 -13
View File
@@ -43,7 +43,7 @@ doc before implementing.
## Milestone map ## Milestone map
| # | Milestone | Outcome | Spec | | # | Milestone | Outcome | Spec |
|---|-----------|---------|------| | -------- | --------------------------- | ------------------------------------------------------------------------------ | ------------------------------------------- |
| **M0** | Skeleton ✅ | Repo builds, tests run, empty shell renders | — | | **M0** | Skeleton ✅ | Repo builds, tests run, empty shell renders | — |
| **M1** | **MVP core loop** | Author a Vega-Lite snippet, see it render live, it persists | §02, §03AC, §04, §09A | | **M1** | **MVP core loop** | Author a Vega-Lite snippet, see it render live, it persists | §02, §03AC, §04, §09A |
| **M1.5** | Visual design foundation ✅ | Apply the design language: tokens, IBM Plex, restyled M1 surfaces, chart theme | [arch 09](architecture/09-visual-design.md) | | **M1.5** | Visual design foundation ✅ | Apply the design language: tokens, IBM Plex, restyled M1 surfaces, chart theme | [arch 09](architecture/09-visual-design.md) |
@@ -54,7 +54,7 @@ doc before implementing.
| **M6** | Shell polish | Resize/toggle panes, routing, shortcuts, toasts, a11y, offline | §01, §10 | | **M6** | Shell polish | Resize/toggle panes, routing, shortcuts, toasts, a11y, offline | §01, §10 |
**MVP boundary = end of M1** (a genuinely usable single-user chart authoring loop). **MVP boundary = end of M1** (a genuinely usable single-user chart authoring loop).
M1.5 makes it *look right*; M2 makes it *robust*; M3M6 make it *complete*. M1.5 makes it _look right_; M2 makes it _robust_; M3M6 make it _complete_.
Ship/dogfood after M1, iterate. Ship/dogfood after M1, iterate.
--- ---
@@ -69,13 +69,14 @@ shell, first core module (`format-detection`) with tests.
--- ---
## M1 · MVP core loop *the quickest usable Astrolabe* ## M1 · MVP core loop → _the quickest usable Astrolabe_
**Goal:** select/create a snippet, edit its spec JSON, watch a live Vega-Lite **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 preview, and have it survive reload. Single source kind: inline-data specs only
(datasets come in M3). No draft/published yet — edits save directly. (datasets come in M3). No draft/published yet — edits save directly.
**Core (`src/core/`)** **Core (`src/core/`)**
- `snippet.ts` — the Snippet type (spec §09A) + factory (`createSnippet`, - `snippet.ts` — the Snippet type (spec §09A) + factory (`createSnippet`,
default sample bar-chart template, auto-generated date/time name). default sample bar-chart template, auto-generated date/time name).
- `rendering.ts``prepareSpecForRender(spec, { fitMode })` skeleton; in M1 it's - `rendering.ts``prepareSpecForRender(spec, { fitMode })` skeleton; in M1 it's
@@ -83,11 +84,13 @@ preview, and have it survive reload. Single source kind: inline-data specs only
Establish the "transform a copy, never mutate stored spec" contract now. Establish the "transform a copy, never mutate stored spec" contract now.
**Infrastructure (`src/app/infrastructure/`)** **Infrastructure (`src/app/infrastructure/`)**
- `idb.ts` — thin IndexedDB wrapper (open, get/put/delete/getAll by store). - `idb.ts` — thin IndexedDB wrapper (open, get/put/delete/getAll by store).
*(see [Architecture 02 · Persistence](architecture/02-persistence.md))* _(see [Architecture 02 · Persistence](architecture/02-persistence.md))_
- `snippet-store.ts` — persist snippets (object store `snippets`). - `snippet-store.ts` — persist snippets (object store `snippets`).
**App** **App**
- `stores/SnippetStore.ts``useSnippetStore` with `snippets`, `activeSnippetId`, - `stores/SnippetStore.ts``useSnippetStore` with `snippets`, `activeSnippetId`,
selector-derived `activeSnippet`; load-on-startup; create/select/delete/update actions 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. (debounced auto-save of edits, spec §03B). Seed one sample snippet on first run.
@@ -100,12 +103,14 @@ preview, and have it survive reload. Single source kind: inline-data specs only
- Fill the three panes in `App.tsx` with these. - Fill the three panes in `App.tsx` with these.
**Tests (core-first)** **Tests (core-first)**
- `snippet.test.ts` — factory defaults, sample template validity, unique naming. - `snippet.test.ts` — factory defaults, sample template validity, unique naming.
- `rendering.test.ts` — copy-not-mutate invariant; pass-through shape. - `rendering.test.ts` — copy-not-mutate invariant; pass-through shape.
- A store test for create/select/delete/auto-save reducer logic (logic extracted - A store test for create/select/delete/auto-save reducer logic (logic extracted
from the component so it's testable without DOM). from the component so it's testable without DOM).
**Manual checks** **Manual checks**
- Fresh load seeds a sample snippet that renders a bar chart. - Fresh load seeds a sample snippet that renders a bar chart.
- Type in the editor → preview updates after the debounce; bad JSON → editor keeps - Type in the editor → preview updates after the debounce; bad JSON → editor keeps
working, preview shows an error, recovers when fixed. working, preview shows an error, recovers when fixed.
@@ -113,15 +118,16 @@ preview, and have it survive reload. Single source kind: inline-data specs only
--- ---
## M1.5 · Visual design foundation ✅ (done) *make the MVP look like itself* ## 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 **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 later milestone builds on settled tokens instead of placeholders. The expensive part
(the design decisions) is already done — this milestone is *application*, not (the design decisions) is already done — this milestone is _application_, not
invention. See [Architecture 09 · Visual Design Language](architecture/09-visual-design.md) invention. See [Architecture 09 · Visual Design Language](architecture/09-visual-design.md)
and the companion `visual-specimen.html`. and the companion `visual-specimen.html`.
**Styles** **Styles**
- Port the settled specimen tokens into `styles/tokens.css` (IBM-Plex type scale, - 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 8px-based spacing, role-based color, square chrome, motion); light + dark themes
via `[data-theme]`. via `[data-theme]`.
@@ -129,6 +135,7 @@ and the companion `visual-specimen.html`.
(offline/PWA — never a CDN). (offline/PWA — never a CDN).
**App** **App**
- Restyle the four M1 surfaces against the tokens: App shell, SnippetLibrary, - Restyle the four M1 surfaces against the tokens: App shell, SnippetLibrary,
SpecEditor (Monaco theme follows `[data-theme]`), LivePreview. Tokens only — no SpecEditor (Monaco theme follows `[data-theme]`), LivePreview. Tokens only — no
raw hexes, no hardcoded hues in components. raw hexes, no hardcoded hues in components.
@@ -141,14 +148,17 @@ and the companion `visual-specimen.html`.
so dogfooding dark mode through M2M4 beat waiting for the Settings modal. so dogfooding dark mode through M2M4 beat waiting for the Settings modal.
**Core** **Core**
- Align `src/core/vega-themes.ts`: chart `Config` per theme + a categorical - Align `src/core/vega-themes.ts`: chart `Config` per theme + a categorical
`range.category` palette (clone `carbon-design-system/carbon-charts` for the `range.category` palette (clone `carbon-design-system/carbon-charts` for the
sequence — see Architecture 09 §7). sequence — see Architecture 09 §7).
**Tests** **Tests**
- Light: the design is mostly visual — a token/theme smoke check, trust the eye. - Light: the design is mostly visual — a token/theme smoke check, trust the eye.
**Manual checks** **Manual checks**
- The real app looks deliberate in both themes; theme flip repaints UI + chart. - 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. - Keyboard focus ring visible; text/UI contrast passes AA in light and dark.
- No placeholder styling remains on the M1 surfaces. - No placeholder styling remains on the M1 surfaces.
@@ -170,6 +180,7 @@ set to Plex Mono explicitly since it can't read the CSS token.
assistance, and the fit-mode rendering contract. assistance, and the fit-mode rendering contract.
**Core** **Core**
- `rendering.ts` — implement **fit-mode** transform (Original/Width/Height/Full → - `rendering.ts` — implement **fit-mode** transform (Original/Width/Height/Full →
Vega-Lite `"container"`), recursing into layered/concat/child specs (spec §04 Vega-Lite `"container"`), recursing into layered/concat/child specs (spec §04
Rendering Contract, step 2). Rendering Contract, step 2).
@@ -177,6 +188,7 @@ assistance, and the fit-mode rendering contract.
validation/autocomplete (mine vega-editor for sourcing/versioning the schema). validation/autocomplete (mine vega-editor for sourcing/versioning the schema).
**App** **App**
- Snippet gains `spec` (published) + `draftSpec` (working) per §09A; editing - Snippet gains `spec` (published) + `draftSpec` (working) per §09A; editing
touches `draftSpec` only. touches `draftSpec` only.
- `SpecEditor` header: Draft/Published toggle; **Publish** (promotes draft, recomputes - `SpecEditor` header: Draft/Published toggle; **Publish** (promotes draft, recomputes
@@ -187,10 +199,12 @@ assistance, and the fit-mode rendering contract.
- Preview **Fit control** (4 modes), persisted (`previewFitMode`). - Preview **Fit control** (4 modes), persisted (`previewFitMode`).
**Tests** **Tests**
- Fit-mode transforms for each mode incl. nested specs; copy-not-mutate. - Fit-mode transforms for each mode incl. nested specs; copy-not-mutate.
- Draft/publish/revert reducer logic; "has unpublished changes" derivation. - Draft/publish/revert reducer logic; "has unpublished changes" derivation.
**Manual checks** **Manual checks**
- Edit draft, see status flip to "draft"; Publish → status clears; Revert → - Edit draft, see status flip to "draft"; Publish → status clears; Revert →
draft restored with confirmation. draft restored with confirmation.
- Invalid spec shows inline error; autocomplete suggests Vega-Lite properties. - Invalid spec shows inline error; autocomplete suggests Vega-Lite properties.
@@ -204,18 +218,21 @@ assistance, and the fit-mode rendering contract.
the reference. the reference.
**Core** **Core**
- `profiling.ts` — row/column counts, column names, **per-column type inference** - `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))* (number/text/date/boolean). _(see [Architecture 06 · Type Inference](architecture/06-type-inference.md))_
- `rendering.ts` — implement **dataset reference resolution** (§04 Rendering - `rendering.ts` — implement **dataset reference resolution** (§04 Rendering
Contract, step 1): `{data:{name}}` → inline values / raw text+format / URL+format, Contract, step 1): `{data:{name}}` → inline values / raw text+format / URL+format,
recursing into sub-specs; "dataset not found" error. recursing into sub-specs; "dataset not found" error.
- `dataset.ts` — Dataset type (§09B); name uniqueness helpers; rename-propagation - `dataset.ts` — Dataset type (§09B); name uniqueness helpers; rename-propagation
into referencing specs. *(see [Architecture 07 · Naming & Relationships](architecture/07-naming-and-relationships.md))* into referencing specs. _(see [Architecture 07 · Naming & Relationships](architecture/07-naming-and-relationships.md))_
**Infrastructure** **Infrastructure**
- `dataset-store.ts` — separate high-capacity IndexedDB store (§09E). - `dataset-store.ts` — separate high-capacity IndexedDB store (§09E).
**App** **App**
- `stores/DatasetStore.ts` + Datasets **modal** (list/detail panes, create form, - `stores/DatasetStore.ts` + Datasets **modal** (list/detail panes, create form,
edit, delete, copy-reference) via the modal registry/coordinator. edit, delete, copy-reference) via the modal registry/coordinator.
- Snippet `datasetRefs` maintained on publish; library shows dataset icon + - Snippet `datasetRefs` maintained on publish; library shows dataset icon +
@@ -224,11 +241,13 @@ the reference.
- URL-sourced datasets fetched at render time. - URL-sourced datasets fetched at render time.
**Tests** **Tests**
- Reference resolution per source/format incl. nested; not-found error. - Reference resolution per source/format incl. nested; not-found error.
- Profiling/type inference across mixed columns, nulls, booleans. - Profiling/type inference across mixed columns, nulls, booleans.
- Rename propagation; name-uniqueness + import-style auto-suffix. - Rename propagation; name-uniqueness + import-style auto-suffix.
**Manual checks** **Manual checks**
- Create a dataset, reference it by name in a snippet → preview renders. - 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. - Extract inline data → spec rewritten to a reference, dataset appears, links show both ways.
- Delete/rename a referenced dataset behaves per spec. - Delete/rename a referenced dataset behaves per spec.
@@ -240,21 +259,25 @@ the reference.
**Goal:** no-JSON chart composition from a dataset → a new snippet. **Goal:** no-JSON chart composition from a dataset → a new snippet.
**Core** **Core**
- `chart-builder.ts` — pure spec assembler: (mark ∈ Bar/Line/Point/Area/Circle) + - `chart-builder.ts` — pure spec assembler: (mark ∈ Bar/Line/Point/Area/Circle) +
channels (X/Y/Color/Size) with field types (Quantitative/Nominal/Ordinal/Temporal) channels (X/Y/Color/Size) with field types (Quantitative/Nominal/Ordinal/Temporal)
+ optional width/height → complete Vega-Lite spec with tooltips + named data ref - optional width/height → complete Vega-Lite spec with tooltips + named data ref
(§06 Output). Field-type defaults from inferred column type. (§06 Output). Field-type defaults from inferred column type.
**App** **App**
- Chart Builder **modal** (config pane + live preview pane), launched from a - Chart Builder **modal** (config pane + live preview pane), launched from a
selected dataset; default pre-population (first col→X, second→Y); validation selected dataset; default pre-population (first col→X, second→Y); validation
(≥1 channel); Create Snippet → new linked snippet becomes active. (≥1 channel); Create Snippet → new linked snippet becomes active.
**Tests** **Tests**
- Spec assembly: mark/channel/type permutations, unmapped channels omitted, - Spec assembly: mark/channel/type permutations, unmapped channels omitted,
width/height inclusion, field-type derivation, validation gate. width/height inclusion, field-type derivation, validation gate.
**Manual checks** **Manual checks**
- Build a bar chart from a dataset in a few clicks; preview live-updates; - Build a bar chart from a dataset in a few clicks; preview live-updates;
Create → new snippet opens and renders. Create → new snippet opens and renders.
@@ -265,6 +288,7 @@ the reference.
**Goal:** preferences and whole-workspace backup/transfer. **Goal:** preferences and whole-workspace backup/transfer.
**Core** **Core**
- `settings.ts` — UserSettings shape + defaults + load-with-fallback (§07, §09C); - `settings.ts` — UserSettings shape + defaults + load-with-fallback (§07, §09C);
unknown/missing values fall back silently. unknown/missing values fall back silently.
- `import-normalize.ts` — accept envelope / bare array / single snippet / foreign - `import-normalize.ts` — accept envelope / bare array / single snippet / foreign
@@ -274,11 +298,13 @@ the reference.
- `export-envelope.ts` — build the `{version, exportedAt, exportedBy, snippets, datasets}` envelope. - `export-envelope.ts` — build the `{version, exportedAt, exportedBy, snippets, datasets}` envelope.
**Infrastructure** **Infrastructure**
- `settings-store.ts` (localStorage): **extend** the minimal M1.5 adapter (which - `settings-store.ts` (localStorage): **extend** the minimal M1.5 adapter (which
already persists `ui.theme`) to the full UserSettings record; `ux-prefs` for sort already persists `ui.theme`) to the full UserSettings record; `ux-prefs` for sort
+ panel layout (§09D). - panel layout (§09D).
**App** **App**
- Settings **modal** (Appearance/Editor/Performance/Formatting), Apply/Cancel/Reset, - Settings **modal** (Appearance/Editor/Performance/Formatting), Apply/Cancel/Reset,
dirty indicator; wire render-debounce + theme + date-format through to the app. dirty indicator; wire render-debounce + theme + date-format through to the app.
Theme already switches (M1.5 header toggle + chart/editor themes) — M5 surfaces Theme already switches (M1.5 header toggle + chart/editor themes) — M5 surfaces
@@ -287,11 +313,13 @@ the reference.
- Date formatting util (smart/iso/custom) used by the library list. - Date formatting util (smart/iso/custom) used by the library list.
**Tests** **Tests**
- Import normalization across all accepted shapes; merge/collision/rename logic; - Import normalization across all accepted shapes; merge/collision/rename logic;
quota-overage messaging path. Envelope round-trip (export→import idempotence). quota-overage messaging path. Envelope round-trip (export→import idempotence).
- Settings load-with-fallback for partial/unknown records. - Settings load-with-fallback for partial/unknown records.
**Manual checks** **Manual checks**
- Change theme/debounce/date-format → takes effect; Cancel reverts; Reset confirms. - Change theme/debounce/date-format → takes effect; Cancel reverts; Reset confirms.
- Export → reimport into a populated workspace merges without overwrite; renames reported. - Export → reimport into a populated workspace merges without overwrite; renames reported.
@@ -304,9 +332,9 @@ the reference.
- **Panes:** drag-resize handles with min widths; per-pane show/hide toggle strip; - **Panes:** drag-resize handles with min widths; per-pane show/hide toggle strip;
widths + visibility persist (§01A, §09D). widths + visibility persist (§01A, §09D).
- **Routing:** URL hash view-state (`#snippet-<id>`, `#datasets/...`) with Back/Forward; - **Routing:** URL hash view-state (`#snippet-<id>`, `#datasets/...`) with Back/Forward;
restore on load (§01E). *(see [Architecture 04 · Routing & Events](architecture/04-routing-and-events.md))* restore on load (§01E). _(see [Architecture 04 · Routing & Events](architecture/04-routing-and-events.md))_
- **Shortcuts:** Cmd/Ctrl+Shift+N / +K / +S / +, / Esc via a single key router - **Shortcuts:** Cmd/Ctrl+Shift+N / +K / +S / +, / Esc via a single key router
(§01D). *(see [Architecture 04 · Routing & Events](architecture/04-routing-and-events.md))* (§01D). _(see [Architecture 04 · Routing & Events](architecture/04-routing-and-events.md))_
- **Toasts:** success/error/warning/info, stacking, auto-dismiss, reduced-motion (§01F). - **Toasts:** success/error/warning/info, stacking, auto-dismiss, reduced-motion (§01F).
- **Storage monitor** for the snippet tier (§02). - **Storage monitor** for the snippet tier (§02).
- **A11y:** modal focus trap + return, labelled icon buttons, contrast in both themes (§10). - **A11y:** modal focus trap + return, labelled icon buttons, contrast in both themes (§10).
@@ -340,7 +368,7 @@ The **how** behind each milestone is documented self-containedly in
[`docs/architecture/`](architecture/00-overview.md) — no external repo needed: [`docs/architecture/`](architecture/00-overview.md) — no external repo needed:
| Need | Doc | | Need | Doc |
|------|-----| | ------------------------------------------------------------------------------ | --------------------------------------------------------------------------------- |
| Zustand stores, selector derivations, debounced auto-save | [01 · State & Stores](architecture/01-state-and-stores.md) | | 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) | | 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) | | Modal registry + coordinator + shell, unsaved-change detection, focus trap | [03 · Modal System](architecture/03-modal-system.md) |
+49 -43
View File
@@ -5,7 +5,7 @@
> against Syto in its current state, and recommends an integration path. > 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 — > **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 > 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 > (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 > 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 > exists in the codebase. The recommendation is **harvest, don't port** — and the framing decision
@@ -16,10 +16,10 @@
## 1. Executive Summary ## 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. | | **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. | | **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. | | **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. | | **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. |
--- ---
@@ -27,21 +27,21 @@
## 2. The Two Products Side by Side ## 2. The Two Products Side by Side
| Dimension | **Astrolabe** | **Syto** | | Dimension | **Astrolabe** | **Syto** |
|---|---|---| | ------------- | ----------------------------------------------------------------------------- | -------------------------------------------------------------------- |
| Core artifact | A **snippet** = a saved Vega-Lite spec + metadata | A **workflow** = a declarative transform pipeline over a Source | | 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* | | 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) | | 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 | | 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 | | 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`) | | 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 | | 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 | | 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) | | 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 | | 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 **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 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 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), 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. which directly violates SOUL's **"No Parallel Systems"** value.
@@ -54,26 +54,26 @@ This is not a technical blocker; it is a product-identity decision, and per proj
**What `SOUL.md` currently says:** **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."* - _"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."* - _"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 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 squarely "charts as final output" and "a visualization tool." Porting Astrolabe as-is would
contradict two written non-goals. contradict two written non-goals.
**However**, there is a reading that is fully *aligned* with the rest of SOUL: **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. - _"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. - _"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. - 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 **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? thing well (wrangling ends in a usable artifact), or is it the BI/viz scope SOUL rejects?
Two coherent resolutions: 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). - **(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. - **(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 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 currently weakest (no output artifact), and because doing it natively avoids the parallel-systems
@@ -86,26 +86,26 @@ trap. But this is the user's call to make against SOUL.
Legend: 🟢 already exists / strong reuse · 🟡 partial, needs adaptation · 🔴 net-new build Legend: 🟢 already exists / strong reuse · 🟡 partial, needs adaptation · 🔴 net-new build
| Astrolabe feature | Syto today | Verdict | Notes | | 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. | | **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. | | **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. | | **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.) | | **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). | | **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. | | **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. | | **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. | | **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. | | **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. | | **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. | | **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. | | **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. | | **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. | | **Offline / PWA / installable** | `vite-plugin-pwa` already configured | 🟢 | Free. |
| **i18n** | i18next, en/uk, namespaced | 🟢 | New strings go in a namespace; framework is there. | | **i18n** | i18next, en/uk, namespaced | 🟢 | New strings go in a namespace; framework is there. |
| **Toasts** | Notification system exists | 🟢 | Reuse. | | **Toasts** | Notification system exists | 🟢 | Reuse. |
**Reuse tally:** the *rendering, editing, persistence, settings, schema, i18n, PWA, and toast* **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 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. Astrolabe are either redundant with Syto or actively conflicting and should be dropped.
--- ---
@@ -113,24 +113,24 @@ Astrolabe are either redundant with Syto or actively conflicting and should be d
## 5. Technical Friction Points (if ported verbatim) ## 5. Technical Friction Points (if ported verbatim)
1. **Parallel data library.** Astrolabe datasets vs Syto Sources/Models — two stores, two 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 profilers, two "named data" concepts. Violates _No Parallel Systems_. (The fix: charts reference
Models.) Models.)
2. **Parallel persistence + export.** A second IndexedDB store layout and a second JSON envelope 2. **Parallel persistence + export.** A second IndexedDB store layout and a second JSON envelope
alongside workflow v2. Two backup formats for users to confuse. 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*, 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, not a per-document draft/published toggle. Astrolabe's central editing model would be a third,
unrelated state concept. unrelated state concept.
4. **Shell mismatch.** Astrolabe's library·editor·preview triptych is a *whole app*. Syto's shell is 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. 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. 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 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/ settings assume Monaco. And neither today has a **Vega-Lite schema service** — that autocomplete/
validation is net-new work on either stack. validation is net-new work on either stack.
7. **TopoJSON / arbitrary-JSON data.** Syto Sources are *tabular*. Astrolabe datasets include 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 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. 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 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." the user value. That's the signature of "harvest, don't port."
--- ---
@@ -138,14 +138,18 @@ the user value. That's the signature of "harvest, don't port."
## 6. Integration Options ## 6. Integration Options
### Option A — Full port (snippet manager inside Syto) ### Option A — Full port (snippet manager inside Syto)
Bring Astrolabe over more-or-less intact: snippet library, dataset manager, draft/published, its Bring Astrolabe over more-or-less intact: snippet library, dataset manager, draft/published, its
shell, its export. shell, its export.
- **Pros:** Fastest way to "have Astrolabe." Feature-complete chart authoring. - **Pros:** Fastest way to "have Astrolabe." Feature-complete chart authoring.
- **Cons:** Maximal parallel-systems debt (§5). Directly contradicts SOUL non-goals. Two data - **Cons:** Maximal parallel-systems debt (§5). Directly contradicts SOUL non-goals. Two data
libraries, two export formats, shell/routing/shortcut conflicts. **Not recommended.** libraries, two export formats, shell/routing/shortcut conflicts. **Not recommended.**
### Option B — Harvest into a native "Visualize" feature ✅ *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:
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 + - 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` X/Y/Color/Size + field-type controls), populated from the Model's columns and `schema-engine`
types. types.
@@ -153,24 +157,26 @@ Add charting as the natural *output* step of a workflow, reusing Syto's own prim
**reference-resolution + fit-mode** rendering contract where the named data resolves to the **reference-resolution + fit-mode** rendering contract where the named data resolves to the
**Model's rows**. **Model's rows**.
- Power users get the **JSON spec editor** (CodeMirror, with a Vega-Lite schema service added) as the - 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*. 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 - 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. 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, - **Dropped from Astrolabe:** separate dataset library, draft/published, snippet search/sort/tags,
storage monitor, its shell, its import/export, its routing scheme. 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 - **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 reuse of existing infra. Defensible against SOUL with a _narrow_ amendment ("single-chart output of
a model," not dashboards/library). a model," not dashboards/library).
- **Cons:** Requires the SOUL decision (§3). Loses Astrolabe features that depend on the - **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, snippet/dataset model (TopoJSON/URL datasets, multi-snippet library). Net-new: schema service,
builder dialog, render contract. builder dialog, render contract.
### Option C — Sibling `/tools/` mini-app ### Option C — Sibling `/tools/` mini-app
Port Astrolabe as a self-contained app under `/tools/astrolabe/`, sharing only *code* (vega render
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. 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). - **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. Lower conceptual conflict. Astrolabe keeps its own model.
- **Cons:** Syto's `/tools/` layer is designed for *small, single-purpose* utilities; Astrolabe is a - **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, 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. just quarantined. "Integration" here means "co-located," not "unified" — limited synergy.
@@ -178,7 +184,7 @@ helpers, CodeMirror wrapper, i18n) with the main app — no AppStore/DialogStore
## 7. Recommendation ## 7. Recommendation
1. **Make the SOUL call first (§3).** Decide whether single-chart *output of a model* is in scope. 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 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. tight scope statement and proceed to Option B.
2. **Pursue Option B.** Harvest the three high-value, well-aligned pieces: 2. **Pursue Option B.** Harvest the three high-value, well-aligned pieces:
@@ -191,14 +197,14 @@ helpers, CodeMirror wrapper, i18n) with the main app — no AppStore/DialogStore
namespace any new hash state under Syto's scheme, resolve the Ctrl+S/Ctrl+K shortcut collisions. 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 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 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. Systems_, and reusing the infrastructure Syto has already built.
--- ---
## 8. Open Questions for the User ## 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.) - **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? - **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. - **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? - **Editor depth:** Is full Vega-Lite schema autocomplete/validation in scope, or is a plain JSON editor + live error surface enough for v1?
+8 -8
View File
@@ -6,21 +6,21 @@
> repository to work from them. > repository to work from them.
> >
> They are the architectural counterpart to [`docs/spec/`](../spec/): the **spec** says > 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 > _what the app does_ (behavior, acceptance points); this **playbook** says _how we build
> it* (state, persistence, modals, routing, rendering, inference, relationships). > it_ (state, persistence, modals, routing, rendering, inference, relationships).
## How to use this playbook ## How to use this playbook
- Building a feature? Read the relevant spec section first (the *what*), then the matching - 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). 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), - Each doc states the pattern, the **rationale** (what problem it solves, what it prevents),
TypeScript sketches in Astrolabe terms, and Do/Don't rules. TypeScript sketches in Astrolabe terms, and Do/Don't rules.
- The sketches are *illustrative*, not finished code. Adapt them; keep the principles. - The sketches are _illustrative_, not finished code. Adapt them; keep the principles.
## The documents ## The documents
| # | Doc | Covers | | # | 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. | | 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. | | 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. | | 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. |
@@ -29,14 +29,14 @@
| 06 | [Type Inference & Profiling](06-type-inference.md) | Pure, portable column-type inference (number/text/date/boolean) and the dataset profile shape. | | 06 | [Type Inference & Profiling](06-type-inference.md) | Pure, portable column-type inference (number/text/date/boolean) and the dataset profile shape. |
| 07 | [Naming & Relationships](07-naming-and-relationships.md) | Unique-name enforcement + import auto-suffix; the bidirectional snippet↔dataset name link; rename propagation into specs. | | 07 | [Naming & Relationships](07-naming-and-relationships.md) | Unique-name enforcement + import auto-suffix; the bidirectional snippet↔dataset name link; rename propagation into specs. |
| 08 | [vega/editor Techniques](08-vega-editor-techniques.md) | Reference brief: borrowable Monaco-schema wiring, vega-embed lifecycle, two-tier validation, and data-flow/debounce techniques distilled from the official Vega-Lite editor — plus where we do better. | | 08 | [vega/editor Techniques](08-vega-editor-techniques.md) | Reference brief: borrowable Monaco-schema wiring, vega-embed lifecycle, two-tier validation, and data-flow/debounce techniques distilled from the official Vega-Lite editor — plus where we do better. |
| 09 | [Visual Design Language](09-visual-design.md) | The *visual* contract: principles inspired by IBM/Carbon, deliberate divergences (square chrome, free color/theming), the token system (Plex type, 8px spacing, role-based color, motion), component conventions, and where to mine the Carbon/IBM source repos for more. Companion: [`visual-specimen.html`](visual-specimen.html). | | 09 | [Visual Design Language](09-visual-design.md) | The _visual_ contract: principles inspired by IBM/Carbon, deliberate divergences (square chrome, free color/theming), the token system (Plex type, 8px spacing, role-based color, motion), component conventions, and where to mine the Carbon/IBM source repos for more. Companion: [`visual-specimen.html`](visual-specimen.html). |
## The non-negotiable layering (every doc assumes this) ## The non-negotiable layering (every doc assumes this)
- **`src/core/`** — portable, pure logic. No browser APIs, no React, no Monaco. Spec - **`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.) 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/stores/`** — Zustand stores. (Doc 01.)
- **`src/app/infrastructure/`** — the *only* place that touches `indexedDB`, `localStorage`, - **`src/app/infrastructure/`** — the _only_ place that touches `indexedDB`, `localStorage`,
or `window.location`. Everything else goes through these typed adapters. (Docs 02, 04.) or `window.location`. Everything else goes through these typed adapters. (Docs 02, 04.)
- **`src/app/services/` & `orchestration/`** — coordination that composes stores + - **`src/app/services/` & `orchestration/`** — coordination that composes stores +
infrastructure + core (lifecycle, routing sync, dependency upkeep). (Docs 03, 04, 07.) infrastructure + core (lifecycle, routing sync, dependency upkeep). (Docs 03, 04, 07.)
+23 -12
View File
@@ -15,7 +15,7 @@ not components" architecture, and it carries no build-time magic. The principles
are the durable part — they would survive a change of library. 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 **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* **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. 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 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 the lowest-migration-risk state library, so signals gave way to Zustand. A bonus: borrowing
@@ -58,7 +58,7 @@ Three ways to touch a store:
- **`set(partial)`** — update state (shallow-merges). Inside actions, the only place - **`set(partial)`** — update state (shallow-merges). Inside actions, the only place
that mutates state. that mutates state.
- **`get()`** — read current state inside actions without subscribing. - **`get()`** — read current state inside actions without subscribing.
- **the hook `useAppStore(selector)`** — read state *in a React component*, subscribing - **the hook `useAppStore(selector)`** — read state _in a React component_, subscribing
to exactly what the selector returns. to exactly what the selector returns.
### Reading in components — always select narrowly ### Reading in components — always select narrowly
@@ -95,7 +95,9 @@ no React involved. This is the property that lets our logic live outside compone
```ts ```ts
openModal('settings'); // via the modal coordinator (doc 03) openModal('settings'); // via the modal coordinator (doc 03)
const theme = useAppStore.getState().uiTheme; // snapshot read const theme = useAppStore.getState().uiTheme; // snapshot read
const unsub = useAppStore.subscribe((s, prev) => { /* react to changes */ }); const unsub = useAppStore.subscribe((s, prev) => {
/* react to changes */
});
``` ```
> Rule: in components, **select narrowly** (and `useShallow` for object/array > Rule: in components, **select narrowly** (and `useShallow` for object/array
@@ -106,7 +108,7 @@ const unsub = useAppStore.subscribe((s, prev) => { /* react to changes */ });
## 2. One Source of Truth per Fact — Derive, Don't Duplicate ## 2. One Source of Truth per Fact — Derive, Don't Duplicate
Every fact lives in exactly one state field. Anything that can be *calculated* 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 from other state is computed **in a selector at read time**, never stored as a
second field you keep in sync by hand. second field you keep in sync by hand.
@@ -121,8 +123,8 @@ read, drift is structurally impossible.
// activeSnippetId: string | null // activeSnippetId: string | null
// Derive in the component's selector — not a stored field: // Derive in the component's selector — not a stored field:
const activeSnippet = useSnippetStore((s) => const activeSnippet = useSnippetStore(
s.snippets.find((x) => x.id === s.activeSnippetId) ?? null, (s) => s.snippets.find((x) => x.id === s.activeSnippetId) ?? null,
); );
const snippetCount = useSnippetStore((s) => s.snippets.length); const snippetCount = useSnippetStore((s) => s.snippets.length);
``` ```
@@ -141,13 +143,13 @@ const active = useSnippetStore(selectActiveSnippet);
``` ```
> Rule: if you can compute it, do not store it. Add a new state field only for a > 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. > value that is _input_ the app receives, not output it derives.
--- ---
## 3. Where State Lives: Central vs. Per-Feature Stores ## 3. Where State Lives: Central vs. Per-Feature Stores
Each store is its own `create()` module. We split by *concern*, not by component Each store is its own `create()` module. We split by _concern_, not by component
tree. tree.
### Per-feature stores ### Per-feature stores
@@ -162,13 +164,13 @@ Each cohesive feature owns a store holding its durable domain state.
### The central `useAppStore` ### The central `useAppStore`
`useAppStore` holds only *cross-cutting, ephemeral UI state* that no single `useAppStore` holds only _cross-cutting, ephemeral UI state_ that no single
feature owns — which modal is open, the runtime theme, transient render flags. feature owns — which modal is open, the runtime theme, transient render flags.
### How to decide ### How to decide
| Put it in a **feature store** when… | Put it in **`useAppStore`** when… | | 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'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 outlives a single interaction | It belongs to no single feature |
| It gets persisted | Multiple unrelated features read/write it | | It gets persisted | Multiple unrelated features read/write it |
@@ -253,7 +255,14 @@ export function SnippetList() {
{snippets.map((s) => ( {snippets.map((s) => (
<li key={s.id} aria-current={s.id === activeSnippetId} onClick={() => select(s.id)}> <li key={s.id} aria-current={s.id === activeSnippetId} onClick={() => select(s.id)}>
{s.name} {s.name}
<button onClick={(e) => { e.stopPropagation(); remove(s.id); }}></button> <button
onClick={(e) => {
e.stopPropagation();
remove(s.id);
}}
>
</button>
</li> </li>
))} ))}
</ul> </ul>
@@ -310,7 +319,9 @@ Subscribers read state and write to `src/app/infrastructure/` adapters (IndexedD
```ts ```ts
// src/main.tsx // src/main.tsx
const applyTheme = (t: string) => { document.documentElement.dataset.theme = t; }; const applyTheme = (t: string) => {
document.documentElement.dataset.theme = t;
};
applyTheme(useAppStore.getState().uiTheme); applyTheme(useAppStore.getState().uiTheme);
useAppStore.subscribe((s, prev) => { useAppStore.subscribe((s, prev) => {
if (s.uiTheme !== prev.uiTheme) applyTheme(s.uiTheme); if (s.uiTheme !== prev.uiTheme) applyTheme(s.uiTheme);
+36 -23
View File
@@ -1,6 +1,6 @@
# 02 · Persistence Architecture # 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*. 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_.
--- ---
@@ -39,7 +39,7 @@ IndexedDB's native API is event-based (`onsuccess`/`onerror`) and verbose. The a
### 2.1 Opening the database ### 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). 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 ```ts
// src/app/infrastructure/db.ts // src/app/infrastructure/db.ts
@@ -96,7 +96,7 @@ function wrap<T>(req: IDBRequest<T>): Promise<T> {
async function tx<T>( async function tx<T>(
store: string, store: string,
mode: IDBTransactionMode, mode: IDBTransactionMode,
run: (s: IDBObjectStore) => IDBRequest<T> run: (s: IDBObjectStore) => IDBRequest<T>,
): Promise<T> { ): Promise<T> {
const db = await openDB(); const db = await openDB();
return new Promise<T>((resolve, reject) => { return new Promise<T>((resolve, reject) => {
@@ -171,7 +171,7 @@ export async function ensureDatasetData(dataset: Dataset): Promise<Dataset['data
## 4. Per-Record Schema Versioning & Read-Time Migration ## 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. 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. 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.
@@ -204,14 +204,18 @@ export async function loadSnippets(): Promise<Snippet[]> {
} }
export async function saveSnippet(s: Snippet): Promise<void> { export async function saveSnippet(s: Snippet): Promise<void> {
await put('snippets', { ...s, version: CURRENT_SNIPPET_VERSION, modified: new Date().toISOString() }); await put('snippets', {
...s,
version: CURRENT_SNIPPET_VERSION,
modified: new Date().toISOString(),
});
} }
``` ```
### Rationale ### 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. - **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. - **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. - **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. > **Do:** default `version` to the earliest shape (`1`) when the field is absent.
@@ -237,8 +241,14 @@ export const CURRENT_SETTINGS_VERSION = 1;
export interface UserSettings { export interface UserSettings {
version: number; version: number;
editor: { fontSize: number; theme: string; minimap: boolean; wordWrap: 'on' | 'off'; editor: {
lineNumbers: 'on' | 'off'; tabSize: number }; fontSize: number;
theme: string;
minimap: boolean;
wordWrap: 'on' | 'off';
lineNumbers: 'on' | 'off';
tabSize: number;
};
performance: { renderDebounce: number }; performance: { renderDebounce: number };
ui: { theme: 'light' | 'dark'; previewFitMode: 'default' | 'width' | 'height' | 'full' }; ui: { theme: 'light' | 'dark'; previewFitMode: 'default' | 'width' | 'height' | 'full' };
formatting: { dateFormat: 'smart' | 'iso' | 'custom'; customDateFormat: string }; formatting: { dateFormat: 'smart' | 'iso' | 'custom'; customDateFormat: string };
@@ -248,8 +258,14 @@ export interface UserSettings {
// contract; this is just where it's encoded. // contract; this is just where it's encoded.
const DEFAULTS: UserSettings = { const DEFAULTS: UserSettings = {
version: CURRENT_SETTINGS_VERSION, version: CURRENT_SETTINGS_VERSION,
editor: { fontSize: 12, theme: 'auto', minimap: false, wordWrap: 'on', editor: {
lineNumbers: 'on', tabSize: 2 }, fontSize: 12,
theme: 'auto',
minimap: false,
wordWrap: 'on',
lineNumbers: 'on',
tabSize: 2,
},
performance: { renderDebounce: 1500 }, performance: { renderDebounce: 1500 },
ui: { theme: 'light', previewFitMode: 'default' }, ui: { theme: 'light', previewFitMode: 'default' },
formatting: { dateFormat: 'smart', customDateFormat: '' }, formatting: { dateFormat: 'smart', customDateFormat: '' },
@@ -324,7 +340,7 @@ const SORT_DEFAULTS = { sortBy: 'modified' as const, sortOrder: 'desc' as const
Astrolabe has three tiers with different capacities and risk profiles: Astrolabe has three tiers with different capacities and risk profiles:
| Tier | Backing | Holds | Budget & behavior | | Tier | Backing | Holds | Budget & behavior |
|------|---------|-------|-------------------| | -------------------- | -------------------- | ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Snippet store** | IndexedDB `snippets` | All snippet records | Practical budget ~**5 MB**. A storage monitor estimates usage and surfaces a warning as it fills. Snippets are user-authored and irreplaceable, so we fail **loudly**. | | **Snippet store** | IndexedDB `snippets` | All snippet records | Practical budget ~**5 MB**. A storage monitor estimates usage and surfaces a warning as it fills. Snippets are user-authored and irreplaceable, so we fail **loudly**. |
| **Dataset store** | IndexedDB `datasets` | All dataset payloads | Separate, **high-capacity**; suited to large payloads. Lazily loaded (§3). | | **Dataset store** | IndexedDB `datasets` | All dataset payloads | Separate, **high-capacity**; suited to large payloads. Lazily loaded (§3). |
| **Settings & prefs** | localStorage | `UserSettings` + app/UI prefs (§5) | Small; effectively unbounded for this use. | | **Settings & prefs** | localStorage | `UserSettings` + app/UI prefs (§5) | Small; effectively unbounded for this use. |
@@ -349,10 +365,7 @@ const WARN_AT = 0.8;
export async function reportSnippetUsage(snippets: Snippet[]): Promise<StorageReport> { export async function reportSnippetUsage(snippets: Snippet[]): Promise<StorageReport> {
// Cheap, deterministic estimate: serialize the records we hold. // Cheap, deterministic estimate: serialize the records we hold.
const snippetBytes = snippets.reduce( const snippetBytes = snippets.reduce((n, s) => n + new Blob([JSON.stringify(s)]).size, 0);
(n, s) => n + new Blob([JSON.stringify(s)]).size,
0
);
const ratio = snippetBytes / SNIPPET_BUDGET; const ratio = snippetBytes / SNIPPET_BUDGET;
const report: StorageReport = { const report: StorageReport = {
snippetBytes, snippetBytes,
@@ -361,9 +374,7 @@ export async function reportSnippetUsage(snippets: Snippet[]): Promise<StorageRe
warn: ratio >= WARN_AT, warn: ratio >= WARN_AT,
}; };
if (report.warn) { if (report.warn) {
console.warn( console.warn(`[storage] snippet tier ${(ratio * 100).toFixed(0)}% of ${SNIPPET_BUDGET} bytes`);
`[storage] snippet tier ${(ratio * 100).toFixed(0)}% of ${SNIPPET_BUDGET} bytes`
);
} }
return report; return report;
} }
@@ -380,15 +391,17 @@ export async function saveSnippet(s: Snippet): Promise<void> {
} catch (err) { } catch (err) {
if (err instanceof DOMException && err.name === 'QuotaExceededError') { if (err instanceof DOMException && err.name === 'QuotaExceededError') {
// Surface to the user via the store; do NOT silently drop the write. // Surface to the user via the store; do NOT silently drop the write.
throw new StorageQuotaError('Snippet storage is full. Export and remove snippets to free space.'); throw new StorageQuotaError(
'Snippet storage is full. Export and remove snippets to free space.',
);
} }
throw err; throw err;
} }
} }
``` ```
> **Do:** surface quota warnings *before* the budget is hit (the 80% threshold) and hard errors loudly when a write fails. > **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 the adapter may safely swallow is a *read* failure, where falling back to defaults/empty is the correct behavior. > **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 the adapter may safely swallow is a _read_ failure, where falling back to defaults/empty is the correct behavior.
--- ---
@@ -396,8 +409,8 @@ export async function saveSnippet(s: Snippet): Promise<void> {
1. Define the record type with `id`, `created`, `modified`, and a `version` field. 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). 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*. 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. 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. 5. Expose typed `load*/save*/ensure*` functions from one infrastructure module — and from _only_ there.
6. If the tier has a budget, hook it into the storage monitor and propagate `QuotaExceededError`. 6. If the tier has a budget, hook it into the storage monitor and propagate `QuotaExceededError`.
7. Test the adapter against `fake-indexeddb` / a localStorage stub; test the migration with fixtures from each historical version. 7. Test the adapter against `fake-indexeddb` / a localStorage stub; test the migration with fixtures from each historical version.
+44 -17
View File
@@ -20,7 +20,7 @@ authoritative architecture for adding, opening, closing, and rendering modals.
The system is three layers, each with a single responsibility: The system is three layers, each with a single responsibility:
| Layer | Responsibility | Lives in | | Layer | Responsibility | Lives in |
|-------|----------------|----------| | --------------- | ----------------------------------------------------------- | ----------------------------------------- |
| **Registry** | Static metadata per modal (title, validity, snapshot, init) | `src/app/modals/modal-registry.ts` | | **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` | | **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 | | **Shell** | Render exactly one modal; backdrop / Escape / focus trap | `src/app/App.tsx` + a `useFocusTrap` hook |
@@ -149,15 +149,25 @@ export const MODAL_REGISTRY: Record<ModalName, ModalConfig> = {
init: (sourceKey) => useExtractStore.getState().initFrom(sourceKey), init: (sourceKey) => useExtractStore.getState().initFrom(sourceKey),
getState: () => ({ name: useExtractStore.getState().name }), getState: () => ({ name: useExtractStore.getState().name }),
hasError: () => useExtractStore.getState().name.trim() === '', hasError: () => useExtractStore.getState().name.trim() === '',
getError: () => getError: () => (useExtractStore.getState().name.trim() ? null : 'modals.extract.nameRequired'),
useExtractStore.getState().name.trim() ? null : 'modals.extract.nameRequired',
}, },
// Applies immediately — no getState, so closing never prompts. // Applies immediately — no getState, so closing never prompts.
settings: { name: 'settings', title: 'modals.settings.title', component: SettingsModal, isUrlNavigable: true, init: () => useSettingsStore.getState().loadFromPrefs() }, settings: {
name: 'settings',
title: 'modals.settings.title',
component: SettingsModal,
isUrlNavigable: true,
init: () => useSettingsStore.getState().loadFromPrefs(),
},
// Pure info modals — no state, no validity, not navigable for donate. // Pure info modals — no state, no validity, not navigable for donate.
about: { name: 'about', title: 'modals.about.title', component: AboutModal, isUrlNavigable: true }, about: {
name: 'about',
title: 'modals.about.title',
component: AboutModal,
isUrlNavigable: true,
},
donate: { name: 'donate', title: 'modals.donate.title', component: DonateModal }, donate: { name: 'donate', title: 'modals.donate.title', component: DonateModal },
}; };
``` ```
@@ -171,8 +181,7 @@ directly:
export const getModalConfig = (name: ActiveModal): ModalConfig | undefined => export const getModalConfig = (name: ActiveModal): ModalConfig | undefined =>
name ? MODAL_REGISTRY[name] : undefined; name ? MODAL_REGISTRY[name] : undefined;
export const getModalTitle = (name: ActiveModal): string => export const getModalTitle = (name: ActiveModal): string => getModalConfig(name)?.title ?? '';
getModalConfig(name)?.title ?? '';
export const isUrlNavigable = (name: ActiveModal): boolean => export const isUrlNavigable = (name: ActiveModal): boolean =>
getModalConfig(name)?.isUrlNavigable ?? false; getModalConfig(name)?.isUrlNavigable ?? false;
@@ -185,12 +194,14 @@ export const isUrlNavigable = (name: ActiveModal): boolean =>
> component. > component.
**Do** **Do**
- Add a modal by appending one `MODAL_REGISTRY` entry and writing its component. - Add a modal by appending one `MODAL_REGISTRY` entry and writing its component.
- Express validity through `hasError` / `getError` so the shell's action button - Express validity through `hasError` / `getError` so the shell's action button
and tooltip stay generic. and tooltip stay generic.
- Omit `getState` for any modal that commits changes immediately. - Omit `getState` for any modal that commits changes immediately.
**Don't** **Don't**
- Don't `switch (activeModal)` outside the shell's body render. Lookups belong - Don't `switch (activeModal)` outside the shell's body render. Lookups belong
in registry helpers. in registry helpers.
- Don't put rendering or DOM concerns in the registry — it is pure metadata. - Don't put rendering or DOM concerns in the registry — it is pure metadata.
@@ -231,15 +242,15 @@ import { MODAL_REGISTRY, getModalConfig } from './modal-registry';
import { syncModalToUrl, clearModalFromUrl } from './UrlStateSync'; import { syncModalToUrl, clearModalFromUrl } from './UrlStateSync';
let confirmDiscard: (msg: string) => Promise<boolean> = async () => true; let confirmDiscard: (msg: string) => Promise<boolean> = async () => true;
export const setConfirm = (fn: typeof confirmDiscard) => { confirmDiscard = fn; }; export const setConfirm = (fn: typeof confirmDiscard) => {
confirmDiscard = fn;
};
// Coordinator-internal: the getState() JSON captured at open, compared on close. // Coordinator-internal: the getState() JSON captured at open, compared on close.
let stateSnapshot: string | null = null; let stateSnapshot: string | null = null;
const snapshot = (name: ActiveModal) => const snapshot = (name: ActiveModal) =>
getModalConfig(name)?.getState getModalConfig(name)?.getState ? JSON.stringify(getModalConfig(name)!.getState!()) : null;
? JSON.stringify(getModalConfig(name)!.getState!())
: null;
/** Open `name`, optionally with a sub-target (dataset id, source key). */ /** Open `name`, optionally with a sub-target (dataset id, source key). */
export function openModal(name: ModalName, arg?: string): void { export function openModal(name: ModalName, arg?: string): void {
@@ -306,11 +317,13 @@ export const activeModalError = (): string | null =>
> directly could bypass the discard check or leave the URL stale. > directly could bypass the discard check or leave the URL stale.
**Do** **Do**
- Route every open/close through `openModal` / `closeModal`. - Route every open/close through `openModal` / `closeModal`.
- Take the snapshot in `openModal` (after `init`) and compare in `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. - Keep the coordinator DOM-free so it can be tested with plain Vitest.
**Don't** **Don't**
- Don't mutate `activeModal` directly from components or handlers. - Don't mutate `activeModal` directly from components or handlers.
- Don't skip `closeModal`'s unsaved-change check by toggling state manually; - Don't skip `closeModal`'s unsaved-change check by toggling state manually;
pass `force` only when the user has explicitly saved or confirmed. pass `force` only when the user has explicitly saved or confirmed.
@@ -351,7 +364,9 @@ export function App() {
<div <div
className={styles.backdrop} className={styles.backdrop}
onClick={() => void closeModal()} // backdrop dismisses onClick={() => void closeModal()} // backdrop dismisses
onKeyDown={(e) => { if (e.key === 'Escape') void closeModal(); }} onKeyDown={(e) => {
if (e.key === 'Escape') void closeModal();
}}
> >
<div <div
ref={modalRef} ref={modalRef}
@@ -363,7 +378,9 @@ export function App() {
> >
<header className={styles.modalHeader}> <header className={styles.modalHeader}>
<h2 id="modal-title">{t(getModalTitle(name))}</h2> <h2 id="modal-title">{t(getModalTitle(name))}</h2>
<button aria-label={t('buttons.close')} onClick={() => void closeModal()}>×</button> <button aria-label={t('buttons.close')} onClick={() => void closeModal()}>
×
</button>
</header> </header>
<div className={styles.modalBody}> <div className={styles.modalBody}>
@@ -382,7 +399,9 @@ export function App() {
className="btn-primary" className="btn-primary"
aria-disabled={hasError || undefined} aria-disabled={hasError || undefined}
title={errorMsg ? t(errorMsg) : undefined} title={errorMsg ? t(errorMsg) : undefined}
onClick={() => { if (!hasError) config.component /* invoke save handler */; }} onClick={() => {
if (!hasError) config.component /* invoke save handler */;
}}
> >
{t('buttons.save')} {t('buttons.save')}
</button> </button>
@@ -428,9 +447,15 @@ export function useFocusTrap<T extends HTMLElement = HTMLDivElement>(active: boo
if (e.key !== 'Tab') return; if (e.key !== 'Tab') return;
const f = el.querySelectorAll<HTMLElement>(FOCUSABLE); const f = el.querySelectorAll<HTMLElement>(FOCUSABLE);
if (!f.length) return; if (!f.length) return;
const first = f[0], last = f[f.length - 1]; const first = f[0],
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); } last = f[f.length - 1];
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); } 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); el.addEventListener('keydown', onKey);
@@ -451,6 +476,7 @@ export function useFocusTrap<T extends HTMLElement = HTMLDivElement>(active: boo
> accessibility is fixed once. > accessibility is fixed once.
**Do** **Do**
- Render the active modal via `<config.component />` — the single mapping point. - Render the active modal via `<config.component />` — the single mapping point.
- Put `onClick={closeModal}` on the backdrop and `stopPropagation` on the body. - Put `onClick={closeModal}` on the backdrop and `stopPropagation` on the body.
- Compute `hasError`/`getError`/preview reads with a selector at the shell level. - Compute `hasError`/`getError`/preview reads with a selector at the shell level.
@@ -458,6 +484,7 @@ export function useFocusTrap<T extends HTMLElement = HTMLDivElement>(active: boo
tooltip. tooltip.
**Don't** **Don't**
- Don't render two modals simultaneously, and don't stack a second backdrop. - 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 - Don't attach the focus trap to the backdrop — attach it to the modal body so
the backdrop click stays outside the trap. the backdrop click stays outside the trap.
+21 -9
View File
@@ -34,7 +34,7 @@ Zustand stores. Components never read `location.hash` or attach
The hash is the serialized view. Astrolabe's forms: The hash is the serialized view. Astrolabe's forms:
| State | Hash | | State | Hash |
| ----------------------------- | --------------------------------- | | --------------------------- | ------------------------------ |
| Default snippets view | _(empty / absent)_ | | Default snippets view | _(empty / absent)_ |
| A selected snippet | `#snippet-<id>` | | A selected snippet | `#snippet-<id>` |
| Datasets manager (list) | `#datasets` | | Datasets manager (list) | `#datasets` |
@@ -88,12 +88,18 @@ export function parseHash(rawHash: string): ViewState {
export function serializeHash(view: ViewState): string { export function serializeHash(view: ViewState): string {
switch (view.kind) { switch (view.kind) {
case 'snippets': return ''; case 'snippets':
case 'snippet': return `#snippet-${view.snippetId}`; return '';
case 'datasets': return '#datasets'; case 'snippet':
case 'dataset': return `#datasets/dataset-${view.datasetId}`; return `#snippet-${view.snippetId}`;
case 'dataset-new': return '#datasets/new'; case 'datasets':
case 'dataset-build': return `#datasets/dataset-${view.datasetId}/build`; return '#datasets';
case 'dataset':
return `#datasets/dataset-${view.datasetId}`;
case 'dataset-new':
return '#datasets/new';
case 'dataset-build':
return `#datasets/dataset-${view.datasetId}/build`;
} }
} }
@@ -165,7 +171,10 @@ function applyView(view: ViewState): void {
return; return;
case 'snippet': { case 'snippet': {
const snippet = useSnippetStore.getState().byId(view.snippetId); const snippet = useSnippetStore.getState().byId(view.snippetId);
if (!snippet) { replaceView({ kind: 'snippets' }); return; } if (!snippet) {
replaceView({ kind: 'snippets' });
return;
}
useAppStore.getState().setActiveModal(null); useAppStore.getState().setActiveModal(null);
useSnippetStore.getState().select(view.snippetId); useSnippetStore.getState().select(view.snippetId);
return; return;
@@ -176,7 +185,10 @@ function applyView(view: ViewState): void {
case 'dataset': case 'dataset':
case 'dataset-build': { case 'dataset-build': {
const ds = useDatasetStore.getState().byId(view.datasetId); const ds = useDatasetStore.getState().byId(view.datasetId);
if (!ds) { replaceView({ kind: 'datasets' }); return; } if (!ds) {
replaceView({ kind: 'datasets' });
return;
}
useAppStore.getState().setActiveModal('datasets'); useAppStore.getState().setActiveModal('datasets');
useDatasetStore.getState().select(view.datasetId); useDatasetStore.getState().select(view.datasetId);
if (view.kind === 'dataset-build') useAppStore.getState().setActiveModal('chartBuilder'); if (view.kind === 'dataset-build') useAppStore.getState().setActiveModal('chartBuilder');
@@ -5,7 +5,7 @@ the preview pane. This covers four mechanics: **embedding** a spec via
`vega-embed`, **theming** so charts match the active UI theme, **debounced `vega-embed`, **theming** so charts match the active UI theme, **debounced
re-rendering** so typing stays smooth, and **error handling** so a broken spec 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 produces a readable message and self-heals. It deliberately stops at the
embedding boundary — the *content* of the spec (resolving named-dataset embedding boundary — the _content_ of the spec (resolving named-dataset
references, applying fit-mode sizing) is prepared upstream by a pure transform; references, applying fit-mode sizing) is prepared upstream by a pure transform;
see §6. see §6.
@@ -82,7 +82,7 @@ export async function renderSpec(
Every successful `vegaEmbed` returns a `result.view` (a live Vega `View` 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 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 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. listeners keep firing and resources accumulate over a long editing session.
The renderer that drives re-rendering must therefore hold the previous handle and The renderer that drives re-rendering must therefore hold the previous handle and
@@ -181,7 +181,7 @@ re-rendering picks up the new config and the chart restyles automatically.
### Rules ### Rules
- **Do** keep `chartConfigFor` as the *only* place that maps a UI theme to a Vega - **Do** keep `chartConfigFor` as the _only_ place that maps a UI theme to a Vega
config. Adding a UI theme = adding one config and one map entry. config. Adding a UI theme = adding one config and one map entry.
- **Do** set chart `background: 'transparent'` so the pane's own background shows - **Do** set chart `background: 'transparent'` so the pane's own background shows
through and theme switches look seamless. through and theme switches look seamless.
@@ -233,8 +233,8 @@ export function escapeVegaField(name: string): string {
encoding.x = { field: escapeVegaField(columnName), type: 'quantitative' }; encoding.x = { field: escapeVegaField(columnName), type: 'quantitative' };
``` ```
This matters wherever Astrolabe *constructs* spec fragments from data-derived This matters wherever Astrolabe _constructs_ spec fragments from data-derived
column names — most notably the chart builder (see *Chart Builder* spec) and any 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 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:` the user's responsibility; Astrolabe does not rewrite hand-authored `field:`
values. values.
@@ -297,11 +297,17 @@ export function createDebouncedRenderer(opts: {
timer = setTimeout(run, opts.delayMs()); timer = setTimeout(run, opts.delayMs());
}, },
flush() { flush() {
if (timer) { clearTimeout(timer); timer = null; } if (timer) {
clearTimeout(timer);
timer = null;
}
void run(); void run();
}, },
cancel() { cancel() {
if (timer) { clearTimeout(timer); timer = null; } if (timer) {
clearTimeout(timer);
timer = null;
}
generation++; // abandon any in-flight result generation++; // abandon any in-flight result
}, },
}; };
@@ -330,7 +336,7 @@ useSettingsStore.subscribe((s, prev) => {
### Busy indicator ### Busy indicator
`setBusy(true/false)` toggles store state that the preview reads to overlay a `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 **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 last good render stays visible while the next one computes — the pane never goes
blank mid-edit. blank mid-edit.
@@ -366,8 +372,8 @@ It does two deterministic things, on a **deep copy** of the spec:
Vega-Lite's `"container"` keyword (Original = untouched; Width/Height/Full set Vega-Lite's `"container"` keyword (Original = untouched; Width/Height/Full set
the corresponding dimension(s) to `"container"`), recursing the same way. the corresponding dimension(s) to `"container"`), recursing the same way.
This is *content* preparation, not embedding, and it is fully covered by the This is _content_ preparation, not embedding, and it is fully covered by the
*Live Preview* spec. The only invariant this doc cares about: _Live Preview_ spec. The only invariant this doc cares about:
> `prepareSpecForRender` runs on a copy and returns a new spec. The renderer > `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 > embeds that returned spec. **The user's stored spec is never mutated by
@@ -393,7 +399,7 @@ area and recover on its own once the spec is valid again. Errors arise at three
stages, all funneled to one error field the preview reads: stages, all funneled to one error field the preview reads:
| Stage | Failure | Surfaced as | | Stage | Failure | Surfaced as |
|---|---|---| | -------------------------------- | -------------------------------------- | ---------------------- |
| Parse | Invalid JSON | "Invalid JSON: …" | | Parse | Invalid JSON | "Invalid JSON: …" |
| Prepare (`prepareSpecForRender`) | Referenced dataset missing/unfetchable | "Dataset not found: …" | | Prepare (`prepareSpecForRender`) | Referenced dataset missing/unfetchable | "Dataset not found: …" |
| Embed (`vega-embed`) | Vega-Lite compile / data error | "Rendering error: …" | | Embed (`vega-embed`) | Vega-Lite compile / data error | "Rendering error: …" |
@@ -427,7 +433,9 @@ async function render(): Promise<void> {
current = await renderSpec(node, prepared, config); current = await renderSpec(node, prepared, config);
usePreviewStore.getState().setError(null); // success clears any prior error usePreviewStore.getState().setError(null); // success clears any prior error
} catch (e) { } catch (e) {
usePreviewStore.getState().setError( usePreviewStore
.getState()
.setError(
`Rendering error: ${(e as Error).message}. ` + `Rendering error: ${(e as Error).message}. ` +
`Check your JSON syntax and that the spec is valid Vega-Lite.`, `Check your JSON syntax and that the spec is valid Vega-Lite.`,
); );
@@ -458,11 +466,11 @@ manual retry, no reload.
## Summary ## Summary
| Concern | Mechanism | Source of truth | | Concern | Mechanism | Source of truth |
|---|---|---| | ------------- | ----------------------------------------------------------------------- | ----------------------------------------------- |
| Embedding | One `renderSpec` over `vega-embed`, `actions: false`, `renderer: 'svg'` | `src/app/services/chart-renderer.ts` | | 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` | | 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` | | 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` | | Field names | `escapeVegaField` on every data-derived `field:` | `src/core/rendering.ts` |
| Debounce | `createDebouncedRenderer`, delay from `renderDebounce` setting | `src/app/services/debounced-renderer.ts` | | Debounce | `createDebouncedRenderer`, delay from `renderDebounce` setting | `src/app/services/debounced-renderer.ts` |
| Spec prep | `prepareSpecForRender` (pure, on a copy) | `src/core/rendering.ts` (see *Live Preview*) | | Spec prep | `prepareSpecForRender` (pure, on a copy) | `src/core/rendering.ts` (see _Live Preview_) |
| Errors | One error field, cleared on success, empty = nothing | `PreviewStore.error` | | Errors | One error field, cleared on success, empty = nothing | `PreviewStore.error` |
+7 -7
View File
@@ -13,7 +13,7 @@ flow, the detail panel) calls into this module; nothing here reaches back out.
## 1. Why infer types at all ## 1. Why infer types at all
A dataset is just rows of values. The UI wants to *describe* it without 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 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 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 small type indicator next to each column name in the dataset detail panel and
@@ -50,7 +50,7 @@ Given the values of a single column, decide its type.
entirely empty, or there are zero rows), default to `string`. There is no entirely empty, or there are zero rows), default to `string`. There is no
evidence for any other type. evidence for any other type.
3. **Run the type checks in precedence order.** For each candidate type, ask: 3. **Run the type checks in precedence order.** For each candidate type, ask:
*does **every** surviving value match this type?* The first candidate for _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, 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 else fall back"** rule: one stray value that doesn't fit knocks the column
down to the next candidate, and ultimately to `string`. down to the next candidate, and ultimately to `string`.
@@ -62,7 +62,7 @@ overlap, and we want the most specific interpretation that fits.
1. **boolean** first. The strings `"true"`/`"false"` are not numbers and not 1. **boolean** first. The strings `"true"`/`"false"` are not numbers and not
dates, so booleans never collide with the other checks — but putting them 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* 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.) treat `0`/`1` as boolean; that's a number column.)
2. **number** second. `Number("2024")` is a perfectly good number, so a 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 of bare years would read as `number` — which is the honest answer. Numbers
@@ -82,7 +82,7 @@ overlap, and we want the most specific interpretation that fits.
whitespace so `Number("") === 0` doesn't sneak through. whitespace so `Number("") === 0` doesn't sneak through.
- **boolean**: native `boolean` values pass; otherwise the trimmed, - **boolean**: native `boolean` values pass; otherwise the trimmed,
lower-cased string must be exactly `"true"` or `"false"`. lower-cased string must be exactly `"true"` or `"false"`.
- **date**: guard *before* parsing. Require the trimmed value to look - **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** 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 then confirm `Date.parse` returns a finite timestamp. The shape guard is
essential: `Date.parse` will happily accept `"42"` or `"March"` on some essential: `Date.parse` will happily accept `"42"` or `"March"` on some
@@ -155,7 +155,7 @@ export function inferColumnType(values: readonly unknown[]): ColumnType {
- **Do** ignore empty cells before classifying. - **Do** ignore empty cells before classifying.
- **Do** keep the precedence boolean → number → date → string. - **Do** keep the precedence boolean → number → date → string.
- **Do** guard date detection with a shape regex before trusting `Date.parse`. - **Do** guard date detection with a shape regex before trusting `Date.parse`.
- **Don't** classify a column unless *every* present value matches — one - **Don't** classify a column unless _every_ present value matches — one
outlier means `string`. outlier means `string`.
- **Don't** add more types (integer, float, datetime, json). Four, no more. - **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 - **Don't** let `Number("")`, `Date.parse("42")`, or `0`/`1` leak into the wrong
@@ -170,7 +170,7 @@ the UI can describe it without re-parsing the payload. Per the data model, a
profiled dataset carries: profiled dataset carries:
| Field | Type | Meaning | | Field | Type | Meaning |
| ------------- | --------------------------------- | -------------------------------------- | | ------------- | ----------------------- | ---------------------------------- |
| `rowCount` | `number \| null` | Data rows, or `null` when N/A. | | `rowCount` | `number \| null` | Data rows, or `null` when N/A. |
| `columnCount` | `number \| null` | Columns, or `null` when N/A. | | `columnCount` | `number \| null` | Columns, or `null` when N/A. |
| `columns` | `string[]` | Column names, in order. | | `columns` | `string[]` | Column names, in order. |
@@ -274,7 +274,7 @@ export function profileData(
} }
``` ```
Parsing CSV/TSV text and detecting the payload shape happen *upstream* of Parsing CSV/TSV text and detecting the payload shape happen _upstream_ of
`profileData`; this function takes already-parsed rows so it stays pure and `profileData`; this function takes already-parsed rows so it stays pure and
trivially testable. The caller passes `null` for URL and non-tabular datasets. trivially testable. The caller passes `null` for URL and non-tabular datasets.
@@ -10,7 +10,7 @@ Two concerns live here, and they reinforce each other:
key users see and the key snippets reference, so duplicates would be key users see and the key snippets reference, so duplicates would be
ambiguous. We reject duplicate names on create/rename, and auto-suffix ambiguous. We reject duplicate names on create/rename, and auto-suffix
collisions during bulk import. collisions during bulk import.
2. **Relationship tracking** — a snippet references datasets *by name* through 2. **Relationship tracking** — a snippet references datasets _by name_ through
its `datasetRefs: string[]` field. This is a bidirectional, name-based link: 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 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 who uses it. Renaming a dataset must propagate to every snippet that points
@@ -25,7 +25,7 @@ read and mutate stores live in `src/app/services/`.
Datasets carry a numeric `id`, but snippets reference them **by name** because 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 that is what Vega-Lite uses: a spec resolves data through a named-data
reference, `{ "data": { "name": "MyDataset" } }`. The name *is* the contract reference, `{ "data": { "name": "MyDataset" } }`. The name _is_ the contract
between a spec and the dataset library. Storing a numeric id in the spec would 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. mean the spec is no longer a standalone, paste-anywhere Vega-Lite document.
@@ -122,7 +122,7 @@ export function makeUniqueName(desired: string, existingNames: Iterable<string>)
- **Forward** (snippet → datasets): read `snippet.datasetRefs`. Cheap, stored. - **Forward** (snippet → datasets): read `snippet.datasetRefs`. Cheap, stored.
- **Reverse** (dataset → snippets): there is no stored back-pointer. We compute - **Reverse** (dataset → snippets): there is no stored back-pointer. We compute
it by scanning snippets. Keeping it *derived* means it can never disagree with it by scanning snippets. Keeping it _derived_ means it can never disagree with
the forward links — there is one source of truth. the forward links — there is one source of truth.
`datasetRefs` is **derived from the spec**, not hand-maintained. It is `datasetRefs` is **derived from the spec**, not hand-maintained. It is
@@ -216,9 +216,9 @@ import type { Snippet } from '../../core/types';
/** Snippets whose datasetRefs include `name` (case-insensitive). */ /** Snippets whose datasetRefs include `name` (case-insensitive). */
export function findSnippetsReferencingDataset(name: string): Snippet[] { export function findSnippetsReferencingDataset(name: string): Snippet[] {
const lower = name.toLowerCase(); const lower = name.toLowerCase();
return useSnippetStore.getState().snippets.filter((s) => return useSnippetStore
s.datasetRefs.some((ref) => ref.toLowerCase() === lower), .getState()
); .snippets.filter((s) => s.datasetRefs.some((ref) => ref.toLowerCase() === lower));
} }
/** Count for the usage badge. */ /** Count for the usage badge. */
@@ -240,7 +240,7 @@ they update the moment any snippet is published with changed refs.
**Don't** **Don't**
- Don't add a `referencedBy` array to datasets. A stored reverse pointer is a - 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`. second source of truth that _will_ fall out of sync with `datasetRefs`.
--- ---
@@ -249,7 +249,7 @@ they update the moment any snippet is published with changed refs.
On import we never overwrite an existing dataset. A dataset whose name collides On import we never overwrite an existing dataset. A dataset whose name collides
is renamed to a unique name via `makeUniqueName`, and **every rename is 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 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 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`. incoming `Sales` datasets become `Sales 2` and `Sales 3`, not two `Sales 2`.
```ts ```ts
@@ -292,7 +292,7 @@ export function dedupeIncomingDatasetNames(
**Do** **Do**
- Reserve each chosen name immediately so collisions *within* one import are - Reserve each chosen name immediately so collisions _within_ one import are
also resolved. also resolved.
- Return the rename list and show it; a silent rename looks like data loss. - Return the rename list and show it; a silent rename looks like data loss.
@@ -331,7 +331,8 @@ export function renameDatasetInSpec(spec: Json, oldName: string, newName: string
for (const [k, v] of Object.entries(node as Record<string, Json>)) { for (const [k, v] of Object.entries(node as Record<string, Json>)) {
if ( if (
k === 'data' && k === 'data' &&
v && typeof v === 'object' && v &&
typeof v === 'object' &&
(v as Record<string, Json>).name === oldName (v as Record<string, Json>).name === oldName
) { ) {
out[k] = { ...(v as object), name: newName }; out[k] = { ...(v as object), name: newName };
@@ -417,7 +418,7 @@ export function renameDatasetEverywhere(oldName: string, newName: string): { upd
## 7. Where things live ## 7. Where things live
| Concern | Location | Pure? | Tested | | Concern | Location | Pure? | Tested |
|---|---|---|---| | --------------------------------------------- | ----------------------------------------- | ------------------------------ | ---------------- |
| `makeUniqueName`, `isNameTaken` | `src/core/naming.ts` | yes | unit | | `makeUniqueName`, `isNameTaken` | `src/core/naming.ts` | yes | unit |
| `extractDatasetRefs`, `recomputeDatasetRefs` | `src/core/spec-refs.ts` | yes | unit | | `extractDatasetRefs`, `recomputeDatasetRefs` | `src/core/spec-refs.ts` | yes | unit |
| `renameDatasetInSpec` | `src/core/spec-refs.ts` | yes | unit | | `renameDatasetInSpec` | `src/core/spec-refs.ts` | yes | unit |
@@ -427,6 +428,6 @@ export function renameDatasetEverywhere(oldName: string, newName: string): { upd
The dividing line: anything that takes plain data and returns plain data is 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 **core** and unit-tested in isolation; anything that reaches into a Zustand store
is an **app service**. The rename rule of thumb — *the spec is the source of is an **app service**. The rename rule of thumb — _the spec is the source of
truth, `datasetRefs` mirrors it, the reverse lookup is derived* — is what keeps truth, `datasetRefs` mirrors it, the reverse lookup is derived_ — is what keeps
the bidirectional link from ever needing manual repair. the bidirectional link from ever needing manual repair.
+17 -16
View File
@@ -21,9 +21,9 @@ that tree.
## Stack delta (read this first — it changes how directly we can borrow) ## Stack delta (read this first — it changes how directly we can borrow)
| | vega/editor | Astrolabe | | | vega/editor | Astrolabe |
|---|---|---| | ----------------- | -------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- |
| UI framework | **React** | **React** (moved off Preact before build start) | | UI framework | **React** | **React** (moved off Preact before build start) |
| State | Redux-ish single `State` in React context (`useState`) | Zustand **stores***not* Redux | | 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**) | | 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) | | 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 | | Schema validation | Monaco JSON worker **+** standalone `ajv ^8` (two independent layers) | same two-layer model planned |
@@ -48,14 +48,15 @@ assets are hashed files in `dist/` that Workbox precaches automatically; (2) **p
third-party fetch on load contradicts SOUL's "the only outbound requests are user-created 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 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 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. 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.)** **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 The wrapper helps with the easy 80% (mount a JSON editor, lifecycle) and adds nothing to the
load-bearing 20% this app needs: load-bearing 20% this app needs:
- **Workers** are still ours — the wrapper never manages `MonacoEnvironment` (see §1 gotcha). - **Workers** are still ours — the wrapper never manages `MonacoEnvironment` (see §1 gotcha).
- The **M2 schema service** (`jsonDefaults.setDiagnosticsOptions`, `fileMatch`) is namespace-level; - The **M2 schema service** (`jsonDefaults.setDiagnosticsOptions`, `fileMatch`) is namespace-level;
you reach *through* the wrapper via `onMount`, so it saves nothing there. 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 - 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 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. stays fluid" — you end up using it uncontrolled, i.e. the raw pattern anyway.
@@ -78,7 +79,7 @@ price of offline, paid in any non-CDN setup, and the wrapper would not remove it
Self-hosting raw Monaco forces a choice of ESM entry point, and the granularity matters: Self-hosting raw Monaco forces a choice of ESM entry point, and the granularity matters:
| Import | What you get | Use? | | Import | What you get | Use? |
|---|---|---| | --------------------------- | ------------------------------------------------------------------- | -------------------------------------------------------------------------------------- |
| `monaco-editor` (barrel) | All features **+ every basic language** (sql, abap, solidity, …) | ❌ language bloat (~20 dead chunks) | | `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/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 | | `esm/vs/editor/edcore.main` | `editor.all` (all 59 feature contributions) + API, **no languages** | ✅ full editor UX, JSON-only weight |
@@ -100,7 +101,7 @@ JSON strings, where Monaco disables auto-suggest by default).
> **The single biggest surprise:** vega/editor does **not** use `vega-embed` for its live > **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 > 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 > 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 > 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 > version (`src/components/renderer/renderer.tsx`) is the best available documentation of the
> lifecycle/cleanup discipline `vega-embed` still expects from us. > lifecycle/cleanup discipline `vega-embed` still expects from us.
@@ -180,7 +181,7 @@ vega/editor's hand-rolled renderer (`src/components/renderer/renderer.tsx`) reve
**Gotchas / where we improve:** **Gotchas / where we improve:**
- ⚠️ **Finalize before re-embed, or leak.** Every spec change must `view.finalize()` the old - ⚠️ **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` 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 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. 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 - ⚠️ **Race on rapid edits.** `runAsync` is async; a stale render can resolve after a newer one
@@ -201,7 +202,7 @@ sorts errors into two tiers. Both are worth copying.
1. **Monaco JSON worker** → inline **squiggles, hovers, autocomplete** in the editor. 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**. 2. **`ajv ^8`** (`src/utils/validate.ts`) → runs at parse time, feeds the **error/log pane**.
It does *not* create editor markers. It does _not_ create editor markers.
**The two error tiers (keep them separate):** **The two error tiers (keep them separate):**
@@ -215,7 +216,7 @@ The orchestration is `app.tsx:188-291`: `parseJSONCOrThrow` → `$schema` semver
`validateVegaLite` (ajv, warn) → `vegaLite.compile` (throw=fatal) → render (`renderer.tsx`, `validateVegaLite` (ajv, warn) → `vegaLite.compile` (throw=fatal) → render (`renderer.tsx`,
throw=fatal). throw=fatal).
**ajv setup specifics that *will* bite a from-scratch impl** (`validate.ts:9-17`): **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 - `new Ajv({ strict: false })` — the VL/Vega schemas fail ajv strict-mode at **compile** time
otherwise. otherwise.
@@ -227,15 +228,15 @@ throw=fatal).
per keystroke is a perf killer. per keystroke is a perf killer.
**Where we improve:** ajv errors are shown as JSON-pointer text (e.g. `/encoding/x`) with **no **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 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 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 — ajv markers, we map `instancePath` → editor offsets ourselves via jsonc-parser's node tree —
something the reference does *not* do. something the reference does _not_ do.
## 4 · Data flow & debouncing (M1/M2 — translate to Zustand stores) ## 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 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 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. (`app.tsx:338-365`). Errors don't clobber the last-good derived specs.
**The Zustand-store translation (this is the shape to build):** **The Zustand-store translation (this is the shape to build):**
@@ -251,18 +252,18 @@ text (store field, debounced writer on editor change)
- **Debounce only at edit→state**, not state→render. vega/editor debounces the editor at - **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 **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 diff (`renderer.tsx:340-349`). (1200 ms is _their_ number; tune ours — our settings expose a
render-debounce preference.) render-debounce preference.)
- **A manual-parse escape hatch** (Ctrl/Cmd+S re-parses without waiting) maps to a future - **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`). live-vs-manual preview toggle (`renderer.tsx:89-111`).
- **`LocalLogger` pattern** (`utils/logger.ts`): a logger that buffers `errors/warns/infos/debugs` - **`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 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. step _return_ structured diagnostics with zero browser coupling — e.g.
`validateSpec(spec) → { errors, warns }`. Ideal core-first fit. `validateSpec(spec) → { errors, warns }`. Ideal core-first fit.
- **`json-stringify-pretty-compact`** for the format action and prettify-on-load — much nicer - **`json-stringify-pretty-compact`** for the format action and prettify-on-load — much nicer
than `JSON.stringify(…, null, 2)` for VL specs. than `JSON.stringify(…, null, 2)` for VL specs.
**Persistence note:** vega/editor snapshots its whole state to localStorage on *every* change, **Persistence note:** vega/editor snapshots its whole state to localStorage on _every_ change,
stripping non-serializable fields (`view`, `runtime`, editor refs) and restoring via stripping non-serializable fields (`view`, `runtime`, editor refs) and restoring via
`{ ...DEFAULT_STATE, ...parsed }` (`context/app-context.tsx`). Our **debounced auto-save to `{ ...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 IndexedDB** (doc 01/02) is the better pattern — but the "strip non-serializable, restore with
@@ -273,7 +274,7 @@ defaults-spread" discipline is worth keeping.
## Borrow list (where each lands) ## Borrow list (where each lands)
| Technique | Lands in | Milestone | | Technique | Lands in | Milestone |
|---|---|---| | ---------------------------------------------------------------------------------------- | -------------------------------------- | --------- |
| Bundle VL schema from package `build/`; `setDiagnosticsOptions` | `src/app/infrastructure/` Monaco setup | M2 | | Bundle VL schema from package `build/`; `setDiagnosticsOptions` | `src/app/infrastructure/` Monaco setup | M2 |
| `markdownDescription` patch + compact formatter | Monaco setup | M2 | | `markdownDescription` patch + compact formatter | Monaco setup | M2 |
| Explicit Vite worker wiring (`MonacoEnvironment.getWorker`) | Monaco setup | M2 | | Explicit Vite worker wiring (`MonacoEnvironment.getWorker`) | Monaco setup | M2 |
+17 -17
View File
@@ -1,9 +1,9 @@
# 09 · Visual Design Language # 09 · Visual Design Language
> **Status:** foundational design pass. This is the *visual* contract — the > **Status:** foundational design pass. This is the _visual_ contract — the
> counterpart to `docs/spec/` (behavior) and the rest of `docs/architecture/` > counterpart to `docs/spec/` (behavior) and the rest of `docs/architecture/`
> (structure). `styles/tokens.css`, `styles/base.css`, component CSS Modules, and > (structure). `styles/tokens.css`, `styles/base.css`, component CSS Modules, and
> `src/core/vega-themes.ts` implement *to this doc*. > `src/core/vega-themes.ts` implement _to this doc_.
> >
> **Companion:** [`visual-specimen.html`](./visual-specimen.html) — a standalone, > **Companion:** [`visual-specimen.html`](./visual-specimen.html) — a standalone,
> openable "kitchen sink" that renders every token and element with a live > openable "kitchen sink" that renders every token and element with a live
@@ -12,8 +12,8 @@
Astrolabe's look is **inspired by the IBM Design Language / Carbon**, but Carbon is 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 **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 principles in our own words. We borrow IBM's _engineered structure_; we keep
*color and theming free*. _color and theming free_.
--- ---
@@ -22,21 +22,21 @@ principles in our own words. We borrow IBM's *engineered structure*; we keep
IBM's four design principles map almost exactly onto Astrolabe's SOUL ("the spec is 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: the star; the UI is a thin, considered shell"). Restated for us:
1. **Considered***remove everything gratuitous.* No decoration that isn't 1. **Considered**_remove everything gratuitous._ No decoration that isn't
carrying meaning. Whitespace is a feature. carrying meaning. Whitespace is a feature.
2. **Unified** — a *small fixed kit* (one type family, a neutral ramp, one accent, 2. **Unified** — a _small fixed kit_ (one type family, a neutral ramp, one accent,
a handful of components) reused systematically. Identity comes from consistency, a handful of components) reused systematically. Identity comes from consistency,
not novelty per screen. not novelty per screen.
3. **Executed***everything communicates, including what we leave out.* Alignment, 3. **Executed**_everything communicates, including what we leave out._ Alignment,
rhythm, and empty space are decisions, not leftovers. rhythm, and empty space are decisions, not leftovers.
4. **Progressive***every element reduces friction.* If it doesn't help the user 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. read, edit, or find a snippet faster, it doesn't earn its place.
…plus our own, where we part ways with IBM: …plus our own, where we part ways with IBM:
5. **Structure is rigorous; color is free.** The grid, type scale, spacing, and 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 square geometry are systematic and fixed. Color, accent, and theming are the
*expressive* layer — open, swappable, and meant to be played with. _expressive_ layer — open, swappable, and meant to be played with.
--- ---
@@ -46,7 +46,7 @@ What we **borrow** vs. where we **diverge** — recorded so future readers know
were choices, not drift: were choices, not drift:
| Topic | IBM/Carbon | Astrolabe | | Topic | IBM/Carbon | Astrolabe |
|---|---|---| | ------------------------------- | --------------------------------------------------- | ----------------------------------------------------------------------------------------- |
| Adoption | A framework + component lib | **Inspiration only.** Transcribed tokens, our own components | | 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 | | 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 | | UI chrome corners | ~02px (near-square) | **Fully square, `radius: 0`** — one notch more austere/engineered |
@@ -74,13 +74,13 @@ tokens/themes before porting them across.
32 · 42`. Body is **14/20** (already our `--font-size-base`). Captions/labels 12. 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 - **Weights:** 400 regular, 600 semibold for emphasis/headings; 300 light reserved
for large display only. for large display only.
- **Breathing room:** Plex *"requires space to breathe."* Don't over-tighten — - **Breathing room:** Plex _"requires space to breathe."_ Don't over-tighten —
body line-height ≥ 1.4, default tracking (no negative letter-spacing on text). body line-height ≥ 1.4, default tracking (no negative letter-spacing on text).
Flush-left, clear hierarchy. Flush-left, clear hierarchy.
### 3.2 Spacing — the 8px base unit ### 3.2 Spacing — the 8px base unit
IBM's product/web rule: *"the 8px mini unit guides everything."* Every gap, pad, 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): 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: `--space-1: 2px · --space-2: 4px · --space-3: 8px · --space-4: 12px · --space-5:
@@ -95,7 +95,7 @@ Color is expressed as **roles**, never raw hexes, so themes can repaint the whol
UI by swapping one set of values. Borrowed from Carbon's layering model: UI by swapping one set of values. Borrowed from Carbon's layering model:
| Role token | Meaning | | Role token | Meaning |
|---|---| | ---------------------------------------------------- | ----------------------------------------------------------------------------------- |
| `--bg` | App canvas (lowest layer) | | `--bg` | App canvas (lowest layer) |
| `--layer-01` / `--layer-02` | Raised surfaces (panels, cards, popovers) — elevation by lightness step, not shadow | | `--layer-01` / `--layer-02` | Raised surfaces (panels, cards, popovers) — elevation by lightness step, not shadow |
| `--border` / `--border-strong` | Subtle and prominent separators | | `--border` / `--border-strong` | Subtle and prominent separators |
@@ -160,7 +160,7 @@ The chart `Config` is themed to match the app, per theme:
- `background: transparent` (inherits the surface), Plex font for titles/labels, - `background: transparent` (inherits the surface), Plex font for titles/labels,
axis/grid colors derived from the neutral ramp + `--text-secondary`. axis/grid colors derived from the neutral ramp + `--text-secondary`.
- **Categorical palette** for `range.category` is part of the *free color* layer — - **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 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 starting point, but not mandatory). Light and dark variants. This is where
expressive color earns its keep. expressive color earns its keep.
@@ -171,7 +171,7 @@ The chart `Config` is themed to match the app, per theme:
## 6. Implementation map ## 6. Implementation map
| Artifact | Role | | Artifact | Role |
|---|---| | ------------------------------------------------ | ----------------------------------------------------- |
| [`visual-specimen.html`](./visual-specimen.html) | Living preview + token sandbox. Iterate here first | | [`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/tokens.css` | The settled tokens — ported from the specimen in M1.5 |
| `styles/base.css` | Font wiring (`@fontsource`), reset, reduced-motion | | `styles/base.css` | Font wiring (`@fontsource`), reset, reduced-motion |
@@ -193,12 +193,12 @@ Convention: clone under `/Users/oleh/code/reference/` with
`git clone --depth 1 https://github.com/carbon-design-system/<repo>.git`. `git clone --depth 1 https://github.com/carbon-design-system/<repo>.git`.
| Need | Repo | Where it lives | | 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.mdx` (~1.4 GB clone — image-heavy; the MDX is what we want) | | **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.mdx` (~1.4 GB clone — image-heavy; the MDX is what we want) |
| **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` | | **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` | | **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` | | **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 (§16) and in the > The decisions we made _from_ these sources are captured above (§16) and in the
> specimen, so we don't need to re-derive them — only return to the repos to extend > 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). > the research (e.g. the chart palette, or a component pattern we haven't tackled).
File diff suppressed because it is too large Load Diff
+9 -9
View File
@@ -1,6 +1,6 @@
# 00 · Product Overview # 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. 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 ## What Astrolabe Is
@@ -8,7 +8,7 @@ Astrolabe is a local-first tool for authoring, organizing, and previewing Vega-L
## Who It Is For ## 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). 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 ## Core Value
@@ -22,29 +22,29 @@ People who work with Vega-Lite directly and want a fast, private workspace to dr
- **Local-first** — all data is stored in the browser and survives reload; the app works fully offline and is installable as a standalone app. - **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). - **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. - **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. - **Keyboard-friendly and shareable** — common actions have shortcuts, and the current location is reflected in a shareable URL.
## Non-Goals ## Non-Goals
- No user accounts, authentication, or cross-device sync (use *Import & Export* to move data). - No user accounts, authentication, or cross-device sync (use _Import & Export_ to move data).
- No server-side storage, rendering, or processing. - No server-side storage, rendering, or processing.
- No collaboration or multi-user features. - No collaboration or multi-user features.
- No general BI/dashboarding — a snippet is a single Vega-Lite visualization, not a composed report. - No general BI/dashboarding — a snippet is a single Vega-Lite visualization, not a composed report.
## Key Concepts (Glossary) ## 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*. - **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. - **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*. - **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** — 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. - **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*. - **Live preview** — the rendered chart, updated automatically as the spec changes. See _Live Preview_.
## How This Specification Is Organized ## How This Specification Is Organized
| # | Section | Covers | | # | Section | Covers |
|---|---------|--------| | --- | -------------------------------------- | ------------------------------------------------------------------------------------------ |
| 00 | Product Overview | This document — purpose, scope, glossary. | | 00 | Product Overview | This document — purpose, scope, glossary. |
| 01 | Application Shell & Navigation | Layout, panes, header, modals, keyboard shortcuts, URL state, toasts, offline/installable. | | 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. | | 02 | Snippet Library | Browsing, search, sort, metadata, create/duplicate/delete, storage monitor. |
+15 -15
View File
@@ -6,9 +6,9 @@ This section describes the overall workspace structure, the header toolbar, the
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: 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*). - **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*). - **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*). - **Live preview** (right) — render the current spec (see _Live Preview_).
Behavior: Behavior:
@@ -28,18 +28,18 @@ A fixed header spans the top of the app.
- **Right side**: a row of text entry points. Each opens a destination: - **Right side**: a row of text entry points. Each opens a destination:
| Entry point | Opens | | Entry point | Opens |
|---|---| | --------------- | --------------------------------------------------------------------------------------------------------------- |
| Import | A file-picker dialog to choose a previously exported file; the chosen file is imported (see *Import & Export*). | | Import | A file-picker dialog to choose a previously exported file; the chosen file is imported (see _Import & Export_). |
| Export | Immediately produces a downloaded file containing all snippets and datasets (see *Import & Export*). | | Export | Immediately produces a downloaded file containing all snippets and datasets (see _Import & Export_). |
| Datasets | The Datasets manager modal (see *Datasets*). | | Datasets | The Datasets manager modal (see _Datasets_). |
| Settings | The Settings modal (see *Settings*). | | Settings | The Settings modal (see _Settings_). |
| About & Privacy | The About & Help modal (keyboard shortcuts, about, and privacy information). | | About & Privacy | The About & Help modal (keyboard shortcuts, about, and privacy information). |
| Donate | The Donate modal. | | Donate | The Donate modal. |
Notes: Notes:
- Import and Export act directly (file dialog / file download); they do not open in-app modals. - Import and Export act directly (file dialog / file download); they do not open in-app modals.
- The Datasets, Settings, About & Privacy, and Donate entry points each open a modal (see *Modal System*). - The Datasets, Settings, About & Privacy, and Donate entry points each open a modal (see _Modal System_).
## C. Modal System ## C. Modal System
@@ -48,7 +48,7 @@ The app shows at most one modal at a time. The modal set is: Datasets, Settings,
- Opening any modal closes whichever modal was previously open; the two never overlap. - 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. - Every modal can be dismissed by: clicking its close button, pressing **Escape**, or clicking the backdrop outside the modal body.
- Clicking inside the modal body does not dismiss it. - Clicking inside the modal body does not dismiss it.
- The Chart Builder and Extract-to-Dataset modals are opened from within the Datasets / snippet workflows (see *Chart Builder* and *Datasets*), not from the header. - The Chart Builder and Extract-to-Dataset modals are opened from within the Datasets / snippet workflows (see _Chart Builder_ and _Datasets_), not from the header.
- Dismissing a modal returns the user to the underlying workspace unchanged. - Dismissing a modal returns the user to the underlying workspace unchanged.
## D. Keyboard Shortcuts ## D. Keyboard Shortcuts
@@ -56,10 +56,10 @@ The app shows at most one modal at a time. The modal set is: Datasets, Settings,
Shortcuts are platform-aware: the modifier is **Cmd** on Mac and **Ctrl** on other platforms (shown below as Cmd/Ctrl). Shortcuts are platform-aware: the modifier is **Cmd** on Mac and **Ctrl** on other platforms (shown below as Cmd/Ctrl).
| Shortcut | Action | | Shortcut | Action |
|---|---| | -------------------- | ---------------------------------------------------------------------------------- |
| Cmd/Ctrl + Shift + N | Create a new snippet (see *Snippet Library*) | | Cmd/Ctrl + Shift + N | Create a new snippet (see _Snippet Library_) |
| Cmd/Ctrl + K | Toggle the Datasets manager open/closed | | 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 + S | Publish the current snippet's draft (see _Spec Editor & Draft/Published Workflow_) |
| Cmd/Ctrl + , | Open the Settings modal | | Cmd/Ctrl + , | Open the Settings modal |
| Escape | Close the active modal | | Escape | Close the active modal |
@@ -76,7 +76,7 @@ The app reflects its current location in the URL hash so that reloading restores
States and their hash forms: States and their hash forms:
| State | Hash | | State | Hash |
|---|---| | --------------------------- | ------------------------------ |
| A selected snippet | `#snippet-<id>` | | A selected snippet | `#snippet-<id>` |
| Datasets manager (list) | `#datasets` | | Datasets manager (list) | `#datasets` |
| A specific dataset | `#datasets/dataset-<id>` | | A specific dataset | `#datasets/dataset-<id>` |
@@ -111,5 +111,5 @@ Events that raise toasts include:
Astrolabe is local-first and usable without a network connection. 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. - 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*). - 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. - The app is installable as a standalone application from a supporting browser and, once installed, launches in its own window.
+15 -15
View File
@@ -1,14 +1,14 @@
# 02 · Snippet Library # 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 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
The list shows every saved snippet and is always visible. A persistent "Create New Snippet" affordance sits at the top of the list, above all snippets, so the user can always start a new snippet regardless of scroll position. The list shows every saved snippet and is always visible. A persistent "Create New Snippet" affordance sits at the top of the list, above all snippets, so the user can always start a new snippet regardless of scroll position.
- The list shows all snippets, ordered newest-modified first by default (see *Sort*). - The list shows all snippets, ordered newest-modified first by default (see _Sort_).
- A "Create New Snippet" item is pinned at the top of the list; activating it creates and selects a new snippet (see *Snippet Operations*). - A "Create New Snippet" item is pinned at the top of the list; activating it creates and selects a new snippet (see _Snippet Operations_).
- 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*). - 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. - 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"); when there are genuinely no snippets, it shows "No snippets found". - When no snippets match the current search, the list shows an empty-state message ("No snippets match your search"); when there are genuinely no snippets, it shows "No snippets found".
- On first run, when no snippets exist, the app seeds one sample bar-chart snippet so the user starts with a working example. - On first run, when no snippets exist, the app seeds one sample bar-chart snippet so the user starts with a working example.
@@ -18,10 +18,10 @@ The list shows every saved snippet and is always visible. A persistent "Create N
Each list item is a compact row summarizing one snippet, designed for fast scanning. Each list item is a compact row summarizing one snippet, designed for fast scanning.
- Shows the snippet **name**. - 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 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 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 **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. - 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. - The active snippet is visually highlighted.
## Search ## Search
@@ -42,7 +42,7 @@ The user chooses how the list is ordered. The choice persists across sessions so
- Selecting the already-active sort field flips the direction; selecting a different field switches to it and resets to descending. - 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). - Default ordering is **Modified, descending** (newest changes first).
- Name sorts alphabetically; Size sorts by stored snippet size; Created and Modified sort chronologically. - 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 **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. - The selected sort field and direction persist across sessions.
## Selected-Snippet Metadata Panel ## Selected-Snippet Metadata Panel
@@ -51,15 +51,15 @@ When a snippet is active, a metadata panel (within the left pane) exposes its ed
- Shows and lets the user edit the **Name** inline; edits save automatically. - 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 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*). - 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. - 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*). - The panel also exposes the Duplicate and Delete operations for the active snippet (see _Snippet Operations_).
## Snippet Operations ## Snippet Operations
The library provides the lifecycle operations for snippets. Each operation gives clear feedback via a toast notification (see *Application Shell & Navigation*). The library provides the lifecycle operations for snippets. Each operation gives clear feedback via a toast notification (see _Application Shell & Navigation_).
- **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. - **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.
- **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. - **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 no snippet is active. A toast confirms the deletion. - **Delete**: permanently removes the active snippet after the user confirms a warning that the action cannot be undone. After deletion no snippet is active. A toast confirms the deletion.
- These operations never affect other snippets. - These operations never affect other snippets.
@@ -69,12 +69,12 @@ The library provides the lifecycle operations for snippets. Each operation gives
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. 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). - 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).
- 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*). - 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. - 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 ## Storage Monitor
A small indicator at the bottom of the library shows how much of the snippet storage budget is in use, warning the user before they run out of room. This concerns snippet storage specifically; datasets are stored separately with far greater capacity (see *Datasets* / *Data Model*). A small indicator at the bottom of the library shows how much of the snippet storage budget is in use, warning the user before they run out of room. This concerns snippet storage specifically; datasets are stored separately with far greater capacity (see _Datasets_ / _Data Model_).
- Displays current usage against the total budget (used vs. total), where the practical snippet budget is about 5 MB. - Displays current usage against the total budget (used vs. total), where the practical snippet budget is about 5 MB.
- A fill indicator reflects the percentage used. - A fill indicator reflects the percentage used.
+13 -13
View File
@@ -1,6 +1,6 @@
# 03 · Spec Editor & Draft/Published Workflow # 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*. 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 ## A. The Spec Editor
@@ -10,8 +10,8 @@ The editor presents the active snippet's spec as formatted JSON with full code-e
- 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. - 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). - The editor offers schema-driven autocomplete/suggestions while typing (property names and allowed values from the Vega-Lite schema).
- Pasting content and typing trigger automatic reformatting so the JSON stays consistently indented. - Pasting content and typing trigger automatic reformatting so the JSON stays consistently indented.
- The editor's appearance and behavior — font size, editor theme, minimap visibility, word wrap, line numbers, and tab size — are configurable and read from *Settings*; this section does not redefine their defaults. - The editor's appearance and behavior — font size, editor theme, minimap visibility, word wrap, line numbers, and tab size — are configurable and read from _Settings_; this section does not redefine their defaults.
- The editor always edits a single active snippet. Selecting a different snippet in the *Snippet Library*, or toggling the Draft/Published view, replaces the editor content with the corresponding spec. - The editor always edits a single active snippet. Selecting a different snippet in the _Snippet Library_, or toggling the Draft/Published view, replaces the editor content with the corresponding spec.
## B. Auto-Save of the Draft ## B. Auto-Save of the Draft
@@ -19,17 +19,17 @@ Edits persist automatically so the user never loses work and never needs an expl
- 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. - 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 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 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. - Auto-save is distinct from Publish: auto-save preserves in-progress work; Publish promotes that work to stable.
## C. Auto-Render to Preview ## C. Auto-Render to Preview
Edits flow to the *Live Preview* automatically, so the user sees results without invoking a render. 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. - 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. - 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 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. - 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 ## D. Draft vs Published Workflow
@@ -39,13 +39,13 @@ Every snippet carries two versions of its spec: a **published** (stable) version
- **Draft view** shows the working draft and is the editable surface — all typing, auto-save, and auto-render act on the draft. - **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. - **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. - 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. - 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 ### Publish
- A **Publish** action promotes the current draft to become the published version (the two are made identical). - 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. - Publish is also triggered by the keyboard shortcut Cmd/Ctrl+S.
- On publish, the snippet's dataset references are recomputed from the now-published spec (see *Datasets* for reference linking). - On publish, the snippet's dataset references are recomputed from the now-published spec (see _Datasets_ for reference linking).
- A success toast confirms the snippet was published. - A success toast confirms the snippet was published.
- Publish is unavailable when no snippet is active. - Publish is unavailable when no snippet is active.
@@ -63,11 +63,11 @@ When the spec cannot be parsed or cannot be rendered, the editor pane shows the
- When the spec is invalid JSON, or is valid JSON but fails to render as Vega-Lite (including an unresolved dataset reference), a clear, readable error message appears in the editor pane, near the editor area. - When the spec is invalid JSON, or is valid JSON but fails to render as Vega-Lite (including an unresolved dataset reference), 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 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. - 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*. - This is the editor-side error affordance only; how a valid spec is drawn lives in _Live Preview_.
## F. Extract Inline Data to a Dataset ## F. Extract Inline Data to a Dataset
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 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. - 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). - Choosing it opens a modal that shows a read-only preview of the inline data and asks the user for a dataset name (required).
@@ -75,4 +75,4 @@ When a snippet's spec embeds its data inline, the user can lift that data out in
- 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, and reloads the editor to show the rewritten spec. - 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, and reloads the editor to show the rewritten spec.
- A toast confirms the dataset was created, and the modal closes. - A toast confirms the dataset was created, and the modal closes.
- The user can cancel the modal at any time, leaving the spec unchanged. - 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*. - Dataset-side specifics (formats, storage, the bidirectional snippet↔dataset link) are described in _Datasets_.
+11 -11
View File
@@ -5,19 +5,19 @@ The right pane renders the active snippet's current specification as a live Vega
## Purpose & Live Updating ## Purpose & Live Updating
- Renders the active snippet's current spec as a Vega-Lite visualization. - 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*). - 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*). - 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. - 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. - 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 ## 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*). 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. - 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. - Before rendering, the preview substitutes the referenced dataset's stored contents into the spec.
- URL-sourced datasets are fetched as needed at render time. - URL-sourced datasets are fetched as needed at render time.
- If a referenced dataset cannot be found or fetched, the preview shows a readable error (see *Error Display*) rather than a broken chart. - If a referenced dataset cannot be found or fetched, the preview shows a readable error (see _Error Display_) rather than a broken chart.
## Fit / Sizing Modes ## Fit / Sizing Modes
@@ -28,34 +28,34 @@ The preview pane header has a "Fit" control offering exactly four modes that det
- **Height** — fits the chart's height to the pane; the width 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. - **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. The exact spec transform each mode performs is defined in _Rendering Contract_ below.
Behavior of the selected mode: Behavior of the selected mode:
- The control shows the four modes with the active one visibly indicated. - The control shows the four modes with the active one visibly indicated.
- The selected mode persists across sessions, stored in *Settings* as `previewFitMode`. - The selected mode persists across sessions, stored in _Settings_ as `previewFitMode`.
- The default is the natural Original mode. - The default is the natural Original mode.
## Rendering Contract ## 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. 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*): **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 | | Dataset source / format | The reference's `data` becomes |
|---|---| | ----------------------- | ---------------------------------------------------------------- |
| URL (any format) | a URL reference to the dataset's address, tagged with its format | | URL (any format) | a URL reference to the dataset's address, tagged with its format |
| Inline JSON | the parsed values, inlined | | Inline JSON | the parsed values, inlined |
| Inline CSV / TSV | the raw text, inlined, tagged with its format (CSV or TSV) | | Inline CSV / TSV | the raw text, inlined, tagged with its format (CSV or TSV) |
| Inline TopoJSON | the value, inlined, tagged as TopoJSON | | 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. - 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*). - 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: **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 | | Mode | Transform |
|---|---| | -------- | ------------------------------------------------------------------------------------------ |
| Original | spec sizing left untouched (the spec's own `width`/`height`, or Vega-Lite defaults, apply) | | 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` | | Width | set `width` to `"container"`; remove any explicit `height` |
| Height | set `height` to `"container"`; remove any explicit `width` | | Height | set `height` to `"container"`; remove any explicit `width` |
@@ -77,5 +77,5 @@ When a spec cannot be rendered, the preview replaces the chart area with a clear
## Responsiveness ## 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*). - 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. - Resizing does not require a manual refresh; the displayed chart adapts to the new pane dimensions.
+8 -8
View File
@@ -7,13 +7,13 @@ The **Dataset Manager** is a modal for creating and managing named, reusable dat
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 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. - 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*). - 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. - See _Data Model_ for the stored shape of a dataset.
## Opening & Navigation ## Opening & Navigation
- Opened from a header control or via the keyboard shortcut Cmd/Ctrl+K. - 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*). - 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. - Closing the modal clears the current selection and any open create form.
## Layout ## Layout
@@ -38,7 +38,7 @@ Clicking an item selects it and shows its detail. Per-item actions (delete, plus
A dataset has one of two source types, chosen when creating it: 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. - **Inline** — the data itself is pasted in and stored directly in the library.
- **URL** — the dataset stores a remote URL (http/https). The data is not copied locally; it is fetched on demand when a referencing spec is rendered (see *Live Preview*). - **URL** — the dataset stores a remote URL (http/https). The data is not copied locally; it is fetched on demand when a referencing spec is rendered (see _Live Preview_).
For inline datasets the library holds the full data and can profile it. For URL datasets the library holds only the link, so row/column/size figures are typically not computed up front and show as "N/A". For inline datasets the library holds the full data and can profile it. For URL datasets the library holds only the link, so row/column/size figures are typically not computed up front and show as "N/A".
@@ -80,7 +80,7 @@ The detail pane for a selected dataset shows:
- **Comment** (optional free-text notes), when present. - **Comment** (optional free-text notes), when present.
- **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. - **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 truncated rendering of the data (raw text for CSV/TSV/URL, pretty-printed for JSON/TopoJSON). - **Preview**: a truncated rendering of the data (raw text for CSV/TSV/URL, pretty-printed for JSON/TopoJSON).
- **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*). - **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 ## Actions
@@ -94,14 +94,14 @@ Each action raises a confirming toast (or an error toast on failure).
## Build Chart From Dataset ## 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. 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.
## Extract Inline Data → Dataset ## 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. 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 ## Naming & Uniqueness
- Dataset names must be **unique**. Attempting to create a dataset with a name already in use is rejected with an error toast. - 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*). - 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. - Renaming a dataset that is referenced by snippets keeps references consistent by updating the matching named-data references in affected specs.
+7 -7
View File
@@ -1,11 +1,11 @@
# 06 · Chart Builder # 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*. 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_.
## Opening ## Opening
- Launched from a selected dataset in the *Datasets* manager via that dataset's "build chart" action. - Launched from a selected dataset in the _Datasets_ manager via that dataset's "build chart" action.
- Opens as a modal dialog over the application; the URL reflects the dataset's "build" action so the open builder is shareable/restorable (see *Application Shell & Navigation*). - Opens as a modal dialog over the application; the URL reflects the dataset's "build" action so the open builder is shareable/restorable (see _Application Shell & Navigation_).
- On open, the builder loads the selected dataset, displays its name, and pre-populates sensible defaults (see below). If no dataset is available, it shows a "No dataset loaded" message and offers no controls. - On open, the builder loads the selected dataset, displays its name, and pre-populates sensible defaults (see below). If no dataset is available, it shows a "No dataset loaded" message and offers no controls.
## Layout ## Layout
@@ -27,7 +27,7 @@ A two-pane modal:
- Exactly four channels are offered, in this order: **X, Y, Color, Size**. - Exactly four channels are offered, in this order: **X, Y, Color, Size**.
- For each channel the user: - For each channel the user:
- Picks a dataset column from a dropdown of the dataset's detected columns (see *Datasets* for column detection). A "None" option leaves the channel unmapped. Each column option shows a small type indicator alongside the column name. - Picks a dataset column from a dropdown of the dataset's detected columns (see _Datasets_ for column detection). A "None" option leaves the channel unmapped. Each column option shows a small type indicator alongside the column name.
- Optionally overrides the channel's **field type**, chosen from an exact set: **Quantitative, Nominal, Ordinal, Temporal**. The type override only appears once a column is selected for that channel. - Optionally overrides the channel's **field type**, chosen from an exact set: **Quantitative, Nominal, Ordinal, Temporal**. The type override only appears once a column is selected for that channel.
- When a column is chosen, its field type defaults from the dataset's inferred column type (numeric → Quantitative, date → Temporal, otherwise Nominal); the user may change it afterward. - When a column is chosen, its field type defaults from the dataset's inferred column type (numeric → Quantitative, date → Temporal, otherwise Nominal); the user may change it afterward.
- Clearing a channel back to "None" leaves it out of the produced spec. - Clearing a channel back to "None" leaves it out of the produced spec.
@@ -39,11 +39,11 @@ A two-pane modal:
### Dimensions (optional) ### Dimensions (optional)
- Optional numeric **Width** and **Height** inputs in pixels. - Optional numeric **Width** and **Height** inputs in pixels.
- When left empty, the chart uses default/responsive sizing (consistent with *Live Preview*); when provided, the values are written into the spec. - When left empty, the chart uses default/responsive sizing (consistent with _Live Preview_); when provided, the values are written into the spec.
## Live Preview ## 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*). - 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. - 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. - 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. - If the spec fails to render, the pane shows an inline error message describing the problem instead of a chart.
@@ -60,7 +60,7 @@ Selecting "Create Snippet" produces the final artifact:
- Builds a complete Vega-Lite spec containing: the schema reference, a named data reference to the dataset, the chosen mark (with tooltips enabled), the mapped encodings (each with its field and field type), and any explicit width/height. - Builds a complete Vega-Lite spec containing: the schema reference, a named data reference to the dataset, the chosen mark (with tooltips enabled), the mapped encodings (each with its field and field type), 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). - 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. - 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*). - Links the snippet to the dataset by recording the dataset reference, so the bidirectional snippet↔dataset relationship is established (see _Datasets_).
- Raises a success toast naming the created snippet. - Raises a success toast naming the created snippet.
- Closes the builder; the newly created snippet becomes the active snippet in the library/editor. - Closes the builder; the newly created snippet becomes the active snippet in the library/editor.
+9 -9
View File
@@ -16,7 +16,7 @@ Astrolabe provides a **Settings** modal where users tune appearance, the spec ed
Controls the overall UI theme. Choosing the Dark theme switches the whole application chrome to a dark presentation. Controls the overall UI theme. Choosing the Dark theme switches the whole application chrome to a dark presentation.
| Setting | Options | Default | | Setting | Options | Default |
| -------- | ------------- | ------- | | -------- | ----------- | ------- |
| UI theme | Light, Dark | Light | | UI theme | Light, Dark | Light |
The UI theme is also exposed as a **header toggle** for one-click switching; it The UI theme is also exposed as a **header toggle** for one-click switching; it
@@ -25,10 +25,10 @@ so the two always agree. (The toggle shipped in M1.5, ahead of this modal.)
### Editor ### 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. 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 | | Setting | Options / Range | Default |
| ------------- | ------------------------------------- | -------- | | ------------ | -------------------------------------------------- | ------- |
| Font size | 1018 px (integer) | 12 px | | Font size | 1018 px (integer) | 12 px |
| Editor theme | Auto + explicit overrides (provisional — see note) | Auto | | Editor theme | Auto + explicit overrides (provisional — see note) | Auto |
| Minimap | On / Off | Off | | Minimap | On / Off | Off |
@@ -46,19 +46,19 @@ These settings configure the spec editor used to edit Vega-Lite specs (see *Spec
### Performance ### Performance
| Setting | Range | Default | | Setting | Range | Default |
| --------------- | -------------------- | -------- | | --------------- | ----------- | ------- |
| Render debounce | 5005000 ms | 1500 ms | | Render debounce | 5005000 ms | 1500 ms |
- Render debounce is the delay after the user stops typing before the preview re-renders (see *Live Preview*). - 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. - 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. - The current value is shown alongside the control.
### Formatting ### Formatting
Governs how dates are rendered throughout the app, for example the timestamps shown in the *Snippet Library* list. Governs how dates are rendered throughout the app, for example the timestamps shown in the _Snippet Library_ list.
| Setting | Options | Default | | Setting | Options | Default |
| ------------------- | ----------------------------------------- | ------- | | ------------------ | ----------------------- | ------- |
| Date format | Smart, ISO 8601, Custom | Smart | | Date format | Smart, ISO 8601, Custom | Smart |
| Custom date format | Free-text format string | (empty) | | Custom date format | Free-text format string | (empty) |
@@ -71,8 +71,8 @@ Governs how dates are rendered throughout the app, for example the timestamps sh
The following preferences also persist locally across sessions but are managed outside this modal and are documented in their own sections: The following preferences also persist locally across sessions but are managed outside this modal and are documented in their own sections:
- **Preview fit mode** — how the preview is sized/fit; see *Live Preview*. - **Preview fit mode** — how the preview is sized/fit; see _Live Preview_.
- **Snippet sort preference** — the snippet list's sort field and direction; see *Snippet Library*. - **Snippet sort preference** — the snippet list's sort field and direction; see _Snippet Library_.
## Behaviors ## Behaviors
+11 -7
View File
@@ -4,7 +4,7 @@ Astrolabe lets a user back up or transfer their entire workspace as a single JSO
## Export ## Export
Export produces one downloadable JSON file containing every snippet (see *Snippet Library*) and every dataset (see *Datasets*), wrapped in an envelope carrying format metadata. Export produces one downloadable JSON file containing every snippet (see _Snippet Library_) and every dataset (see _Datasets_), wrapped in an envelope carrying format metadata.
- **Trigger**: the **Export** header control runs the export immediately (no intermediate dialog). - **Trigger**: the **Export** header control runs the export immediately (no intermediate dialog).
- **Contents**: all snippets and all datasets currently stored, plus envelope metadata. - **Contents**: all snippets and all datasets currently stored, plus envelope metadata.
@@ -21,15 +21,19 @@ The downloaded file is a single JSON object: an envelope with a format `version`
"version": "1.0", "version": "1.0",
"exportedAt": "2026-06-03T12:00:00.000Z", "exportedAt": "2026-06-03T12:00:00.000Z",
"exportedBy": "Astrolabe", "exportedBy": "Astrolabe",
"snippets": [ /* full snippet objects (see Data Model) */ ], "snippets": [
"datasets": [ /* full dataset objects (see Data Model) */ ] /* full snippet objects (see Data Model) */
],
"datasets": [
/* full dataset objects (see Data Model) */
]
} }
``` ```
- `version` — export format version (currently `"1.0"`). - `version` — export format version (currently `"1.0"`).
- `exportedAt` — ISO 8601 timestamp of the export. - `exportedAt` — ISO 8601 timestamp of the export.
- `exportedBy` — fixed identifier `"Astrolabe"`. - `exportedBy` — fixed identifier `"Astrolabe"`.
- `snippets` / `datasets` — 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.) - `snippets` / `datasets` — 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.)
## Import ## Import
@@ -47,7 +51,7 @@ The importer recognizes several shapes so that both Astrolabe exports and looser
- **Older / foreign snippet shapes** — snippets that do not match the current model are normalized onto it: - **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. - 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 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*). - 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. - 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. 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.
@@ -60,7 +64,7 @@ A snippet is treated as already in current Astrolabe format when it carries an I
### Dataset conflicts ### Dataset conflicts
When an imported dataset's name already exists in the library, it is auto-renamed to a unique name rather than overwriting the existing one (see *Datasets*). When an imported dataset'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. - A numeric suffix is appended to the original name; further suffixes are added until the name is unique.
- The renamed datasets are reported to the user via a warning toast listing each `original -> new` rename. - The renamed datasets are reported to the user via a warning toast listing each `original -> new` rename.
@@ -68,7 +72,7 @@ When an imported dataset's name already exists in the library, it is auto-rename
### Storage limit handling ### Storage limit handling
Snippet storage has an approximate 5 MB budget (see *Snippet Library* storage monitor). 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 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. - 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.
+21 -21
View File
@@ -2,16 +2,16 @@
This section defines the persistent entities of Astrolabe and how they relate. It is the authoritative data contract: an implementer recreating the app should store equivalent records with these fields and meanings. Types are given abstractly (string, number, boolean, ISO-timestamp string, string[], object, "JSON value") so they map onto any stack. "JSON value" means any valid JSON shape — object, array, string, number, boolean, or null. This section defines the persistent entities of Astrolabe and how they relate. It is the 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*. 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
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*. 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 | | Field | Type | Meaning |
|-------|------|---------| | ------------- | -------------------- | ------------------------------------------------------------------------------------------------------------------- |
| `id` | string | Unique, stable identifier for the snippet. | | `id` | string | Unique, stable identifier for the snippet. |
| `version` | number | Schema version of this record, used for read-time migration (see *Schema versioning* below). | | `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. | | `name` | string | Human-readable title shown in the library. |
| `created` | ISO-timestamp string | When the snippet was first created. | | `created` | ISO-timestamp string | When the snippet was first created. |
| `modified` | ISO-timestamp string | When the snippet was last saved. | | `modified` | ISO-timestamp string | When the snippet was last saved. |
@@ -19,25 +19,25 @@ A **Snippet** is a saved Vega-Lite specification together with its metadata. Sni
| `draftSpec` | JSON value | The **working draft** Vega-Lite spec being edited. May be an object or a string. | | `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. | | `comment` | string | Free-form user note about the snippet. |
| `tags` | string[] | User-assigned labels for filtering and organization. | | `tags` | string[] | User-assigned labels for filtering and organization. |
| `datasetRefs` | string[] | Names of *Datasets* referenced by this spec (see relationships below). | | `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. | | `meta` | object | Free-form, extensible metadata bag for app- or feature-specific data. |
### Dual spec / draftSpec model ### 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. 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
`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 is maintained to mirror the dataset names actually referenced in the spec. `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 is maintained to mirror the dataset names actually referenced in the spec.
## B. Dataset ## 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. 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 | | Field | Type | Meaning |
|-------|------|---------| | ------------- | ------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| `id` | number | Unique numeric identifier. | | `id` | number | Unique numeric identifier. |
| `version` | number | Schema version of this record, used for read-time migration (see *Schema versioning* below). | | `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`. | | `name` | string | Unique, human-readable name; the key snippets reference via `datasetRefs`. |
| `data` | JSON value | The payload. For `source = url`: the URL string. For `source = inline`: the raw CSV/TSV text, or the parsed JSON/TopoJSON value. | | `data` | JSON value | The payload. For `source = url`: the URL string. For `source = inline`: the raw CSV/TSV text, or the parsed JSON/TopoJSON value. |
| `format` | string | One of `json`, `csv`, `tsv`, `topojson`. | | `format` | string | One of `json`, `csv`, `tsv`, `topojson`. |
@@ -55,14 +55,14 @@ The `rowCount`, `columnCount`, `columns`, `columnTypes`, and `size` fields are d
### Schema versioning ### 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`. 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`.
## C. UserSettings ## 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. **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 | | Field | Type | Meaning |
|-------|------|---------| | ----------------------------- | ------- | ---------------------------------------------------------- |
| `version` | number | Schema version of the settings record, used for migration. | | `version` | number | Schema version of the settings record, used for migration. |
| `editor.fontSize` | number | Editor font size. | | `editor.fontSize` | number | Editor font size. |
| `editor.theme` | string | Editor color theme identifier. | | `editor.theme` | string | Editor color theme identifier. |
@@ -82,20 +82,20 @@ UserSettings = { version, editor: { fontSize, theme, minimap, wordWrap, lineNumb
## D. App / UI preferences (persisted separately) ## 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. 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). - **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. - **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 ## E. Persistence & limits
| Tier | What it holds | Capacity & behavior | | 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*). | | 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. | | Dataset store | All _Dataset_ records | Local, in a separate, much higher-capacity store, suited to larger payloads. |
| Settings & preferences | *UserSettings* plus the app/UI preferences in (D) | Local, small. | | 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. 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 ## F. Cross-entity relationships
@@ -104,4 +104,4 @@ Snippets and datasets are linked **bidirectionally by dataset name**: `snippet.d
- From a snippet, `datasetRefs` yields its linked datasets. - From a snippet, `datasetRefs` yields its linked datasets.
- From a dataset, scanning snippets for its `name` in `datasetRefs` yields the snippets that reference it. - 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*. 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_.
+18 -18
View File
@@ -1,54 +1,54 @@
# 10 · Non-Functional Requirements # 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. 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.
## Platform & Form Factor ## Platform & Form Factor
- **Target**: modern evergreen desktop browsers. The app is a single-page application that loads once and then runs locally. - **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. - **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. - **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*). - **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 ## 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.) 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. - **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. - **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. - **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. - **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 ## Performance & Responsiveness
- **Live editing stays fluid**: typing in the editor must remain smooth regardless of spec size; rendering must never block input. - **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. - **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. - **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*). - **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*). - **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 ## 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. - **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. - **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. - **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. - **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*). - **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 ## 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*). - **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. - **Confirm destructive actions**: deleting snippets or datasets, reverting a draft, and resetting settings require explicit confirmation.
- **Warn before storage failure**: snippet storage usage is surfaced with escalating warnings as it fills, and the user is told when a save fails rather than losing data silently (see *Snippet Library*). - **Warn before storage failure**: snippet storage usage is surfaced with escalating warnings as it fills, 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*). - **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*). - **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*). - **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 ## 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. - **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. - **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. - **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 ## 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. - **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.
+3 -3
View File
@@ -1,6 +1,6 @@
# Astrolabe — Product Specification # 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. 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 ## How to read this spec
@@ -12,12 +12,12 @@ A UX/behavioral specification of **Astrolabe**, a browser-based snippet manager
- **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. - **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. - **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. - **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 ## Contents
| # | Section | | # | Section |
|---|---------| | --- | ----------------------------------------------------------------- |
| 00 | [Product Overview](00-product-overview.md) | | 00 | [Product Overview](00-product-overview.md) |
| 01 | [Application Shell & Navigation](01-application-shell.md) | | 01 | [Application Shell & Navigation](01-application-shell.md) |
| 02 | [Snippet Library](02-snippet-library.md) | | 02 | [Snippet Library](02-snippet-library.md) |
+5 -1
View File
@@ -16,7 +16,11 @@ function relativeDate(iso: string): string {
const now = new Date(); const now = new Date();
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()); const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const dayMs = 24 * 60 * 60 * 1000; const dayMs = 24 * 60 * 60 * 1000;
const days = Math.floor((startOfToday.getTime() - new Date(then.getFullYear(), then.getMonth(), then.getDate()).getTime()) / dayMs); const days = Math.floor(
(startOfToday.getTime() -
new Date(then.getFullYear(), then.getMonth(), then.getDate()).getTime()) /
dayMs,
);
if (days <= 0) return 'Today'; if (days <= 0) return 'Today';
if (days === 1) return 'Yesterday'; if (days === 1) return 'Yesterday';
if (days < 7) return `${days}d ago`; if (days < 7) return `${days}d ago`;
+22 -2
View File
@@ -17,7 +17,17 @@ import styles from './ThemeToggle.module.css';
function MoonIcon() { function MoonIcon() {
return ( return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"> <svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z" /> <path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z" />
</svg> </svg>
); );
@@ -25,7 +35,17 @@ function MoonIcon() {
function SunIcon() { function SunIcon() {
return ( return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"> <svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<circle cx="12" cy="12" r="4.5" /> <circle cx="12" cy="12" r="4.5" />
<path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4" /> <path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4" />
</svg> </svg>