mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Format entire codebase with Prettier (mechanical, no behavior change)
This commit is contained in:
@@ -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.
|
||||
|
||||
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
|
||||
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
|
||||
don't fix (pre-existing patterns the new code follows; observations the change exposes but
|
||||
that are out of scope), mark it with a `// TODO:` at the relevant code site explaining
|
||||
*what* could be improved and *why* (1–3 lines). **If an observation is important enough to
|
||||
_what_ could be improved and _why_ (1–3 lines). **If an observation is important enough to
|
||||
mention in the summary, it is important enough to deserve a `// TODO:` at the code location**
|
||||
— otherwise the next reader has no way to recover the context.
|
||||
|
||||
@@ -104,25 +104,31 @@ Review all changes in scope. If changes span multiple patterns below, apply all
|
||||
## Pattern A: New Functionality
|
||||
|
||||
### Testing
|
||||
|
||||
- Unit tests for new `src/core/` logic (test the core hardest).
|
||||
- Lighter component/interaction tests for new UI.
|
||||
- Tests pass before proceeding.
|
||||
|
||||
### Documentation
|
||||
|
||||
Update relevant docs if the feature is significant:
|
||||
|
||||
- **`docs/spec/`** — if product behavior changed (this is a contract; change deliberately).
|
||||
- **`docs/architecture/`** — if a new pattern, navigation map, or decision rule emerged.
|
||||
- **`docs/IMPLEMENTATION-PLAN.md`** — mark milestone progress.
|
||||
Use the `/doc-update` skill for session-discovered gaps. The list is not exclusive.
|
||||
Use the `/doc-update` skill for session-discovered gaps. The list is not exclusive.
|
||||
|
||||
### Dependencies
|
||||
|
||||
If `package.json` changed:
|
||||
|
||||
- Flag each new dependency; explain what it does and why it's needed.
|
||||
- Could a small custom implementation avoid it? Note the trade-off.
|
||||
- Prefer dependencies that solve genuinely hard problems (parsing, rendering) over those that
|
||||
save boilerplate.
|
||||
|
||||
### Alignment Check
|
||||
|
||||
- **SOUL.md** — philosophy (must not violate without good reason).
|
||||
- **`docs/spec/`** — behavioral contract.
|
||||
- **`docs/architecture/`** — the relevant pattern doc.
|
||||
@@ -132,14 +138,17 @@ If `package.json` changed:
|
||||
## Pattern B: Bug Fixes
|
||||
|
||||
### Testing
|
||||
|
||||
- Add a regression test that reproduces the bug and verifies the fix.
|
||||
- Interaction test if the bug affected UI behavior.
|
||||
|
||||
### Documentation
|
||||
|
||||
Usually not required unless the bug revealed incorrect docs, or the fix changes documented
|
||||
(spec) behavior.
|
||||
|
||||
### Alignment Check
|
||||
|
||||
- **SOUL.md** philosophy; **`docs/spec/`** behavioral contract; **`docs/architecture/`** patterns.
|
||||
|
||||
---
|
||||
@@ -147,24 +156,29 @@ Usually not required unless the bug revealed incorrect docs, or the fix changes
|
||||
## Pattern C: Refactoring
|
||||
|
||||
### Impact Analysis
|
||||
|
||||
1. **Search for usages** of modified functions/types across the codebase (Grep).
|
||||
2. **Identify call sites** (components, stores, services, infrastructure, tests).
|
||||
3. **Check exports** used by other modules.
|
||||
4. **Review dependencies** — what the code depends on and what depends on it.
|
||||
|
||||
### Testing
|
||||
|
||||
- Update existing tests to the new structure; verify all call sites.
|
||||
- Run `npm test` and `npm run typecheck`.
|
||||
|
||||
### Documentation
|
||||
|
||||
Update `docs/architecture/` if a pattern, module responsibility, or navigation map changed.
|
||||
Update JSDoc/inline comments if signatures or behavior changed.
|
||||
|
||||
### Alignment Check
|
||||
|
||||
- **SOUL.md** (simplicity, no parallel systems); **`docs/architecture/`** (consistent with the
|
||||
documented patterns); **`docs/spec/`** (behavior unchanged unless intended).
|
||||
|
||||
### Common Refactoring Checks
|
||||
|
||||
- Function signatures → all call sites updated.
|
||||
- Type definitions → search type usages.
|
||||
- Imports → correct after file moves.
|
||||
@@ -176,10 +190,10 @@ Update JSDoc/inline comments if signatures or behavior changed.
|
||||
|
||||
## Reference Documents
|
||||
|
||||
| Document | Purpose |
|
||||
| --- | --- |
|
||||
| [SOUL.md](../../../SOUL.md) | Project philosophy and core values |
|
||||
| [AGENTS.md](../../../AGENTS.md) | AI onboarding and project context |
|
||||
| [docs/spec/](../../../docs/spec/) | Behavioral contract — *what* the app does |
|
||||
| [docs/architecture/](../../../docs/architecture/00-overview.md) | Architecture playbook — *how* it's built |
|
||||
| [docs/IMPLEMENTATION-PLAN.md](../../../docs/IMPLEMENTATION-PLAN.md) | Milestone sequence and scope |
|
||||
| Document | Purpose |
|
||||
| ------------------------------------------------------------------- | ----------------------------------------- |
|
||||
| [SOUL.md](../../../SOUL.md) | Project philosophy and core values |
|
||||
| [AGENTS.md](../../../AGENTS.md) | AI onboarding and project context |
|
||||
| [docs/spec/](../../../docs/spec/) | Behavioral contract — _what_ the app does |
|
||||
| [docs/architecture/](../../../docs/architecture/00-overview.md) | Architecture playbook — _how_ it's built |
|
||||
| [docs/IMPLEMENTATION-PLAN.md](../../../docs/IMPLEMENTATION-PLAN.md) | Milestone sequence and scope |
|
||||
|
||||
@@ -18,7 +18,7 @@ Documentation serves two purposes — know **where to look** and know **what to
|
||||
are valuable, but at different levels of detail:
|
||||
|
||||
- **Navigation map** (good): "Preview flow: `LivePreview.tsx` → `prepareSpecForRender()` (core) → `vega-embed`" — lists the files and their roles so you don't read a dozen files to find the right four.
|
||||
- **Decision rule** (good): "The fit-mode/reference-resolution transform runs on a *copy* of the spec — never mutate the stored spec" — captures a non-obvious convention.
|
||||
- **Decision rule** (good): "The fit-mode/reference-resolution transform runs on a _copy_ of the spec — never mutate the stored spec" — captures a non-obvious convention.
|
||||
- **Code walkthrough** (bad): "SnippetStore.updateDraft sets draftSpec, which a startup subscriber watches, debounces, then calls snippetStore.put… " — restates the code, goes stale on any rename.
|
||||
|
||||
**Navigation maps** use file/module names (stable) to show flow direction. **Decision
|
||||
@@ -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)
|
||||
|
||||
- **`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
|
||||
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
|
||||
rules land here.
|
||||
- **`docs/IMPLEMENTATION-PLAN.md`** — the *when*: milestone sequence and scope.
|
||||
- **`docs/IMPLEMENTATION-PLAN.md`** — the _when_: milestone sequence and scope.
|
||||
|
||||
## Process
|
||||
|
||||
@@ -59,19 +59,19 @@ not do Y"). If you can't state it concisely, it may be too implementation-specif
|
||||
|
||||
Map each gap to the right document:
|
||||
|
||||
| Gap type | Target document |
|
||||
| --- | --- |
|
||||
| Gap type | Target document |
|
||||
| ------------------------------------------------- | ----------------------------------------------------------------------------- |
|
||||
| Product behavior, capabilities, acceptance points | `docs/spec/` (the relevant 00–10 section) — **contract; change deliberately** |
|
||||
| State / Zustand stores | `docs/architecture/01-state-and-stores.md` |
|
||||
| Persistence, IndexedDB, localStorage, migrations | `docs/architecture/02-persistence.md` |
|
||||
| Modals, dialog lifecycle | `docs/architecture/03-modal-system.md` |
|
||||
| URL routing, keyboard/events | `docs/architecture/04-routing-and-events.md` |
|
||||
| Rendering, theming, vega-embed, preview | `docs/architecture/05-rendering-theming-preview.md` |
|
||||
| Type inference, dataset profiling | `docs/architecture/06-type-inference.md` |
|
||||
| Names, snippet↔dataset links, rename propagation | `docs/architecture/07-naming-and-relationships.md` |
|
||||
| Milestone scope, build order | `docs/IMPLEMENTATION-PLAN.md` |
|
||||
| Project philosophy / identity | `SOUL.md` |
|
||||
| Onboarding, conventions, stack | `AGENTS.md` / `CLAUDE.md` |
|
||||
| State / Zustand stores | `docs/architecture/01-state-and-stores.md` |
|
||||
| Persistence, IndexedDB, localStorage, migrations | `docs/architecture/02-persistence.md` |
|
||||
| Modals, dialog lifecycle | `docs/architecture/03-modal-system.md` |
|
||||
| URL routing, keyboard/events | `docs/architecture/04-routing-and-events.md` |
|
||||
| Rendering, theming, vega-embed, preview | `docs/architecture/05-rendering-theming-preview.md` |
|
||||
| Type inference, dataset profiling | `docs/architecture/06-type-inference.md` |
|
||||
| Names, snippet↔dataset links, rename propagation | `docs/architecture/07-naming-and-relationships.md` |
|
||||
| Milestone scope, build order | `docs/IMPLEMENTATION-PLAN.md` |
|
||||
| Project philosophy / identity | `SOUL.md` |
|
||||
| Onboarding, conventions, stack | `AGENTS.md` / `CLAUDE.md` |
|
||||
|
||||
If a gap fits no existing document, consider a new section in the closest one; prefer
|
||||
extending over creating. A brand-new architecture topic can become `docs/architecture/08-*.md`
|
||||
|
||||
@@ -25,11 +25,11 @@ changes. Categorize:
|
||||
Read the current version from `package.json`. The project uses **simplified semver during
|
||||
pre-1.0**:
|
||||
|
||||
| Bump | When | Example |
|
||||
| --- | --- | --- |
|
||||
| **Minor** (`0.x.0`) | New features, UI changes, behavior changes | `0.1.0` → `0.2.0` |
|
||||
| **Patch** (`0.x.y`) | Bug fixes, polish, performance, internal | `0.1.0` → `0.1.1` |
|
||||
| **Major** (`1.0.0`) | Only when declaring public stability (user decision) | — |
|
||||
| Bump | When | Example |
|
||||
| ------------------- | ---------------------------------------------------- | ----------------- |
|
||||
| **Minor** (`0.x.0`) | New features, UI changes, behavior changes | `0.1.0` → `0.2.0` |
|
||||
| **Patch** (`0.x.y`) | Bug fixes, polish, performance, internal | `0.1.0` → `0.1.1` |
|
||||
| **Major** (`1.0.0`) | Only when declaring public stability (user decision) | — |
|
||||
|
||||
Present the categorized changes and your recommended bump type to the user **for confirmation
|
||||
before proceeding**.
|
||||
|
||||
@@ -12,19 +12,19 @@ validation and a live chart preview, and reuses **datasets** across many snippet
|
||||
local, offline-capable, no account.
|
||||
|
||||
It is a **spec-driven rebuild** on an architecture adapted from its sibling project Syto.
|
||||
The authoritative behavioral contract is **`docs/spec/`** (sections 00–10). Implement *to
|
||||
the spec*; do not port legacy code.
|
||||
The authoritative behavioral contract is **`docs/spec/`** (sections 00–10). Implement _to
|
||||
the spec_; do not port legacy code.
|
||||
|
||||
### Technical Stack
|
||||
|
||||
| Layer | Technology |
|
||||
|-------|------------|
|
||||
| Build | Vite, TypeScript, Vitest (happy-dom) |
|
||||
| UI | React, Zustand, CSS Modules |
|
||||
| Editor | Monaco (JSON + Vega-Lite schema service) |
|
||||
| Charts | Vega-Lite + vega-embed |
|
||||
| Layer | Technology |
|
||||
| ------- | ------------------------------------------------------------------------------------ |
|
||||
| Build | Vite, TypeScript, Vitest (happy-dom) |
|
||||
| UI | React, Zustand, CSS Modules |
|
||||
| Editor | Monaco (JSON + Vega-Lite schema service) |
|
||||
| Charts | Vega-Lite + vega-embed |
|
||||
| Storage | IndexedDB (snippets, datasets), localStorage (settings/prefs), URL hash (view state) |
|
||||
| Offline | `vite-plugin-pwa` (Workbox), `registerType: 'prompt'` |
|
||||
| Offline | `vite-plugin-pwa` (Workbox), `registerType: 'prompt'` |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
## The Problem
|
||||
|
||||
People who work with [Vega-Lite](https://vega.github.io/vega-lite/) directly — analysts,
|
||||
educators, chart authors — don't have a fast, private place to *keep* their charts. The
|
||||
educators, chart authors — don't have a fast, private place to _keep_ their charts. The
|
||||
official Vega-Lite editor is great for a single spec in a tab, but it forgets everything
|
||||
when you close it. Notebooks bury charts in code. BI tools hide the spec behind a GUI and
|
||||
lock you into an account.
|
||||
@@ -17,7 +17,7 @@ account, no server, and full offline use.**
|
||||
The central artifact is the **snippet**: a saved Vega-Lite specification plus metadata.
|
||||
Everything else — the editor, the live preview, the dataset library, the chart builder —
|
||||
exists to author, organize, and reuse snippets. Astrolabe does not abstract Vega-Lite
|
||||
away; a snippet *is* a Vega-Lite spec. The chart builder offers a no-JSON on-ramp, but the
|
||||
away; a snippet _is_ a Vega-Lite spec. The chart builder offers a no-JSON on-ramp, but the
|
||||
JSON is always the source of truth and always editable.
|
||||
|
||||
Reusable **datasets** are stored once and referenced by name from many snippets, so the
|
||||
@@ -26,38 +26,44 @@ data lives in one place and the specs stay lean.
|
||||
## Core Values
|
||||
|
||||
### 1. Local-Only by Default
|
||||
|
||||
Everything runs in the browser. Snippets, datasets, and settings never leave the machine.
|
||||
No accounts, no uploads, no tracking. The only outbound requests are user-created
|
||||
URL-dataset fetches.
|
||||
|
||||
### 2. Vega-Lite Native, Not Vega-Lite Hidden
|
||||
The product domain *is* Vega-Lite. We validate, render, and reason about specs as
|
||||
|
||||
The product domain _is_ Vega-Lite. We validate, render, and reason about specs as
|
||||
Vega-Lite, and we surface its real vocabulary (marks, encodings, field types). We don't
|
||||
invent a parallel chart abstraction. The chart builder is an on-ramp, not a replacement
|
||||
for the spec.
|
||||
|
||||
### 3. Experiment Safely
|
||||
|
||||
A snippet carries a stable **published** spec and an editable **draft**. You can tinker
|
||||
freely without losing a known-good version. Auto-save protects in-progress work; publish
|
||||
promotes it deliberately.
|
||||
|
||||
### 4. Beginner On-Ramp, Power-User Ceiling
|
||||
|
||||
The chart builder lets someone produce a chart without writing JSON. The editor — with
|
||||
schema-aware autocomplete and live validation — lets a power user do anything Vega-Lite
|
||||
can. Neither caps the other.
|
||||
|
||||
### 5. Own Your Data
|
||||
|
||||
Fully local and offline-capable, with import/export for backup and transfer. Your library
|
||||
is a file you control, not a row in someone's database.
|
||||
|
||||
### 6. Predictable, Not Clever
|
||||
|
||||
When a behavior could go several ways, pick the one closest to the user's existing mental
|
||||
model (the Vega-Lite editor, JSON tooling, file-based apps). Least surprise beats most
|
||||
clever.
|
||||
|
||||
## What We're Not
|
||||
|
||||
- **Not a BI/dashboarding tool.** A snippet is *one* visualization, not a composed report
|
||||
- **Not a BI/dashboarding tool.** A snippet is _one_ visualization, not a composed report
|
||||
with cross-filters and layout. Dashboards are a different product.
|
||||
- **Not a data-wrangling tool.** Datasets are stored and referenced, not cleaned or
|
||||
transformed. (That's [Syto](https://github.com/) territory — Astrolabe's sibling in
|
||||
@@ -69,29 +75,34 @@ clever.
|
||||
## Technical Philosophy
|
||||
|
||||
### Spec-Driven, Clean Implementation
|
||||
|
||||
The behavioral contract lives in `docs/spec/`. Astrolabe is a deliberate rebuild on a
|
||||
robust architecture (adapted from Syto): we implement *to the spec*, not by porting old
|
||||
robust architecture (adapted from Syto): we implement _to the spec_, not by porting old
|
||||
code. When the spec and convenience conflict, the spec wins or the spec changes — never
|
||||
silent drift.
|
||||
|
||||
### Portable Core, Thin Browser Shell
|
||||
|
||||
`src/core/` is pure and portable — no browser APIs, no UI framework. Spec operations
|
||||
(detection, profiling, reference resolution, fit transforms, validation, import
|
||||
normalization) live there and are tested hardest. The UI is a thin, replaceable shell over
|
||||
that core.
|
||||
|
||||
### Leverage Existing Libraries
|
||||
|
||||
Vega-Lite renders. Monaco edits. vega-embed mounts charts. React + Zustand drive the UI.
|
||||
We wrap these with thin integration layers rather than reinventing them. Custom code
|
||||
focuses on what's unique to Astrolabe: the snippet/dataset model, the rendering contract,
|
||||
and the workspace that ties it together.
|
||||
|
||||
### No Parallel Systems
|
||||
|
||||
Each fact lives in one place. A snippet↔dataset link, a setting, a schema — one source of
|
||||
truth, others derived. If you're writing the same logic twice, one should import or be
|
||||
generated from the other.
|
||||
|
||||
### Test the Core, Trust the UI
|
||||
|
||||
High coverage on the portable engine (where a bug corrupts data or breaks rendering);
|
||||
lighter coverage on components (where a bug is a cosmetic annoyance).
|
||||
|
||||
|
||||
+60
-32
@@ -42,19 +42,19 @@ doc before implementing.
|
||||
|
||||
## Milestone map
|
||||
|
||||
| # | Milestone | Outcome | Spec |
|
||||
|---|-----------|---------|------|
|
||||
| **M0** | Skeleton ✅ | Repo builds, tests run, empty shell renders | — |
|
||||
| **M1** | **MVP core loop** | Author a Vega-Lite snippet, see it render live, it persists | §02, §03A–C, §04, §09A |
|
||||
| # | Milestone | Outcome | Spec |
|
||||
| -------- | --------------------------- | ------------------------------------------------------------------------------ | ------------------------------------------- |
|
||||
| **M0** | Skeleton ✅ | Repo builds, tests run, empty shell renders | — |
|
||||
| **M1** | **MVP core loop** | Author a Vega-Lite snippet, see it render live, it persists | §02, §03A–C, §04, §09A |
|
||||
| **M1.5** | Visual design foundation ✅ | Apply the design language: tokens, IBM Plex, restyled M1 surfaces, chart theme | [arch 09](architecture/09-visual-design.md) |
|
||||
| **M2** | Editor robustness | Draft/Published, validation, schema autocomplete, fit modes | §03D–E, §04, §07(editor) |
|
||||
| **M3** | Datasets | Named reusable data + reference resolution in preview | §05, §03F, §09B |
|
||||
| **M4** | Chart Builder | No-JSON chart composition from a dataset | §06 |
|
||||
| **M5** | Settings + Import/Export | Preferences + workspace backup/transfer | §07, §08, §09C |
|
||||
| **M6** | Shell polish | Resize/toggle panes, routing, shortcuts, toasts, a11y, offline | §01, §10 |
|
||||
| **M2** | Editor robustness | Draft/Published, validation, schema autocomplete, fit modes | §03D–E, §04, §07(editor) |
|
||||
| **M3** | Datasets | Named reusable data + reference resolution in preview | §05, §03F, §09B |
|
||||
| **M4** | Chart Builder | No-JSON chart composition from a dataset | §06 |
|
||||
| **M5** | Settings + Import/Export | Preferences + workspace backup/transfer | §07, §08, §09C |
|
||||
| **M6** | Shell polish | Resize/toggle panes, routing, shortcuts, toasts, a11y, offline | §01, §10 |
|
||||
|
||||
**MVP boundary = end of M1** (a genuinely usable single-user chart authoring loop).
|
||||
M1.5 makes it *look right*; M2 makes it *robust*; M3–M6 make it *complete*.
|
||||
M1.5 makes it _look right_; M2 makes it _robust_; M3–M6 make it _complete_.
|
||||
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
|
||||
preview, and have it survive reload. Single source kind: inline-data specs only
|
||||
(datasets come in M3). No draft/published yet — edits save directly.
|
||||
|
||||
**Core (`src/core/`)**
|
||||
|
||||
- `snippet.ts` — the Snippet type (spec §09A) + factory (`createSnippet`,
|
||||
default sample bar-chart template, auto-generated date/time name).
|
||||
- `rendering.ts` — `prepareSpecForRender(spec, { fitMode })` skeleton; in M1 it's
|
||||
@@ -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.
|
||||
|
||||
**Infrastructure (`src/app/infrastructure/`)**
|
||||
|
||||
- `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`).
|
||||
|
||||
**App**
|
||||
|
||||
- `stores/SnippetStore.ts` — `useSnippetStore` with `snippets`, `activeSnippetId`,
|
||||
selector-derived `activeSnippet`; load-on-startup; create/select/delete/update actions
|
||||
(debounced auto-save of edits, spec §03B). Seed one sample snippet on first run.
|
||||
@@ -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.
|
||||
|
||||
**Tests (core-first)**
|
||||
|
||||
- `snippet.test.ts` — factory defaults, sample template validity, unique naming.
|
||||
- `rendering.test.ts` — copy-not-mutate invariant; pass-through shape.
|
||||
- A store test for create/select/delete/auto-save reducer logic (logic extracted
|
||||
from the component so it's testable without DOM).
|
||||
|
||||
**Manual checks**
|
||||
|
||||
- Fresh load seeds a sample snippet that renders a bar chart.
|
||||
- Type in the editor → preview updates after the debounce; bad JSON → editor keeps
|
||||
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
|
||||
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)
|
||||
and the companion `visual-specimen.html`.
|
||||
|
||||
**Styles**
|
||||
|
||||
- Port the settled specimen tokens into `styles/tokens.css` (IBM-Plex type scale,
|
||||
8px-based spacing, role-based color, square chrome, motion); light + dark themes
|
||||
via `[data-theme]`.
|
||||
@@ -129,6 +135,7 @@ and the companion `visual-specimen.html`.
|
||||
(offline/PWA — never a CDN).
|
||||
|
||||
**App**
|
||||
|
||||
- Restyle the four M1 surfaces against the tokens: App shell, SnippetLibrary,
|
||||
SpecEditor (Monaco theme follows `[data-theme]`), LivePreview. Tokens only — no
|
||||
raw hexes, no hardcoded hues in components.
|
||||
@@ -141,14 +148,17 @@ and the companion `visual-specimen.html`.
|
||||
so dogfooding dark mode through M2–M4 beat waiting for the Settings modal.
|
||||
|
||||
**Core**
|
||||
|
||||
- Align `src/core/vega-themes.ts`: chart `Config` per theme + a categorical
|
||||
`range.category` palette (clone `carbon-design-system/carbon-charts` for the
|
||||
sequence — see Architecture 09 §7).
|
||||
|
||||
**Tests**
|
||||
|
||||
- Light: the design is mostly visual — a token/theme smoke check, trust the eye.
|
||||
|
||||
**Manual checks**
|
||||
|
||||
- The real app looks deliberate in both themes; theme flip repaints UI + chart.
|
||||
- Keyboard focus ring visible; text/UI contrast passes AA in light and dark.
|
||||
- No placeholder styling remains on the M1 surfaces.
|
||||
@@ -170,6 +180,7 @@ set to Plex Mono explicitly since it can't read the CSS token.
|
||||
assistance, and the fit-mode rendering contract.
|
||||
|
||||
**Core**
|
||||
|
||||
- `rendering.ts` — implement **fit-mode** transform (Original/Width/Height/Full →
|
||||
Vega-Lite `"container"`), recursing into layered/concat/child specs (spec §04
|
||||
Rendering Contract, step 2).
|
||||
@@ -177,6 +188,7 @@ assistance, and the fit-mode rendering contract.
|
||||
validation/autocomplete (mine vega-editor for sourcing/versioning the schema).
|
||||
|
||||
**App**
|
||||
|
||||
- Snippet gains `spec` (published) + `draftSpec` (working) per §09A; editing
|
||||
touches `draftSpec` only.
|
||||
- `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`).
|
||||
|
||||
**Tests**
|
||||
|
||||
- Fit-mode transforms for each mode incl. nested specs; copy-not-mutate.
|
||||
- Draft/publish/revert reducer logic; "has unpublished changes" derivation.
|
||||
|
||||
**Manual checks**
|
||||
|
||||
- Edit draft, see status flip to "draft"; Publish → status clears; Revert →
|
||||
draft restored with confirmation.
|
||||
- Invalid spec shows inline error; autocomplete suggests Vega-Lite properties.
|
||||
@@ -204,18 +218,21 @@ assistance, and the fit-mode rendering contract.
|
||||
the reference.
|
||||
|
||||
**Core**
|
||||
|
||||
- `profiling.ts` — row/column counts, column names, **per-column type inference**
|
||||
(number/text/date/boolean). *(see [Architecture 06 · Type Inference](architecture/06-type-inference.md))*
|
||||
(number/text/date/boolean). _(see [Architecture 06 · Type Inference](architecture/06-type-inference.md))_
|
||||
- `rendering.ts` — implement **dataset reference resolution** (§04 Rendering
|
||||
Contract, step 1): `{data:{name}}` → inline values / raw text+format / URL+format,
|
||||
recursing into sub-specs; "dataset not found" error.
|
||||
- `dataset.ts` — Dataset type (§09B); name uniqueness helpers; rename-propagation
|
||||
into referencing specs. *(see [Architecture 07 · Naming & Relationships](architecture/07-naming-and-relationships.md))*
|
||||
into referencing specs. _(see [Architecture 07 · Naming & Relationships](architecture/07-naming-and-relationships.md))_
|
||||
|
||||
**Infrastructure**
|
||||
|
||||
- `dataset-store.ts` — separate high-capacity IndexedDB store (§09E).
|
||||
|
||||
**App**
|
||||
|
||||
- `stores/DatasetStore.ts` + Datasets **modal** (list/detail panes, create form,
|
||||
edit, delete, copy-reference) via the modal registry/coordinator.
|
||||
- Snippet `datasetRefs` maintained on publish; library shows dataset icon +
|
||||
@@ -224,11 +241,13 @@ the reference.
|
||||
- URL-sourced datasets fetched at render time.
|
||||
|
||||
**Tests**
|
||||
|
||||
- Reference resolution per source/format incl. nested; not-found error.
|
||||
- Profiling/type inference across mixed columns, nulls, booleans.
|
||||
- Rename propagation; name-uniqueness + import-style auto-suffix.
|
||||
|
||||
**Manual checks**
|
||||
|
||||
- Create a dataset, reference it by name in a snippet → preview renders.
|
||||
- Extract inline data → spec rewritten to a reference, dataset appears, links show both ways.
|
||||
- Delete/rename a referenced dataset behaves per spec.
|
||||
@@ -240,21 +259,25 @@ the reference.
|
||||
**Goal:** no-JSON chart composition from a dataset → a new snippet.
|
||||
|
||||
**Core**
|
||||
|
||||
- `chart-builder.ts` — pure spec assembler: (mark ∈ Bar/Line/Point/Area/Circle) +
|
||||
channels (X/Y/Color/Size) with field types (Quantitative/Nominal/Ordinal/Temporal)
|
||||
+ optional width/height → complete Vega-Lite spec with tooltips + named data ref
|
||||
(§06 Output). Field-type defaults from inferred column type.
|
||||
- optional width/height → complete Vega-Lite spec with tooltips + named data ref
|
||||
(§06 Output). Field-type defaults from inferred column type.
|
||||
|
||||
**App**
|
||||
|
||||
- Chart Builder **modal** (config pane + live preview pane), launched from a
|
||||
selected dataset; default pre-population (first col→X, second→Y); validation
|
||||
(≥1 channel); Create Snippet → new linked snippet becomes active.
|
||||
|
||||
**Tests**
|
||||
|
||||
- Spec assembly: mark/channel/type permutations, unmapped channels omitted,
|
||||
width/height inclusion, field-type derivation, validation gate.
|
||||
|
||||
**Manual checks**
|
||||
|
||||
- Build a bar chart from a dataset in a few clicks; preview live-updates;
|
||||
Create → new snippet opens and renders.
|
||||
|
||||
@@ -265,6 +288,7 @@ the reference.
|
||||
**Goal:** preferences and whole-workspace backup/transfer.
|
||||
|
||||
**Core**
|
||||
|
||||
- `settings.ts` — UserSettings shape + defaults + load-with-fallback (§07, §09C);
|
||||
unknown/missing values fall back silently.
|
||||
- `import-normalize.ts` — accept envelope / bare array / single snippet / foreign
|
||||
@@ -274,11 +298,13 @@ the reference.
|
||||
- `export-envelope.ts` — build the `{version, exportedAt, exportedBy, snippets, datasets}` envelope.
|
||||
|
||||
**Infrastructure**
|
||||
|
||||
- `settings-store.ts` (localStorage): **extend** the minimal M1.5 adapter (which
|
||||
already persists `ui.theme`) to the full UserSettings record; `ux-prefs` for sort
|
||||
+ panel layout (§09D).
|
||||
- panel layout (§09D).
|
||||
|
||||
**App**
|
||||
|
||||
- Settings **modal** (Appearance/Editor/Performance/Formatting), Apply/Cancel/Reset,
|
||||
dirty indicator; wire render-debounce + theme + date-format through to the app.
|
||||
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.
|
||||
|
||||
**Tests**
|
||||
|
||||
- Import normalization across all accepted shapes; merge/collision/rename logic;
|
||||
quota-overage messaging path. Envelope round-trip (export→import idempotence).
|
||||
- Settings load-with-fallback for partial/unknown records.
|
||||
|
||||
**Manual checks**
|
||||
|
||||
- Change theme/debounce/date-format → takes effect; Cancel reverts; Reset confirms.
|
||||
- Export → reimport into a populated workspace merges without overwrite; renames reported.
|
||||
|
||||
@@ -304,9 +332,9 @@ the reference.
|
||||
- **Panes:** drag-resize handles with min widths; per-pane show/hide toggle strip;
|
||||
widths + visibility persist (§01A, §09D).
|
||||
- **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
|
||||
(§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).
|
||||
- **Storage monitor** for the snippet tier (§02).
|
||||
- **A11y:** modal focus trap + return, labelled icon buttons, contrast in both themes (§10).
|
||||
@@ -339,14 +367,14 @@ offline reload works; install as standalone; reduced-motion honored.
|
||||
The **how** behind each milestone is documented self-containedly in
|
||||
[`docs/architecture/`](architecture/00-overview.md) — no external repo needed:
|
||||
|
||||
| Need | Doc |
|
||||
|------|-----|
|
||||
| Zustand stores, selector derivations, debounced auto-save | [01 · State & Stores](architecture/01-state-and-stores.md) |
|
||||
| IndexedDB wrapper, lazy loading, migrations, localStorage prefs, storage tiers | [02 · Persistence](architecture/02-persistence.md) |
|
||||
| Modal registry + coordinator + shell, unsaved-change detection, focus trap | [03 · Modal System](architecture/03-modal-system.md) |
|
||||
| URL hash view-state, keyboard routing, interactive-context detection | [04 · Routing & Events](architecture/04-routing-and-events.md) |
|
||||
| vega-embed integration, theming, debounced preview, error display | [05 · Rendering, Theming & Preview](architecture/05-rendering-theming-preview.md) |
|
||||
| Column type inference + dataset profiling | [06 · Type Inference & Profiling](architecture/06-type-inference.md) |
|
||||
| Unique names + import auto-suffix, snippet↔dataset links, rename propagation | [07 · Naming & Relationships](architecture/07-naming-and-relationships.md) |
|
||||
| Monaco setup, Vega-Lite schema service, editor patterns mined from vega/editor | [08 · Vega Editor Techniques](architecture/08-vega-editor-techniques.md) |
|
||||
| Design language: tokens, type, spacing, color roles, components, themes | [09 · Visual Design Language](architecture/09-visual-design.md) |
|
||||
| Need | Doc |
|
||||
| ------------------------------------------------------------------------------ | --------------------------------------------------------------------------------- |
|
||||
| Zustand stores, selector derivations, debounced auto-save | [01 · State & Stores](architecture/01-state-and-stores.md) |
|
||||
| IndexedDB wrapper, lazy loading, migrations, localStorage prefs, storage tiers | [02 · Persistence](architecture/02-persistence.md) |
|
||||
| Modal registry + coordinator + shell, unsaved-change detection, focus trap | [03 · Modal System](architecture/03-modal-system.md) |
|
||||
| URL hash view-state, keyboard routing, interactive-context detection | [04 · Routing & Events](architecture/04-routing-and-events.md) |
|
||||
| vega-embed integration, theming, debounced preview, error display | [05 · Rendering, Theming & Preview](architecture/05-rendering-theming-preview.md) |
|
||||
| Column type inference + dataset profiling | [06 · Type Inference & Profiling](architecture/06-type-inference.md) |
|
||||
| Unique names + import auto-suffix, snippet↔dataset links, rename propagation | [07 · Naming & Relationships](architecture/07-naming-and-relationships.md) |
|
||||
| Monaco setup, Vega-Lite schema service, editor patterns mined from vega/editor | [08 · Vega Editor Techniques](architecture/08-vega-editor-techniques.md) |
|
||||
| Design language: tokens, type, spacing, color roles, components, themes | [09 · Visual Design Language](architecture/09-visual-design.md) |
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
> against Syto in its current state, and recommends an integration path.
|
||||
>
|
||||
> **Short answer:** Not as a wholesale port, and not as the "snippet manager" it is today —
|
||||
> that framing collides with Syto's stated non-goals. But the *valuable parts* of Astrolabe
|
||||
> that framing collides with Syto's stated non-goals. But the _valuable parts_ of Astrolabe
|
||||
> (the Chart Builder, the generic spec→render pipeline, the schema-assisted JSON editor) map
|
||||
> cleanly onto a Syto-native **"chart a model"** feature, and most of the supporting tech already
|
||||
> exists in the codebase. The recommendation is **harvest, don't port** — and the framing decision
|
||||
@@ -15,33 +15,33 @@
|
||||
|
||||
## 1. Executive Summary
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Conceptual fit** | Partial. Astrolabe and Syto are both local-first, browser-only, Vega-Lite-using, three-pane-ish workspaces. But Astrolabe's *primary entity* (a saved chart spec) is a thing Syto deliberately does not have. |
|
||||
| **Strategic fit** | **Conflicted.** `SOUL.md` explicitly lists "Not a BI/visualization platform — charts are for exploration during wrangling, not final output" as a non-goal, and "Do One Thing Well." A *snippet library* is chart-authoring-as-product. This is the central tension and must be resolved before any code. |
|
||||
| **Technical fit** | **Good for the rendering/editing layer, poor for the data-model and shell layers.** Syto already ships Vega-Lite, vega-embed, CodeMirror 6, IndexedDB persistence, a settings system, URL-hash routing, and a far stronger type/schema engine than Astrolabe's profiler. The friction is in the *parallel systems* a verbatim port would introduce. |
|
||||
| **Recommended path** | **Option B (harvest into a native "Visualize" feature).** Reuse Astrolabe's Chart Builder and rendering contract; bind them to Syto **Models** instead of a new "dataset" entity; drop the snippet-as-primary-entity, the draft/published workflow, the separate dataset library, and the separate import/export envelope. |
|
||||
| | |
|
||||
| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Conceptual fit** | Partial. Astrolabe and Syto are both local-first, browser-only, Vega-Lite-using, three-pane-ish workspaces. But Astrolabe's _primary entity_ (a saved chart spec) is a thing Syto deliberately does not have. |
|
||||
| **Strategic fit** | **Conflicted.** `SOUL.md` explicitly lists "Not a BI/visualization platform — charts are for exploration during wrangling, not final output" as a non-goal, and "Do One Thing Well." A _snippet library_ is chart-authoring-as-product. This is the central tension and must be resolved before any code. |
|
||||
| **Technical fit** | **Good for the rendering/editing layer, poor for the data-model and shell layers.** Syto already ships Vega-Lite, vega-embed, CodeMirror 6, IndexedDB persistence, a settings system, URL-hash routing, and a far stronger type/schema engine than Astrolabe's profiler. The friction is in the _parallel systems_ a verbatim port would introduce. |
|
||||
| **Recommended path** | **Option B (harvest into a native "Visualize" feature).** Reuse Astrolabe's Chart Builder and rendering contract; bind them to Syto **Models** instead of a new "dataset" entity; drop the snippet-as-primary-entity, the draft/published workflow, the separate dataset library, and the separate import/export envelope. |
|
||||
|
||||
---
|
||||
|
||||
## 2. The Two Products Side by Side
|
||||
|
||||
| Dimension | **Astrolabe** | **Syto** |
|
||||
|---|---|---|
|
||||
| Core artifact | A **snippet** = a saved Vega-Lite spec + metadata | A **workflow** = a declarative transform pipeline over a Source |
|
||||
| Primary verb | *Author & organize charts* | *Clean & reshape tabular data* |
|
||||
| Data unit | **Dataset** (named blob: JSON/CSV/TSV/TopoJSON, inline or URL) | **Source** (immutable imported table) → **Model** (derived table) |
|
||||
| Persistence | Snippets (~5 MB tier) + Datasets (high-capacity tier), both local | Sources + Models in IndexedDB (lazy row data), prefs in localStorage |
|
||||
| Editor | JSON editor w/ Vega-Lite schema autocomplete + live validation | CodeMirror 6 — used for transform JSON + the expression language |
|
||||
| Rendering | Renders *arbitrary user specs* via reference-resolution + fit-mode transforms | Renders *programmatically generated* EDA specs (`charts.ts`) |
|
||||
| Shell | 3 panes: library · editor · preview, + modals | Ribbon + sidebar + data table + slide-panel/modal dialogs |
|
||||
| Routing | URL hash: `#snippet-<id>`, `#datasets/...` | URL hash: active source/model/dialog |
|
||||
| Export | One JSON envelope of all snippets + datasets | Workflow v2 JSON (transforms, topo-sorted) |
|
||||
| Stack stance | Implementation-agnostic spec | Preact + Signals + Arquero + CSS Modules, fixed |
|
||||
| Dimension | **Astrolabe** | **Syto** |
|
||||
| ------------- | ----------------------------------------------------------------------------- | -------------------------------------------------------------------- |
|
||||
| Core artifact | A **snippet** = a saved Vega-Lite spec + metadata | A **workflow** = a declarative transform pipeline over a Source |
|
||||
| Primary verb | _Author & organize charts_ | _Clean & reshape tabular data_ |
|
||||
| Data unit | **Dataset** (named blob: JSON/CSV/TSV/TopoJSON, inline or URL) | **Source** (immutable imported table) → **Model** (derived table) |
|
||||
| Persistence | Snippets (~5 MB tier) + Datasets (high-capacity tier), both local | Sources + Models in IndexedDB (lazy row data), prefs in localStorage |
|
||||
| Editor | JSON editor w/ Vega-Lite schema autocomplete + live validation | CodeMirror 6 — used for transform JSON + the expression language |
|
||||
| Rendering | Renders _arbitrary user specs_ via reference-resolution + fit-mode transforms | Renders _programmatically generated_ EDA specs (`charts.ts`) |
|
||||
| Shell | 3 panes: library · editor · preview, + modals | Ribbon + sidebar + data table + slide-panel/modal dialogs |
|
||||
| Routing | URL hash: `#snippet-<id>`, `#datasets/...` | URL hash: active source/model/dialog |
|
||||
| Export | One JSON envelope of all snippets + datasets | Workflow v2 JSON (transforms, topo-sorted) |
|
||||
| Stack stance | Implementation-agnostic spec | Preact + Signals + Arquero + CSS Modules, fixed |
|
||||
|
||||
**The key observation:** Astrolabe's "dataset" is conceptually Syto's "Source," and the thing you
|
||||
most want to chart in Syto — a cleaned, transformed **Model** — has *no equivalent in Astrolabe at
|
||||
all*. Astrolabe charts static blobs; Syto produces living, recomputed tables. A naive port would
|
||||
most want to chart in Syto — a cleaned, transformed **Model** — has _no equivalent in Astrolabe at
|
||||
all_. Astrolabe charts static blobs; Syto produces living, recomputed tables. A naive port would
|
||||
bolt a second, weaker data-library (Astrolabe datasets) next to Syto's existing one (Sources/Models),
|
||||
which directly violates SOUL's **"No Parallel Systems"** value.
|
||||
|
||||
@@ -54,26 +54,26 @@ This is not a technical blocker; it is a product-identity decision, and per proj
|
||||
|
||||
**What `SOUL.md` currently says:**
|
||||
|
||||
- *"Do One Thing Well… It's not trying to become a spreadsheet, a statistical package, a visualization tool, or a database. The EDA features… exist to help users understand their data before transforming it — not to replace dedicated analysis tools."*
|
||||
- *"Not a BI/visualization platform: Charts are for exploration during wrangling, not final output. Dashboards and reporting are a separate concern."*
|
||||
- _"Do One Thing Well… It's not trying to become a spreadsheet, a statistical package, a visualization tool, or a database. The EDA features… exist to help users understand their data before transforming it — not to replace dedicated analysis tools."_
|
||||
- _"Not a BI/visualization platform: Charts are for exploration during wrangling, not final output. Dashboards and reporting are a separate concern."_
|
||||
|
||||
A **snippet manager** — a personal, searchable, import/exportable *library of saved charts* — is
|
||||
A **snippet manager** — a personal, searchable, import/exportable _library of saved charts_ — is
|
||||
squarely "charts as final output" and "a visualization tool." Porting Astrolabe as-is would
|
||||
contradict two written non-goals.
|
||||
|
||||
**However**, there is a reading that is fully *aligned* with the rest of SOUL:
|
||||
**However**, there is a reading that is fully _aligned_ with the rest of SOUL:
|
||||
|
||||
- *"Beginner-Friendly, Not Beginner-Limited"* and *"Progressive Complexity"* — today a user can clean data but has **no way to turn the result into a shareable picture.** A chart is the natural last step of a wrangling session.
|
||||
- *"Leverage Existing Libraries — Vega-Lite handles charts."* The infrastructure is already paid for.
|
||||
- Astrolabe's **Chart Builder** (pick a mark, map columns → spec) is the *exact* beginner-friendly, no-JSON affordance Syto favors, and the live JSON editor is the power-user escape hatch.
|
||||
- _"Beginner-Friendly, Not Beginner-Limited"_ and _"Progressive Complexity"_ — today a user can clean data but has **no way to turn the result into a shareable picture.** A chart is the natural last step of a wrangling session.
|
||||
- _"Leverage Existing Libraries — Vega-Lite handles charts."_ The infrastructure is already paid for.
|
||||
- Astrolabe's **Chart Builder** (pick a mark, map columns → spec) is the _exact_ beginner-friendly, no-JSON affordance Syto favors, and the live JSON editor is the power-user escape hatch.
|
||||
|
||||
**The decision to make:** Is "produce a chart as the output of a workflow" *part of* doing the one
|
||||
**The decision to make:** Is "produce a chart as the output of a workflow" _part of_ doing the one
|
||||
thing well (wrangling ends in a usable artifact), or is it the BI/viz scope SOUL rejects?
|
||||
|
||||
Two coherent resolutions:
|
||||
|
||||
- **(A) Amend SOUL** to permit *single-chart output of a model* (not dashboards, not a chart library-as-product) — and integrate as a native feature (§6, Option B).
|
||||
- **(B) Keep it separate** — Astrolabe stays its own thing, or lives as a sibling `/tools/` mini-app that merely *shares code* with Syto (§6, Option C). The main app's non-goals stay intact.
|
||||
- **(A) Amend SOUL** to permit _single-chart output of a model_ (not dashboards, not a chart library-as-product) — and integrate as a native feature (§6, Option B).
|
||||
- **(B) Keep it separate** — Astrolabe stays its own thing, or lives as a sibling `/tools/` mini-app that merely _shares code_ with Syto (§6, Option C). The main app's non-goals stay intact.
|
||||
|
||||
I recommend (A) with a tightly-scoped amendment, because the value lands precisely where Syto is
|
||||
currently weakest (no output artifact), and because doing it natively avoids the parallel-systems
|
||||
@@ -85,27 +85,27 @@ trap. But this is the user's call to make against SOUL.
|
||||
|
||||
Legend: 🟢 already exists / strong reuse · 🟡 partial, needs adaptation · 🔴 net-new build
|
||||
|
||||
| Astrolabe feature | Syto today | Verdict | Notes |
|
||||
|---|---|---|---|
|
||||
| **Vega-Lite rendering** | `charts.ts` + `vega-embed@7` render programmatic specs into DOM refs | 🟡 | Engine present; needs a *generic* "render this arbitrary spec" path + error surface. The hardcoded EDA specs don't help directly, but the rendering primitive does. |
|
||||
| **Dataset-reference resolution** (`{data:{name}}` → contents, recursing into layers) | none | 🔴 | New, but small and pure — and in Syto it resolves to a **Model's data**, not a separate dataset store. |
|
||||
| **Fit-mode transforms** (Original/Width/Height/Full via `"container"`) | none | 🔴 | Small, pure, copy-on-render spec rewrite. Directly portable. |
|
||||
| **JSON spec editor** | CodeMirror 6 (`CodeMirrorEditor.tsx`, `JsonEditorModal.tsx`) + lint infra (`linters/`) | 🟡 | Editor & lint plumbing exist. Missing: a **Vega-Lite schema service** for autocomplete + validation. (Note: Astrolabe's "minimap" and "VS Light/Dark/High-Contrast" editor themes are Monaco-isms; Syto is on CodeMirror — those exact settings don't carry over.) |
|
||||
| **Chart Builder** (mark + X/Y/Color/Size + field types → spec) | none | 🟡→🔴 | The single most valuable, most SOUL-aligned piece. Build it against a **Model's columns** using Syto's existing schema types. High reuse of the *dialog* pattern (registry + slide-panel/modal + debounced preview). |
|
||||
| **Column profiling / type inference** | `schema-engine.ts` (integer/float/date/datetime/boolean/json) | 🟢 | Syto's engine **supersedes** Astrolabe's (number/string/date/boolean). Astrolabe→Vega field-type mapping (numeric→Quantitative, date→Temporal, else Nominal) layers on top trivially. |
|
||||
| **Datasets library + manager modal** | Sources/Models already *are* the data library | 🔴 *(avoid)* | Do **not** build. Reuse Sources/Models. Building it = parallel systems. |
|
||||
| **Snippet library** (search/sort/CRUD, draft vs published, status, tags, storage monitor) | none | 🔴 | The genuinely new persistent entity. Only needed if going full snippet-manager (not recommended). Draft/Published has no analog in Syto's undo/redo model. |
|
||||
| **Settings** (editor/performance/formatting) | `ux-settings.ts` + settings dialog | 🟡 | System exists; add render-debounce + a couple of fields. Editor-theme/minimap fields are Monaco-shaped and mostly drop. |
|
||||
| **Import/Export envelope** (snippets+datasets JSON) | Workflow v2 export/import | 🔴 *(avoid)* | A second export format competing with workflow v2. If charts are part of a workflow, they belong *in* the workflow spec or alongside it — not in a rival envelope. |
|
||||
| **App shell / 3-pane layout** | Ribbon + sidebar + table + slide-panel | 🔴 *(avoid)* | Don't graft Astrolabe's shell. A chart view is a *mode/panel within* Syto's shell. |
|
||||
| **URL-hash routing** | Hash routing for source/model/dialog | 🟡 | Reusable, but Astrolabe's `#snippet-…`/`#datasets/…` scheme would **collide**; must namespace under Syto's existing scheme. |
|
||||
| **Keyboard shortcuts** | `EventRouter` owns Ctrl+S (save), Escape priority chain, etc. | 🟡 | **Collisions:** Astrolabe binds Ctrl+S (Publish) and Ctrl+K (Datasets). Syto already owns Ctrl+S. Must reconcile, not adopt verbatim. |
|
||||
| **Offline / PWA / installable** | `vite-plugin-pwa` already configured | 🟢 | Free. |
|
||||
| **i18n** | i18next, en/uk, namespaced | 🟢 | New strings go in a namespace; framework is there. |
|
||||
| **Toasts** | Notification system exists | 🟢 | Reuse. |
|
||||
| Astrolabe feature | Syto today | Verdict | Notes |
|
||||
| ----------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| **Vega-Lite rendering** | `charts.ts` + `vega-embed@7` render programmatic specs into DOM refs | 🟡 | Engine present; needs a _generic_ "render this arbitrary spec" path + error surface. The hardcoded EDA specs don't help directly, but the rendering primitive does. |
|
||||
| **Dataset-reference resolution** (`{data:{name}}` → contents, recursing into layers) | none | 🔴 | New, but small and pure — and in Syto it resolves to a **Model's data**, not a separate dataset store. |
|
||||
| **Fit-mode transforms** (Original/Width/Height/Full via `"container"`) | none | 🔴 | Small, pure, copy-on-render spec rewrite. Directly portable. |
|
||||
| **JSON spec editor** | CodeMirror 6 (`CodeMirrorEditor.tsx`, `JsonEditorModal.tsx`) + lint infra (`linters/`) | 🟡 | Editor & lint plumbing exist. Missing: a **Vega-Lite schema service** for autocomplete + validation. (Note: Astrolabe's "minimap" and "VS Light/Dark/High-Contrast" editor themes are Monaco-isms; Syto is on CodeMirror — those exact settings don't carry over.) |
|
||||
| **Chart Builder** (mark + X/Y/Color/Size + field types → spec) | none | 🟡→🔴 | The single most valuable, most SOUL-aligned piece. Build it against a **Model's columns** using Syto's existing schema types. High reuse of the _dialog_ pattern (registry + slide-panel/modal + debounced preview). |
|
||||
| **Column profiling / type inference** | `schema-engine.ts` (integer/float/date/datetime/boolean/json) | 🟢 | Syto's engine **supersedes** Astrolabe's (number/string/date/boolean). Astrolabe→Vega field-type mapping (numeric→Quantitative, date→Temporal, else Nominal) layers on top trivially. |
|
||||
| **Datasets library + manager modal** | Sources/Models already _are_ the data library | 🔴 _(avoid)_ | Do **not** build. Reuse Sources/Models. Building it = parallel systems. |
|
||||
| **Snippet library** (search/sort/CRUD, draft vs published, status, tags, storage monitor) | none | 🔴 | The genuinely new persistent entity. Only needed if going full snippet-manager (not recommended). Draft/Published has no analog in Syto's undo/redo model. |
|
||||
| **Settings** (editor/performance/formatting) | `ux-settings.ts` + settings dialog | 🟡 | System exists; add render-debounce + a couple of fields. Editor-theme/minimap fields are Monaco-shaped and mostly drop. |
|
||||
| **Import/Export envelope** (snippets+datasets JSON) | Workflow v2 export/import | 🔴 _(avoid)_ | A second export format competing with workflow v2. If charts are part of a workflow, they belong _in_ the workflow spec or alongside it — not in a rival envelope. |
|
||||
| **App shell / 3-pane layout** | Ribbon + sidebar + table + slide-panel | 🔴 _(avoid)_ | Don't graft Astrolabe's shell. A chart view is a _mode/panel within_ Syto's shell. |
|
||||
| **URL-hash routing** | Hash routing for source/model/dialog | 🟡 | Reusable, but Astrolabe's `#snippet-…`/`#datasets/…` scheme would **collide**; must namespace under Syto's existing scheme. |
|
||||
| **Keyboard shortcuts** | `EventRouter` owns Ctrl+S (save), Escape priority chain, etc. | 🟡 | **Collisions:** Astrolabe binds Ctrl+S (Publish) and Ctrl+K (Datasets). Syto already owns Ctrl+S. Must reconcile, not adopt verbatim. |
|
||||
| **Offline / PWA / installable** | `vite-plugin-pwa` already configured | 🟢 | Free. |
|
||||
| **i18n** | i18next, en/uk, namespaced | 🟢 | New strings go in a namespace; framework is there. |
|
||||
| **Toasts** | Notification system exists | 🟢 | Reuse. |
|
||||
|
||||
**Reuse tally:** the *rendering, editing, persistence, settings, schema, i18n, PWA, and toast*
|
||||
substrate is largely present. The *data-model, shell, routing-scheme, and lifecycle* layers of
|
||||
**Reuse tally:** the _rendering, editing, persistence, settings, schema, i18n, PWA, and toast_
|
||||
substrate is largely present. The _data-model, shell, routing-scheme, and lifecycle_ layers of
|
||||
Astrolabe are either redundant with Syto or actively conflicting and should be dropped.
|
||||
|
||||
---
|
||||
@@ -113,24 +113,24 @@ Astrolabe are either redundant with Syto or actively conflicting and should be d
|
||||
## 5. Technical Friction Points (if ported verbatim)
|
||||
|
||||
1. **Parallel data library.** Astrolabe datasets vs Syto Sources/Models — two stores, two
|
||||
profilers, two "named data" concepts. Violates *No Parallel Systems*. (The fix: charts reference
|
||||
profilers, two "named data" concepts. Violates _No Parallel Systems_. (The fix: charts reference
|
||||
Models.)
|
||||
2. **Parallel persistence + export.** A second IndexedDB store layout and a second JSON envelope
|
||||
alongside workflow v2. Two backup formats for users to confuse.
|
||||
3. **Draft/Published has no home.** Syto's non-destructive model is *pipeline steps + undo/redo*,
|
||||
3. **Draft/Published has no home.** Syto's non-destructive model is _pipeline steps + undo/redo_,
|
||||
not a per-document draft/published toggle. Astrolabe's central editing model would be a third,
|
||||
unrelated state concept.
|
||||
4. **Shell mismatch.** Astrolabe's library·editor·preview triptych is a *whole app*. Syto's shell is
|
||||
4. **Shell mismatch.** Astrolabe's library·editor·preview triptych is a _whole app_. Syto's shell is
|
||||
ribbon-driven with slide-panel dialogs. They don't compose; one must yield.
|
||||
5. **Routing & shortcut collisions.** Hash schemes overlap; Ctrl+S/Ctrl+K already bound.
|
||||
6. **Editor-feature gap.** Syto is on CodeMirror (no minimap, different theme model); Astrolabe's
|
||||
settings assume Monaco. And neither today has a **Vega-Lite schema service** — that autocomplete/
|
||||
validation is net-new work on either stack.
|
||||
7. **TopoJSON / arbitrary-JSON data.** Syto Sources are *tabular*. Astrolabe datasets include
|
||||
7. **TopoJSON / arbitrary-JSON data.** Syto Sources are _tabular_. Astrolabe datasets include
|
||||
TopoJSON and arbitrary JSON. Charting a Model covers the tabular case; map/topology charts would
|
||||
be out of scope unless Sources grow a non-tabular kind.
|
||||
|
||||
None of these are unsolvable — but every one of them is *work created by the port itself*, not by
|
||||
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."
|
||||
|
||||
---
|
||||
@@ -138,14 +138,18 @@ the user value. That's the signature of "harvest, don't port."
|
||||
## 6. Integration Options
|
||||
|
||||
### Option A — Full port (snippet manager inside Syto)
|
||||
|
||||
Bring Astrolabe over more-or-less intact: snippet library, dataset manager, draft/published, its
|
||||
shell, its export.
|
||||
|
||||
- **Pros:** Fastest way to "have Astrolabe." Feature-complete chart authoring.
|
||||
- **Cons:** Maximal parallel-systems debt (§5). Directly contradicts SOUL non-goals. Two data
|
||||
libraries, two export formats, shell/routing/shortcut conflicts. **Not recommended.**
|
||||
|
||||
### Option B — Harvest into a native "Visualize" feature ✅ *recommended*
|
||||
Add charting as the natural *output* step of a workflow, reusing Syto's own primitives:
|
||||
### Option B — Harvest into a native "Visualize" feature ✅ _recommended_
|
||||
|
||||
Add charting as the natural _output_ step of a workflow, reusing Syto's own primitives:
|
||||
|
||||
- A **"Chart" / "Visualize"** action on a **Model** opens a **Chart Builder** (Astrolabe's mark +
|
||||
X/Y/Color/Size + field-type controls), populated from the Model's columns and `schema-engine`
|
||||
types.
|
||||
@@ -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
|
||||
**Model's rows**.
|
||||
- Power users get the **JSON spec editor** (CodeMirror, with a Vega-Lite schema service added) as the
|
||||
escape hatch — consistent with *Beginner-Friendly, Not Beginner-Limited*.
|
||||
escape hatch — consistent with _Beginner-Friendly, Not Beginner-Limited_.
|
||||
- The chart (its spec) is persisted **attached to the Model** (or to the workflow), not as a separate
|
||||
snippet entity. Export rides along with workflow v2 (or a sibling field), not a rival envelope.
|
||||
- **Dropped from Astrolabe:** separate dataset library, draft/published, snippet search/sort/tags,
|
||||
storage monitor, its shell, its import/export, its routing scheme.
|
||||
- **Pros:** No parallel systems. Lands value exactly where Syto is weak (no output artifact). Maximal
|
||||
reuse of existing infra. Defensible against SOUL with a *narrow* amendment ("single-chart output of
|
||||
reuse of existing infra. Defensible against SOUL with a _narrow_ amendment ("single-chart output of
|
||||
a model," not dashboards/library).
|
||||
- **Cons:** Requires the SOUL decision (§3). Loses Astrolabe features that depend on the
|
||||
snippet/dataset model (TopoJSON/URL datasets, multi-snippet library). Net-new: schema service,
|
||||
builder dialog, render contract.
|
||||
|
||||
### Option C — Sibling `/tools/` mini-app
|
||||
Port Astrolabe as a self-contained app under `/tools/astrolabe/`, sharing only *code* (vega render
|
||||
|
||||
Port Astrolabe as a self-contained app under `/tools/astrolabe/`, sharing only _code_ (vega render
|
||||
helpers, CodeMirror wrapper, i18n) with the main app — no AppStore/DialogStore coupling.
|
||||
|
||||
- **Pros:** Keeps the main app's non-goals pristine (it's a separate utility, like other tools).
|
||||
Lower conceptual conflict. Astrolabe keeps its own model.
|
||||
- **Cons:** Syto's `/tools/` layer is designed for *small, single-purpose* utilities; Astrolabe is a
|
||||
- **Cons:** Syto's `/tools/` layer is designed for _small, single-purpose_ utilities; Astrolabe is a
|
||||
full application — a stretch for that slot. Still carries Astrolabe's whole parallel data model,
|
||||
just quarantined. "Integration" here means "co-located," not "unified" — limited synergy.
|
||||
|
||||
@@ -178,7 +184,7 @@ helpers, CodeMirror wrapper, i18n) with the main app — no AppStore/DialogStore
|
||||
|
||||
## 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
|
||||
tight scope statement and proceed to Option B.
|
||||
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.
|
||||
|
||||
This delivers the genuinely useful core of Astrolabe — turning cleaned data into a chart, with a
|
||||
beginner path and a power-user path — while staying true to *Do One Thing Well* and *No Parallel
|
||||
Systems*, and reusing the infrastructure Syto has already built.
|
||||
beginner path and a power-user path — while staying true to _Do One Thing Well_ and _No Parallel
|
||||
Systems_, and reusing the infrastructure Syto has already built.
|
||||
|
||||
---
|
||||
|
||||
## 8. Open Questions for the User
|
||||
|
||||
- **SOUL scope:** Is "a chart as the output of a workflow" inside Syto's mission, or out? (Blocks everything.)
|
||||
- **Persistence model:** Should a chart spec live *on a Model*, *in the workflow v2 export*, or as a new top-level entity?
|
||||
- **Persistence model:** Should a chart spec live _on a Model_, _in the workflow v2 export_, or as a new top-level entity?
|
||||
- **Non-tabular data:** Do we ever need TopoJSON / arbitrary-JSON charts (maps), which Syto Sources can't currently hold? If not, that simplifies scope considerably.
|
||||
- **Editor depth:** Is full Vega-Lite schema autocomplete/validation in scope, or is a plain JSON editor + live error surface enough for v1?
|
||||
|
||||
@@ -6,37 +6,37 @@
|
||||
> repository to work from them.
|
||||
>
|
||||
> They are the architectural counterpart to [`docs/spec/`](../spec/): the **spec** says
|
||||
> *what the app does* (behavior, acceptance points); this **playbook** says *how we build
|
||||
> it* (state, persistence, modals, routing, rendering, inference, relationships).
|
||||
> _what the app does_ (behavior, acceptance points); this **playbook** says _how we build
|
||||
> it_ (state, persistence, modals, routing, rendering, inference, relationships).
|
||||
|
||||
## How to use this playbook
|
||||
|
||||
- Building a feature? Read the relevant spec section first (the *what*), then the matching
|
||||
playbook doc (the *how*), then implement core-first per [`../IMPLEMENTATION-PLAN.md`](../IMPLEMENTATION-PLAN.md).
|
||||
- Building a feature? Read the relevant spec section first (the _what_), then the matching
|
||||
playbook doc (the _how_), then implement core-first per [`../IMPLEMENTATION-PLAN.md`](../IMPLEMENTATION-PLAN.md).
|
||||
- Each doc states the pattern, the **rationale** (what problem it solves, what it prevents),
|
||||
TypeScript sketches in Astrolabe terms, and Do/Don't rules.
|
||||
- The sketches are *illustrative*, not finished code. Adapt them; keep the principles.
|
||||
- The sketches are _illustrative_, not finished code. Adapt them; keep the principles.
|
||||
|
||||
## The documents
|
||||
|
||||
| # | Doc | Covers |
|
||||
|---|-----|--------|
|
||||
| 01 | [State & Stores](01-state-and-stores.md) | Zustand stores; one source of truth; selector derivations; central `useAppStore` vs per-feature stores; testable action functions; debounced auto-save. |
|
||||
| 02 | [Persistence](02-persistence.md) | The infrastructure-adapter boundary; promise-wrapped IndexedDB wrapper; lazy data loading; per-record schema versioning + migration; localStorage prefs with fallback; storage tiers + quota monitoring. |
|
||||
| 03 | [Modal System](03-modal-system.md) | Registry + coordinator + shell; one modal at a time; unsaved-change detection via snapshot; focus trap; backdrop/Escape/close dismissal. |
|
||||
| 04 | [Routing & Events](04-routing-and-events.md) | URL hash as view-state (restore/sync, Back/Forward); global keyboard routing; Escape priority chain; the single-source `isInInteractiveContext()` helper (Monaco-aware). |
|
||||
| 05 | [Rendering, Theming & Preview](05-rendering-theming-preview.md) | vega-embed integration (`actions:false`, `view.finalize()`); field-name escaping; theme→config mapping; debounced non-blocking renderer; resilient error display. |
|
||||
| 06 | [Type Inference & Profiling](06-type-inference.md) | Pure, portable column-type inference (number/text/date/boolean) and the dataset profile shape. |
|
||||
| 07 | [Naming & Relationships](07-naming-and-relationships.md) | Unique-name enforcement + import auto-suffix; the bidirectional snippet↔dataset name link; rename propagation into specs. |
|
||||
| 08 | [vega/editor Techniques](08-vega-editor-techniques.md) | Reference brief: borrowable Monaco-schema wiring, vega-embed lifecycle, two-tier validation, and data-flow/debounce techniques distilled from the official Vega-Lite editor — plus where we do better. |
|
||||
| 09 | [Visual Design Language](09-visual-design.md) | The *visual* contract: principles inspired by IBM/Carbon, deliberate divergences (square chrome, free color/theming), the token system (Plex type, 8px spacing, role-based color, motion), component conventions, and where to mine the Carbon/IBM source repos for more. Companion: [`visual-specimen.html`](visual-specimen.html). |
|
||||
| # | Doc | Covers |
|
||||
| --- | --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| 01 | [State & Stores](01-state-and-stores.md) | Zustand stores; one source of truth; selector derivations; central `useAppStore` vs per-feature stores; testable action functions; debounced auto-save. |
|
||||
| 02 | [Persistence](02-persistence.md) | The infrastructure-adapter boundary; promise-wrapped IndexedDB wrapper; lazy data loading; per-record schema versioning + migration; localStorage prefs with fallback; storage tiers + quota monitoring. |
|
||||
| 03 | [Modal System](03-modal-system.md) | Registry + coordinator + shell; one modal at a time; unsaved-change detection via snapshot; focus trap; backdrop/Escape/close dismissal. |
|
||||
| 04 | [Routing & Events](04-routing-and-events.md) | URL hash as view-state (restore/sync, Back/Forward); global keyboard routing; Escape priority chain; the single-source `isInInteractiveContext()` helper (Monaco-aware). |
|
||||
| 05 | [Rendering, Theming & Preview](05-rendering-theming-preview.md) | vega-embed integration (`actions:false`, `view.finalize()`); field-name escaping; theme→config mapping; debounced non-blocking renderer; resilient error display. |
|
||||
| 06 | [Type Inference & Profiling](06-type-inference.md) | Pure, portable column-type inference (number/text/date/boolean) and the dataset profile shape. |
|
||||
| 07 | [Naming & Relationships](07-naming-and-relationships.md) | Unique-name enforcement + import auto-suffix; the bidirectional snippet↔dataset name link; rename propagation into specs. |
|
||||
| 08 | [vega/editor Techniques](08-vega-editor-techniques.md) | Reference brief: borrowable Monaco-schema wiring, vega-embed lifecycle, two-tier validation, and data-flow/debounce techniques distilled from the official Vega-Lite editor — plus where we do better. |
|
||||
| 09 | [Visual Design Language](09-visual-design.md) | The _visual_ contract: principles inspired by IBM/Carbon, deliberate divergences (square chrome, free color/theming), the token system (Plex type, 8px spacing, role-based color, motion), component conventions, and where to mine the Carbon/IBM source repos for more. Companion: [`visual-specimen.html`](visual-specimen.html). |
|
||||
|
||||
## The non-negotiable layering (every doc assumes this)
|
||||
|
||||
- **`src/core/`** — portable, pure logic. No browser APIs, no React, no Monaco. Spec
|
||||
operations live here and are unit-tested hardest. (Docs 06, 07, parts of 05 land here.)
|
||||
- **`src/app/stores/`** — Zustand stores. (Doc 01.)
|
||||
- **`src/app/infrastructure/`** — the *only* place that touches `indexedDB`, `localStorage`,
|
||||
- **`src/app/infrastructure/`** — the _only_ place that touches `indexedDB`, `localStorage`,
|
||||
or `window.location`. Everything else goes through these typed adapters. (Docs 02, 04.)
|
||||
- **`src/app/services/` & `orchestration/`** — coordination that composes stores +
|
||||
infrastructure + core (lifecycle, routing sync, dependency upkeep). (Docs 03, 04, 07.)
|
||||
|
||||
@@ -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.
|
||||
|
||||
**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.
|
||||
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
|
||||
@@ -58,7 +58,7 @@ Three ways to touch a store:
|
||||
- **`set(partial)`** — update state (shallow-merges). Inside actions, the only place
|
||||
that mutates state.
|
||||
- **`get()`** — read current state inside actions without subscribing.
|
||||
- **the hook `useAppStore(selector)`** — read state *in a React component*, subscribing
|
||||
- **the hook `useAppStore(selector)`** — read state _in a React component_, subscribing
|
||||
to exactly what the selector returns.
|
||||
|
||||
### Reading in components — always select narrowly
|
||||
@@ -93,9 +93,11 @@ Services, orchestration, infrastructure, and tests use the store object directly
|
||||
no React involved. This is the property that lets our logic live outside components:
|
||||
|
||||
```ts
|
||||
openModal('settings'); // via the modal coordinator (doc 03)
|
||||
const theme = useAppStore.getState().uiTheme; // snapshot read
|
||||
const unsub = useAppStore.subscribe((s, prev) => { /* react to changes */ });
|
||||
openModal('settings'); // via the modal coordinator (doc 03)
|
||||
const theme = useAppStore.getState().uiTheme; // snapshot read
|
||||
const unsub = useAppStore.subscribe((s, prev) => {
|
||||
/* react to changes */
|
||||
});
|
||||
```
|
||||
|
||||
> Rule: in components, **select narrowly** (and `useShallow` for object/array
|
||||
@@ -106,7 +108,7 @@ const unsub = useAppStore.subscribe((s, prev) => { /* react to changes */ });
|
||||
|
||||
## 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
|
||||
second field you keep in sync by hand.
|
||||
|
||||
@@ -121,8 +123,8 @@ read, drift is structurally impossible.
|
||||
// activeSnippetId: string | null
|
||||
|
||||
// Derive in the component's selector — not a stored field:
|
||||
const activeSnippet = useSnippetStore((s) =>
|
||||
s.snippets.find((x) => x.id === s.activeSnippetId) ?? null,
|
||||
const activeSnippet = useSnippetStore(
|
||||
(s) => s.snippets.find((x) => x.id === s.activeSnippetId) ?? null,
|
||||
);
|
||||
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
|
||||
> 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
|
||||
|
||||
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.
|
||||
|
||||
### Per-feature stores
|
||||
@@ -162,16 +164,16 @@ Each cohesive feature owns a store holding its durable domain state.
|
||||
|
||||
### 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.
|
||||
|
||||
### How to decide
|
||||
|
||||
| Put it in a **feature store** when… | Put it in **`useAppStore`** when… |
|
||||
| --------------------------------------------- | --------------------------------------------- |
|
||||
| It's domain data (snippets, datasets, specs) | It's transient UI chrome (open modal, theme) |
|
||||
| It outlives a single interaction | It belongs to no single feature |
|
||||
| It gets persisted | Multiple unrelated features read/write it |
|
||||
| Put it in a **feature store** when… | Put it in **`useAppStore`** when… |
|
||||
| -------------------------------------------- | -------------------------------------------- |
|
||||
| It's domain data (snippets, datasets, specs) | It's transient UI chrome (open modal, theme) |
|
||||
| It outlives a single interaction | It belongs to no single feature |
|
||||
| It gets persisted | Multiple unrelated features read/write it |
|
||||
|
||||
> Rule: keep `useAppStore` small. When a chunk of it only ever serves one feature,
|
||||
> that's the signal to extract a feature store. A bloated central store is the
|
||||
@@ -253,7 +255,14 @@ export function SnippetList() {
|
||||
{snippets.map((s) => (
|
||||
<li key={s.id} aria-current={s.id === activeSnippetId} onClick={() => select(s.id)}>
|
||||
{s.name}
|
||||
<button onClick={(e) => { e.stopPropagation(); remove(s.id); }}>✕</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
remove(s.id);
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -310,7 +319,9 @@ Subscribers read state and write to `src/app/infrastructure/` adapters (IndexedD
|
||||
|
||||
```ts
|
||||
// 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);
|
||||
useAppStore.subscribe((s, prev) => {
|
||||
if (s.uiTheme !== prev.uiTheme) applyTheme(s.uiTheme);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 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
|
||||
|
||||
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
|
||||
// src/app/infrastructure/db.ts
|
||||
@@ -96,7 +96,7 @@ function wrap<T>(req: IDBRequest<T>): Promise<T> {
|
||||
async function tx<T>(
|
||||
store: string,
|
||||
mode: IDBTransactionMode,
|
||||
run: (s: IDBObjectStore) => IDBRequest<T>
|
||||
run: (s: IDBObjectStore) => IDBRequest<T>,
|
||||
): Promise<T> {
|
||||
const db = await openDB();
|
||||
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
|
||||
|
||||
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.
|
||||
|
||||
@@ -204,14 +204,18 @@ export async function loadSnippets(): Promise<Snippet[]> {
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
- **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.
|
||||
|
||||
> **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 {
|
||||
version: number;
|
||||
editor: { fontSize: number; theme: string; minimap: boolean; wordWrap: 'on' | 'off';
|
||||
lineNumbers: 'on' | 'off'; tabSize: number };
|
||||
editor: {
|
||||
fontSize: number;
|
||||
theme: string;
|
||||
minimap: boolean;
|
||||
wordWrap: 'on' | 'off';
|
||||
lineNumbers: 'on' | 'off';
|
||||
tabSize: number;
|
||||
};
|
||||
performance: { renderDebounce: number };
|
||||
ui: { theme: 'light' | 'dark'; previewFitMode: 'default' | 'width' | 'height' | 'full' };
|
||||
formatting: { dateFormat: 'smart' | 'iso' | 'custom'; customDateFormat: string };
|
||||
@@ -248,8 +258,14 @@ export interface UserSettings {
|
||||
// contract; this is just where it's encoded.
|
||||
const DEFAULTS: UserSettings = {
|
||||
version: CURRENT_SETTINGS_VERSION,
|
||||
editor: { fontSize: 12, theme: 'auto', minimap: false, wordWrap: 'on',
|
||||
lineNumbers: 'on', tabSize: 2 },
|
||||
editor: {
|
||||
fontSize: 12,
|
||||
theme: 'auto',
|
||||
minimap: false,
|
||||
wordWrap: 'on',
|
||||
lineNumbers: 'on',
|
||||
tabSize: 2,
|
||||
},
|
||||
performance: { renderDebounce: 1500 },
|
||||
ui: { theme: 'light', previewFitMode: 'default' },
|
||||
formatting: { dateFormat: 'smart', customDateFormat: '' },
|
||||
@@ -323,11 +339,11 @@ const SORT_DEFAULTS = { sortBy: 'modified' as const, sortOrder: 'desc' as const
|
||||
|
||||
Astrolabe has three tiers with different capacities and risk profiles:
|
||||
|
||||
| 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**. |
|
||||
| **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. |
|
||||
| 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**. |
|
||||
| **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. |
|
||||
|
||||
Splitting snippets and datasets into separate stores means a few large datasets can't crowd out the snippet budget, and the snippet monitor can report a meaningful "how full is my library" number without summing dataset bytes.
|
||||
|
||||
@@ -338,10 +354,10 @@ Use the Storage Manager API where available, with a manual byte-sum fallback for
|
||||
```ts
|
||||
// src/app/infrastructure/storage-monitor.ts
|
||||
export interface StorageReport {
|
||||
snippetBytes: number; // estimated bytes used by the snippet tier
|
||||
snippetBudget: number; // 5 MB practical budget
|
||||
ratio: number; // snippetBytes / snippetBudget, clamped to >= 0
|
||||
warn: boolean; // ratio crossed the warning threshold
|
||||
snippetBytes: number; // estimated bytes used by the snippet tier
|
||||
snippetBudget: number; // 5 MB practical budget
|
||||
ratio: number; // snippetBytes / snippetBudget, clamped to >= 0
|
||||
warn: boolean; // ratio crossed the warning threshold
|
||||
}
|
||||
|
||||
const SNIPPET_BUDGET = 5 * 1024 * 1024;
|
||||
@@ -349,10 +365,7 @@ const WARN_AT = 0.8;
|
||||
|
||||
export async function reportSnippetUsage(snippets: Snippet[]): Promise<StorageReport> {
|
||||
// Cheap, deterministic estimate: serialize the records we hold.
|
||||
const snippetBytes = snippets.reduce(
|
||||
(n, s) => n + new Blob([JSON.stringify(s)]).size,
|
||||
0
|
||||
);
|
||||
const snippetBytes = snippets.reduce((n, s) => n + new Blob([JSON.stringify(s)]).size, 0);
|
||||
const ratio = snippetBytes / SNIPPET_BUDGET;
|
||||
const report: StorageReport = {
|
||||
snippetBytes,
|
||||
@@ -361,9 +374,7 @@ export async function reportSnippetUsage(snippets: Snippet[]): Promise<StorageRe
|
||||
warn: ratio >= WARN_AT,
|
||||
};
|
||||
if (report.warn) {
|
||||
console.warn(
|
||||
`[storage] snippet tier ${(ratio * 100).toFixed(0)}% of ${SNIPPET_BUDGET} bytes`
|
||||
);
|
||||
console.warn(`[storage] snippet tier ${(ratio * 100).toFixed(0)}% of ${SNIPPET_BUDGET} bytes`);
|
||||
}
|
||||
return report;
|
||||
}
|
||||
@@ -380,15 +391,17 @@ export async function saveSnippet(s: Snippet): Promise<void> {
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === 'QuotaExceededError') {
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> **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.
|
||||
> **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.
|
||||
|
||||
---
|
||||
|
||||
@@ -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.
|
||||
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.
|
||||
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`.
|
||||
7. Test the adapter against `fake-indexeddb` / a localStorage stub; test the migration with fixtures from each historical version.
|
||||
|
||||
@@ -19,11 +19,11 @@ authoritative architecture for adding, opening, closing, and rendering modals.
|
||||
|
||||
The system is three layers, each with a single responsibility:
|
||||
|
||||
| Layer | Responsibility | Lives in |
|
||||
|-------|----------------|----------|
|
||||
| **Registry** | Static metadata per modal (title, validity, snapshot, init) | `src/app/modals/modal-registry.ts` |
|
||||
| **Coordinator** | Lifecycle: open, close, URL sync, change detection | `src/app/modals/ModalCoordinator.ts` |
|
||||
| **Shell** | Render exactly one modal; backdrop / Escape / focus trap | `src/app/App.tsx` + a `useFocusTrap` hook |
|
||||
| Layer | Responsibility | Lives in |
|
||||
| --------------- | ----------------------------------------------------------- | ----------------------------------------- |
|
||||
| **Registry** | Static metadata per modal (title, validity, snapshot, init) | `src/app/modals/modal-registry.ts` |
|
||||
| **Coordinator** | Lifecycle: open, close, URL sync, change detection | `src/app/modals/ModalCoordinator.ts` |
|
||||
| **Shell** | Render exactly one modal; backdrop / Escape / focus trap | `src/app/App.tsx` + a `useFocusTrap` hook |
|
||||
|
||||
---
|
||||
|
||||
@@ -35,12 +35,12 @@ registry, coordinator, and shell are exhaustively type-checked.
|
||||
```ts
|
||||
// src/app/modals/types.ts
|
||||
export type ModalName =
|
||||
| 'datasets' // Datasets manager (list / detail / new-dataset form)
|
||||
| 'settings' // Appearance, editor, performance, formatting prefs
|
||||
| 'about' // About & Help (shortcuts, privacy)
|
||||
| 'donate' // Donate
|
||||
| 'chartBuilder' // Visual no-JSON chart composition for a dataset
|
||||
| 'extract'; // Extract inline spec data into a new dataset
|
||||
| 'datasets' // Datasets manager (list / detail / new-dataset form)
|
||||
| 'settings' // Appearance, editor, performance, formatting prefs
|
||||
| 'about' // About & Help (shortcuts, privacy)
|
||||
| 'donate' // Donate
|
||||
| 'chartBuilder' // Visual no-JSON chart composition for a dataset
|
||||
| 'extract'; // Extract inline spec data into a new dataset
|
||||
|
||||
export type ActiveModal = ModalName | null;
|
||||
```
|
||||
@@ -68,8 +68,8 @@ import type { ModalName } from './types';
|
||||
|
||||
export interface ModalConfig {
|
||||
name: ModalName;
|
||||
title: string; // i18n key or literal
|
||||
component: ComponentType<any>; // the body rendered inside the shell
|
||||
title: string; // i18n key or literal
|
||||
component: ComponentType<any>; // the body rendered inside the shell
|
||||
|
||||
/** Initialize transient modal state when it opens. `arg` carries an
|
||||
* optional sub-target (e.g. a dataset id for chartBuilder/extract). */
|
||||
@@ -120,8 +120,8 @@ export const MODAL_REGISTRY: Record<ModalName, ModalConfig> = {
|
||||
getState: () => {
|
||||
const s = useDatasetStore.getState();
|
||||
return {
|
||||
view: s.view, // 'list' | 'detail' | 'new'
|
||||
draft: s.draftForm, // in-progress new/edit form
|
||||
view: s.view, // 'list' | 'detail' | 'new'
|
||||
draft: s.draftForm, // in-progress new/edit form
|
||||
};
|
||||
},
|
||||
hasError: () => useDatasetStore.getState().formError !== null,
|
||||
@@ -149,15 +149,25 @@ export const MODAL_REGISTRY: Record<ModalName, ModalConfig> = {
|
||||
init: (sourceKey) => useExtractStore.getState().initFrom(sourceKey),
|
||||
getState: () => ({ name: useExtractStore.getState().name }),
|
||||
hasError: () => useExtractStore.getState().name.trim() === '',
|
||||
getError: () =>
|
||||
useExtractStore.getState().name.trim() ? null : 'modals.extract.nameRequired',
|
||||
getError: () => (useExtractStore.getState().name.trim() ? null : 'modals.extract.nameRequired'),
|
||||
},
|
||||
|
||||
// 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.
|
||||
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 },
|
||||
};
|
||||
```
|
||||
@@ -171,8 +181,7 @@ directly:
|
||||
export const getModalConfig = (name: ActiveModal): ModalConfig | undefined =>
|
||||
name ? MODAL_REGISTRY[name] : undefined;
|
||||
|
||||
export const getModalTitle = (name: ActiveModal): string =>
|
||||
getModalConfig(name)?.title ?? '';
|
||||
export const getModalTitle = (name: ActiveModal): string => getModalConfig(name)?.title ?? '';
|
||||
|
||||
export const isUrlNavigable = (name: ActiveModal): boolean =>
|
||||
getModalConfig(name)?.isUrlNavigable ?? false;
|
||||
@@ -185,12 +194,14 @@ export const isUrlNavigable = (name: ActiveModal): boolean =>
|
||||
> component.
|
||||
|
||||
**Do**
|
||||
|
||||
- Add a modal by appending one `MODAL_REGISTRY` entry and writing its component.
|
||||
- Express validity through `hasError` / `getError` so the shell's action button
|
||||
and tooltip stay generic.
|
||||
- Omit `getState` for any modal that commits changes immediately.
|
||||
|
||||
**Don't**
|
||||
|
||||
- Don't `switch (activeModal)` outside the shell's body render. Lookups belong
|
||||
in registry helpers.
|
||||
- Don't put rendering or DOM concerns in the registry — it is pure metadata.
|
||||
@@ -231,15 +242,15 @@ import { MODAL_REGISTRY, getModalConfig } from './modal-registry';
|
||||
import { syncModalToUrl, clearModalFromUrl } from './UrlStateSync';
|
||||
|
||||
let confirmDiscard: (msg: string) => Promise<boolean> = async () => true;
|
||||
export const setConfirm = (fn: typeof confirmDiscard) => { confirmDiscard = fn; };
|
||||
export const setConfirm = (fn: typeof confirmDiscard) => {
|
||||
confirmDiscard = fn;
|
||||
};
|
||||
|
||||
// Coordinator-internal: the getState() JSON captured at open, compared on close.
|
||||
let stateSnapshot: string | null = null;
|
||||
|
||||
const snapshot = (name: ActiveModal) =>
|
||||
getModalConfig(name)?.getState
|
||||
? JSON.stringify(getModalConfig(name)!.getState!())
|
||||
: null;
|
||||
getModalConfig(name)?.getState ? JSON.stringify(getModalConfig(name)!.getState!()) : null;
|
||||
|
||||
/** Open `name`, optionally with a sub-target (dataset id, source key). */
|
||||
export function openModal(name: ModalName, arg?: string): void {
|
||||
@@ -247,7 +258,7 @@ export function openModal(name: ModalName, arg?: string): void {
|
||||
useAppStore.getState().setActiveModal(name);
|
||||
getModalConfig(name)?.init?.(arg);
|
||||
stateSnapshot = snapshot(name);
|
||||
syncModalToUrl(name, arg); // no-op when !isUrlNavigable
|
||||
syncModalToUrl(name, arg); // no-op when !isUrlNavigable
|
||||
}
|
||||
|
||||
/** Close the active modal. Prompts on unsaved changes unless `force`. */
|
||||
@@ -278,7 +289,7 @@ export function toggleDatasets(): void {
|
||||
```ts
|
||||
export function hasUnsavedChanges(): boolean {
|
||||
const name = useAppStore.getState().activeModal;
|
||||
if (!name || stateSnapshot === null) return false; // no snapshot ⇒ opted out
|
||||
if (!name || stateSnapshot === null) return false; // no snapshot ⇒ opted out
|
||||
const current = getModalConfig(name)?.getState?.();
|
||||
if (current == null) return false;
|
||||
return JSON.stringify(current) !== stateSnapshot;
|
||||
@@ -306,11 +317,13 @@ export const activeModalError = (): string | null =>
|
||||
> directly could bypass the discard check or leave the URL stale.
|
||||
|
||||
**Do**
|
||||
|
||||
- Route every open/close through `openModal` / `closeModal`.
|
||||
- Take the snapshot in `openModal` (after `init`) and compare in `closeModal`.
|
||||
- Keep the coordinator DOM-free so it can be tested with plain Vitest.
|
||||
|
||||
**Don't**
|
||||
|
||||
- Don't mutate `activeModal` directly from components or handlers.
|
||||
- Don't skip `closeModal`'s unsaved-change check by toggling state manually;
|
||||
pass `force` only when the user has explicitly saved or confirmed.
|
||||
@@ -350,8 +363,10 @@ export function App() {
|
||||
{config && (
|
||||
<div
|
||||
className={styles.backdrop}
|
||||
onClick={() => void closeModal()} // backdrop dismisses
|
||||
onKeyDown={(e) => { if (e.key === 'Escape') void closeModal(); }}
|
||||
onClick={() => void closeModal()} // backdrop dismisses
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') void closeModal();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={modalRef}
|
||||
@@ -359,11 +374,13 @@ export function App() {
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="modal-title"
|
||||
onClick={(e) => e.stopPropagation()} // inside body never dismisses
|
||||
onClick={(e) => e.stopPropagation()} // inside body never dismisses
|
||||
>
|
||||
<header className={styles.modalHeader}>
|
||||
<h2 id="modal-title">{t(getModalTitle(name))}</h2>
|
||||
<button aria-label={t('buttons.close')} onClick={() => void closeModal()}>×</button>
|
||||
<button aria-label={t('buttons.close')} onClick={() => void closeModal()}>
|
||||
×
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className={styles.modalBody}>
|
||||
@@ -382,7 +399,9 @@ export function App() {
|
||||
className="btn-primary"
|
||||
aria-disabled={hasError || 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')}
|
||||
</button>
|
||||
@@ -428,9 +447,15 @@ export function useFocusTrap<T extends HTMLElement = HTMLDivElement>(active: boo
|
||||
if (e.key !== 'Tab') return;
|
||||
const f = el.querySelectorAll<HTMLElement>(FOCUSABLE);
|
||||
if (!f.length) return;
|
||||
const first = f[0], last = f[f.length - 1];
|
||||
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
|
||||
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
|
||||
const first = f[0],
|
||||
last = f[f.length - 1];
|
||||
if (e.shiftKey && document.activeElement === first) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
} else if (!e.shiftKey && document.activeElement === last) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
};
|
||||
|
||||
el.addEventListener('keydown', onKey);
|
||||
@@ -451,6 +476,7 @@ export function useFocusTrap<T extends HTMLElement = HTMLDivElement>(active: boo
|
||||
> accessibility is fixed once.
|
||||
|
||||
**Do**
|
||||
|
||||
- Render the active modal via `<config.component />` — the single mapping point.
|
||||
- Put `onClick={closeModal}` on the backdrop and `stopPropagation` on the body.
|
||||
- Compute `hasError`/`getError`/preview reads with a selector at the shell level.
|
||||
@@ -458,6 +484,7 @@ export function useFocusTrap<T extends HTMLElement = HTMLDivElement>(active: boo
|
||||
tooltip.
|
||||
|
||||
**Don't**
|
||||
|
||||
- Don't render two modals simultaneously, and don't stack a second backdrop.
|
||||
- Don't attach the focus trap to the backdrop — attach it to the modal body so
|
||||
the backdrop click stays outside the trap.
|
||||
|
||||
@@ -33,14 +33,14 @@ Zustand stores. Components never read `location.hash` or attach
|
||||
|
||||
The hash is the serialized view. Astrolabe's forms:
|
||||
|
||||
| State | Hash |
|
||||
| ----------------------------- | --------------------------------- |
|
||||
| Default snippets view | _(empty / absent)_ |
|
||||
| A selected snippet | `#snippet-<id>` |
|
||||
| Datasets manager (list) | `#datasets` |
|
||||
| A specific dataset | `#datasets/dataset-<id>` |
|
||||
| New-dataset form | `#datasets/new` |
|
||||
| Chart Builder for a dataset | `#datasets/dataset-<id>/build` |
|
||||
| State | Hash |
|
||||
| --------------------------- | ------------------------------ |
|
||||
| Default snippets view | _(empty / absent)_ |
|
||||
| A selected snippet | `#snippet-<id>` |
|
||||
| Datasets manager (list) | `#datasets` |
|
||||
| A specific dataset | `#datasets/dataset-<id>` |
|
||||
| New-dataset form | `#datasets/new` |
|
||||
| Chart Builder for a dataset | `#datasets/dataset-<id>/build` |
|
||||
|
||||
Snippet `id` is an opaque string; dataset `id` is the numeric dataset id
|
||||
rendered as a decimal string. The hash is the **only** persisted view-routing
|
||||
@@ -57,12 +57,12 @@ parsing is a total function with no side effects; writing is the only place
|
||||
```ts
|
||||
// src/app/infrastructure/url-hash.ts
|
||||
export type ViewState =
|
||||
| { kind: 'snippets' } // empty hash
|
||||
| { kind: 'snippet'; snippetId: string } // #snippet-<id>
|
||||
| { kind: 'datasets' } // #datasets
|
||||
| { kind: 'dataset'; datasetId: number } // #datasets/dataset-<id>
|
||||
| { kind: 'dataset-new' } // #datasets/new
|
||||
| { kind: 'dataset-build'; datasetId: number }; // .../build
|
||||
| { kind: 'snippets' } // empty hash
|
||||
| { kind: 'snippet'; snippetId: string } // #snippet-<id>
|
||||
| { kind: 'datasets' } // #datasets
|
||||
| { kind: 'dataset'; datasetId: number } // #datasets/dataset-<id>
|
||||
| { kind: 'dataset-new' } // #datasets/new
|
||||
| { kind: 'dataset-build'; datasetId: number }; // .../build
|
||||
|
||||
export function parseHash(rawHash: string): ViewState {
|
||||
const hash = rawHash.replace(/^#/, '');
|
||||
@@ -88,12 +88,18 @@ export function parseHash(rawHash: string): ViewState {
|
||||
|
||||
export function serializeHash(view: ViewState): string {
|
||||
switch (view.kind) {
|
||||
case 'snippets': return '';
|
||||
case 'snippet': return `#snippet-${view.snippetId}`;
|
||||
case 'datasets': return '#datasets';
|
||||
case 'dataset': return `#datasets/dataset-${view.datasetId}`;
|
||||
case 'dataset-new': return '#datasets/new';
|
||||
case 'dataset-build': return `#datasets/dataset-${view.datasetId}/build`;
|
||||
case 'snippets':
|
||||
return '';
|
||||
case 'snippet':
|
||||
return `#snippet-${view.snippetId}`;
|
||||
case 'datasets':
|
||||
return '#datasets';
|
||||
case 'dataset':
|
||||
return `#datasets/dataset-${view.datasetId}`;
|
||||
case 'dataset-new':
|
||||
return '#datasets/new';
|
||||
case 'dataset-build':
|
||||
return `#datasets/dataset-${view.datasetId}/build`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,10 +151,10 @@ use the History API the way above, but a defensive `applying` flag keeps the
|
||||
// src/app/orchestration/UrlStateSync.ts
|
||||
import { useSnippetStore } from '../stores/SnippetStore';
|
||||
import { useDatasetStore } from '../stores/DatasetStore';
|
||||
import { useAppStore } from '../stores/AppStore'; // activeModal, etc.
|
||||
import { useAppStore } from '../stores/AppStore'; // activeModal, etc.
|
||||
import { readView, replaceView, pushView, type ViewState } from '../infrastructure/url-hash';
|
||||
|
||||
let applying = false; // suppress re-entrancy while we drive the stores
|
||||
let applying = false; // suppress re-entrancy while we drive the stores
|
||||
let started = false;
|
||||
|
||||
// Restore/reconcile is the one path that writes `activeModal` with the bare
|
||||
@@ -165,7 +171,10 @@ function applyView(view: ViewState): void {
|
||||
return;
|
||||
case 'snippet': {
|
||||
const snippet = useSnippetStore.getState().byId(view.snippetId);
|
||||
if (!snippet) { replaceView({ kind: 'snippets' }); return; }
|
||||
if (!snippet) {
|
||||
replaceView({ kind: 'snippets' });
|
||||
return;
|
||||
}
|
||||
useAppStore.getState().setActiveModal(null);
|
||||
useSnippetStore.getState().select(view.snippetId);
|
||||
return;
|
||||
@@ -176,7 +185,10 @@ function applyView(view: ViewState): void {
|
||||
case 'dataset':
|
||||
case 'dataset-build': {
|
||||
const ds = useDatasetStore.getState().byId(view.datasetId);
|
||||
if (!ds) { replaceView({ kind: 'datasets' }); return; }
|
||||
if (!ds) {
|
||||
replaceView({ kind: 'datasets' });
|
||||
return;
|
||||
}
|
||||
useAppStore.getState().setActiveModal('datasets');
|
||||
useDatasetStore.getState().select(view.datasetId);
|
||||
if (view.kind === 'dataset-build') useAppStore.getState().setActiveModal('chartBuilder');
|
||||
@@ -304,7 +316,7 @@ function onKeyDown(e: KeyboardEvent): void {
|
||||
}
|
||||
// Cmd/Ctrl + S -> publish current draft
|
||||
if (mod && e.key.toLowerCase() === 's') {
|
||||
e.preventDefault(); // override the browser "save page" dialog
|
||||
e.preventDefault(); // override the browser "save page" dialog
|
||||
useSnippetStore.getState().publishDraft();
|
||||
return;
|
||||
}
|
||||
@@ -436,8 +448,8 @@ import { startEventRouter } from './EventRouter';
|
||||
|
||||
export function initApp(): void {
|
||||
// ... load settings + hydrate snippet/dataset stores from IndexedDB/localStorage ...
|
||||
startUrlStateSync(); // restore view from hash, then keep hash <-> stores in sync
|
||||
startEventRouter(); // bind global keyboard/paste routing
|
||||
startUrlStateSync(); // restore view from hash, then keep hash <-> stores in sync
|
||||
startEventRouter(); // bind global keyboard/paste routing
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -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
|
||||
re-rendering** so typing stays smooth, and **error handling** so a broken spec
|
||||
produces a readable message and self-heals. It deliberately stops at the
|
||||
embedding boundary — the *content* of the spec (resolving named-dataset
|
||||
embedding boundary — the _content_ of the spec (resolving named-dataset
|
||||
references, applying fit-mode sizing) is prepared upstream by a pure transform;
|
||||
see §6.
|
||||
|
||||
@@ -63,9 +63,9 @@ export async function renderSpec(
|
||||
config: Config,
|
||||
): Promise<RenderHandle> {
|
||||
const result: EmbedResult = await vegaEmbed(node, spec, {
|
||||
actions: false, // no built-in export/source/editor menu — clean chart
|
||||
renderer: 'svg', // crisp, inspectable, copyable output
|
||||
config, // theme config (see §3)
|
||||
actions: false, // no built-in export/source/editor menu — clean chart
|
||||
renderer: 'svg', // crisp, inspectable, copyable output
|
||||
config, // theme config (see §3)
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -82,7 +82,7 @@ export async function renderSpec(
|
||||
|
||||
Every successful `vegaEmbed` returns a `result.view` (a live Vega `View`
|
||||
instance). It owns timers, signal listeners, and DOM. If you embed a new spec
|
||||
into the same node *without* finalizing the old view, the old one leaks — its
|
||||
into the same node _without_ finalizing the old view, the old one leaks — its
|
||||
listeners keep firing and resources accumulate over a long editing session.
|
||||
|
||||
The renderer that drives re-rendering must therefore hold the previous handle and
|
||||
@@ -92,7 +92,7 @@ destroy it before (or while) creating the next:
|
||||
let current: RenderHandle | null = null;
|
||||
|
||||
async function rerender(node: HTMLElement, spec: TopLevelSpec, config: Config) {
|
||||
current?.destroy(); // tear down the previous view first
|
||||
current?.destroy(); // tear down the previous view first
|
||||
current = await renderSpec(node, spec, config);
|
||||
}
|
||||
```
|
||||
@@ -181,7 +181,7 @@ re-rendering picks up the new config and the chart restyles automatically.
|
||||
|
||||
### 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.
|
||||
- **Do** set chart `background: 'transparent'` so the pane's own background shows
|
||||
through and theme switches look seamless.
|
||||
@@ -233,8 +233,8 @@ export function escapeVegaField(name: string): string {
|
||||
encoding.x = { field: escapeVegaField(columnName), type: 'quantitative' };
|
||||
```
|
||||
|
||||
This matters wherever Astrolabe *constructs* spec fragments from data-derived
|
||||
column names — most notably the chart builder (see *Chart Builder* spec) and any
|
||||
This matters wherever Astrolabe _constructs_ spec fragments from data-derived
|
||||
column names — most notably the chart builder (see _Chart Builder_ spec) and any
|
||||
helper that injects an encoding. For specs the user authored by hand, escaping is
|
||||
the user's responsibility; Astrolabe does not rewrite hand-authored `field:`
|
||||
values.
|
||||
@@ -293,15 +293,21 @@ export function createDebouncedRenderer(opts: {
|
||||
|
||||
return {
|
||||
schedule() {
|
||||
if (timer) clearTimeout(timer); // cancel the pending render
|
||||
if (timer) clearTimeout(timer); // cancel the pending render
|
||||
timer = setTimeout(run, opts.delayMs());
|
||||
},
|
||||
flush() {
|
||||
if (timer) { clearTimeout(timer); timer = null; }
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
void run();
|
||||
},
|
||||
cancel() {
|
||||
if (timer) { clearTimeout(timer); timer = null; }
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
generation++; // abandon any in-flight result
|
||||
},
|
||||
};
|
||||
@@ -330,7 +336,7 @@ useSettingsStore.subscribe((s, prev) => {
|
||||
### Busy indicator
|
||||
|
||||
`setBusy(true/false)` toggles store state that the preview reads to overlay a
|
||||
**subtle, non-blocking** spinner/shimmer. It sits *over* the existing chart so the
|
||||
**subtle, non-blocking** spinner/shimmer. It sits _over_ the existing chart so the
|
||||
last good render stays visible while the next one computes — the pane never goes
|
||||
blank mid-edit.
|
||||
|
||||
@@ -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
|
||||
the corresponding dimension(s) to `"container"`), recursing the same way.
|
||||
|
||||
This is *content* preparation, not embedding, and it is fully covered by the
|
||||
*Live Preview* spec. The only invariant this doc cares about:
|
||||
This is _content_ preparation, not embedding, and it is fully covered by the
|
||||
_Live Preview_ spec. The only invariant this doc cares about:
|
||||
|
||||
> `prepareSpecForRender` runs on a copy and returns a new spec. The renderer
|
||||
> embeds that returned spec. **The user's stored spec is never mutated by
|
||||
@@ -392,11 +398,11 @@ A spec that cannot be rendered must produce a **readable** message in the previe
|
||||
area and recover on its own once the spec is valid again. Errors arise at three
|
||||
stages, all funneled to one error field the preview reads:
|
||||
|
||||
| Stage | Failure | Surfaced as |
|
||||
|---|---|---|
|
||||
| Parse | Invalid JSON | "Invalid JSON: …" |
|
||||
| Stage | Failure | Surfaced as |
|
||||
| -------------------------------- | -------------------------------------- | ---------------------- |
|
||||
| Parse | Invalid JSON | "Invalid JSON: …" |
|
||||
| Prepare (`prepareSpecForRender`) | Referenced dataset missing/unfetchable | "Dataset not found: …" |
|
||||
| Embed (`vega-embed`) | Vega-Lite compile / data error | "Rendering error: …" |
|
||||
| Embed (`vega-embed`) | Vega-Lite compile / data error | "Rendering error: …" |
|
||||
|
||||
```ts
|
||||
// inside render(), driven by the debounced renderer
|
||||
@@ -427,10 +433,12 @@ async function render(): Promise<void> {
|
||||
current = await renderSpec(node, prepared, config);
|
||||
usePreviewStore.getState().setError(null); // success clears any prior error
|
||||
} catch (e) {
|
||||
usePreviewStore.getState().setError(
|
||||
`Rendering error: ${(e as Error).message}. ` +
|
||||
`Check your JSON syntax and that the spec is valid Vega-Lite.`,
|
||||
);
|
||||
usePreviewStore
|
||||
.getState()
|
||||
.setError(
|
||||
`Rendering error: ${(e as Error).message}. ` +
|
||||
`Check your JSON syntax and that the spec is valid Vega-Lite.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -457,12 +465,12 @@ manual retry, no reload.
|
||||
|
||||
## Summary
|
||||
|
||||
| Concern | Mechanism | Source of truth |
|
||||
|---|---|---|
|
||||
| Embedding | One `renderSpec` over `vega-embed`, `actions: false`, `renderer: 'svg'` | `src/app/services/chart-renderer.ts` |
|
||||
| View teardown | `view.finalize()` before each re-render and on unmount | the renderer's `RenderHandle` |
|
||||
| Theming | Vega `Config` per UI theme, applied at embed time | `chartConfigFor()` in `src/core/vega-themes.ts` |
|
||||
| Field names | `escapeVegaField` on every data-derived `field:` | `src/core/rendering.ts` |
|
||||
| Debounce | `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*) |
|
||||
| Errors | One error field, cleared on success, empty = nothing | `PreviewStore.error` |
|
||||
| Concern | Mechanism | Source of truth |
|
||||
| ------------- | ----------------------------------------------------------------------- | ----------------------------------------------- |
|
||||
| Embedding | One `renderSpec` over `vega-embed`, `actions: false`, `renderer: 'svg'` | `src/app/services/chart-renderer.ts` |
|
||||
| View teardown | `view.finalize()` before each re-render and on unmount | the renderer's `RenderHandle` |
|
||||
| Theming | Vega `Config` per UI theme, applied at embed time | `chartConfigFor()` in `src/core/vega-themes.ts` |
|
||||
| Field names | `escapeVegaField` on every data-derived `field:` | `src/core/rendering.ts` |
|
||||
| Debounce | `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_) |
|
||||
| Errors | One error field, cleared on success, empty = nothing | `PreviewStore.error` |
|
||||
|
||||
@@ -13,7 +13,7 @@ flow, the detail panel) calls into this module; nothing here reaches back out.
|
||||
|
||||
## 1. Why infer types at all
|
||||
|
||||
A dataset is just rows of values. The UI wants to *describe* it without
|
||||
A dataset is just rows of values. The UI wants to _describe_ it without
|
||||
re-parsing the payload every time: how many rows and columns, what the columns
|
||||
are called, and roughly what each column contains. The inferred type drives the
|
||||
small type indicator next to each column name in the dataset detail panel and
|
||||
@@ -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
|
||||
evidence for any other type.
|
||||
3. **Run the type checks in precedence order.** For each candidate type, ask:
|
||||
*does **every** surviving value match this type?* The first candidate for
|
||||
_does **every** surviving value match this type?_ The first candidate for
|
||||
which the answer is yes wins. This is the **"all values match → that type,
|
||||
else fall back"** rule: one stray value that doesn't fit knocks the column
|
||||
down to the next candidate, and ultimately to `string`.
|
||||
@@ -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
|
||||
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.)
|
||||
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
|
||||
@@ -82,7 +82,7 @@ overlap, and we want the most specific interpretation that fits.
|
||||
whitespace so `Number("") === 0` doesn't sneak through.
|
||||
- **boolean**: native `boolean` values pass; otherwise the trimmed,
|
||||
lower-cased string must be exactly `"true"` or `"false"`.
|
||||
- **date**: guard *before* parsing. Require the trimmed value to look
|
||||
- **date**: guard _before_ parsing. Require the trimmed value to look
|
||||
date-shaped (a leading `YYYY-MM-DD` or `YYYY/MM/DD`, or `M/D/YYYY`) **and**
|
||||
then confirm `Date.parse` returns a finite timestamp. The shape guard is
|
||||
essential: `Date.parse` will happily accept `"42"` or `"March"` on some
|
||||
@@ -155,7 +155,7 @@ export function inferColumnType(values: readonly unknown[]): ColumnType {
|
||||
- **Do** ignore empty cells before classifying.
|
||||
- **Do** keep the precedence boolean → number → date → string.
|
||||
- **Do** guard date detection with a shape regex before trusting `Date.parse`.
|
||||
- **Don't** classify a column unless *every* present value matches — one
|
||||
- **Don't** classify a column unless _every_ present value matches — one
|
||||
outlier means `string`.
|
||||
- **Don't** add more types (integer, float, datetime, json). Four, no more.
|
||||
- **Don't** let `Number("")`, `Date.parse("42")`, or `0`/`1` leak into the wrong
|
||||
@@ -169,13 +169,13 @@ A **profile** is the set of derived summary fields stored on a dataset record so
|
||||
the UI can describe it without re-parsing the payload. Per the data model, a
|
||||
profiled dataset carries:
|
||||
|
||||
| Field | Type | Meaning |
|
||||
| ------------- | --------------------------------- | -------------------------------------- |
|
||||
| `rowCount` | `number \| null` | Data rows, or `null` when N/A. |
|
||||
| `columnCount` | `number \| null` | Columns, or `null` when N/A. |
|
||||
| `columns` | `string[]` | Column names, in order. |
|
||||
| `columnTypes` | `Array<{ name; type }>` | Per-column inferred type (see §2). |
|
||||
| `size` | `number` | Approximate payload size in bytes. |
|
||||
| Field | Type | Meaning |
|
||||
| ------------- | ----------------------- | ---------------------------------- |
|
||||
| `rowCount` | `number \| null` | Data rows, or `null` when N/A. |
|
||||
| `columnCount` | `number \| null` | Columns, or `null` when N/A. |
|
||||
| `columns` | `string[]` | Column names, in order. |
|
||||
| `columnTypes` | `Array<{ name; type }>` | Per-column inferred type (see §2). |
|
||||
| `size` | `number` | Approximate payload size in bytes. |
|
||||
|
||||
`null` row/column counts and an empty `columns`/`columnTypes` are how the UI
|
||||
shows **"N/A"** — see §3.2.
|
||||
@@ -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
|
||||
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
|
||||
ambiguous. We reject duplicate names on create/rename, and auto-suffix
|
||||
collisions during bulk import.
|
||||
2. **Relationship tracking** — a snippet references datasets *by name* through
|
||||
2. **Relationship tracking** — a snippet references datasets _by name_ through
|
||||
its `datasetRefs: string[]` field. This is a bidirectional, name-based link:
|
||||
from a snippet you read its refs; from a dataset you scan snippets to find
|
||||
who uses it. Renaming a dataset must propagate to every snippet that points
|
||||
@@ -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
|
||||
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
|
||||
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.
|
||||
- **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.
|
||||
|
||||
`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). */
|
||||
export function findSnippetsReferencingDataset(name: string): Snippet[] {
|
||||
const lower = name.toLowerCase();
|
||||
return useSnippetStore.getState().snippets.filter((s) =>
|
||||
s.datasetRefs.some((ref) => ref.toLowerCase() === lower),
|
||||
);
|
||||
return useSnippetStore
|
||||
.getState()
|
||||
.snippets.filter((s) => s.datasetRefs.some((ref) => ref.toLowerCase() === lower));
|
||||
}
|
||||
|
||||
/** Count for the usage badge. */
|
||||
@@ -240,7 +240,7 @@ they update the moment any snippet is published with changed refs.
|
||||
**Don't**
|
||||
|
||||
- Don't add a `referencedBy` array to datasets. A stored reverse pointer is a
|
||||
second source of truth that *will* fall out of sync with `datasetRefs`.
|
||||
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
|
||||
is renamed to a unique name via `makeUniqueName`, and **every rename is
|
||||
collected and reported to the user** (toast / summary) so the change is never
|
||||
silent. Crucially, names are reserved *as we go* — within a single import, two
|
||||
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`.
|
||||
|
||||
```ts
|
||||
@@ -292,7 +292,7 @@ export function dedupeIncomingDatasetNames(
|
||||
|
||||
**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.
|
||||
- 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>)) {
|
||||
if (
|
||||
k === 'data' &&
|
||||
v && typeof v === 'object' &&
|
||||
v &&
|
||||
typeof v === 'object' &&
|
||||
(v as Record<string, Json>).name === oldName
|
||||
) {
|
||||
out[k] = { ...(v as object), name: newName };
|
||||
@@ -416,17 +417,17 @@ export function renameDatasetEverywhere(oldName: string, newName: string): { upd
|
||||
|
||||
## 7. Where things live
|
||||
|
||||
| Concern | Location | Pure? | Tested |
|
||||
|---|---|---|---|
|
||||
| `makeUniqueName`, `isNameTaken` | `src/core/naming.ts` | yes | unit |
|
||||
| `extractDatasetRefs`, `recomputeDatasetRefs` | `src/core/spec-refs.ts` | yes | unit |
|
||||
| `renameDatasetInSpec` | `src/core/spec-refs.ts` | yes | unit |
|
||||
| `findSnippetsReferencingDataset`, usage count | `src/app/services/RelationshipService.ts` | no (reads store) | integration |
|
||||
| `renameDatasetEverywhere` | `src/app/services/RelationshipService.ts` | no (mutates stores) | integration |
|
||||
| `dedupeIncomingDatasetNames` | `src/app/services/ImportService.ts` | nearly (uses `makeUniqueName`) | unit/integration |
|
||||
| Concern | Location | Pure? | Tested |
|
||||
| --------------------------------------------- | ----------------------------------------- | ------------------------------ | ---------------- |
|
||||
| `makeUniqueName`, `isNameTaken` | `src/core/naming.ts` | yes | unit |
|
||||
| `extractDatasetRefs`, `recomputeDatasetRefs` | `src/core/spec-refs.ts` | yes | unit |
|
||||
| `renameDatasetInSpec` | `src/core/spec-refs.ts` | yes | unit |
|
||||
| `findSnippetsReferencingDataset`, usage count | `src/app/services/RelationshipService.ts` | no (reads store) | integration |
|
||||
| `renameDatasetEverywhere` | `src/app/services/RelationshipService.ts` | no (mutates stores) | integration |
|
||||
| `dedupeIncomingDatasetNames` | `src/app/services/ImportService.ts` | nearly (uses `makeUniqueName`) | unit/integration |
|
||||
|
||||
The dividing line: anything that takes plain data and returns plain data is
|
||||
**core** and unit-tested in isolation; anything that reaches into a Zustand store
|
||||
is 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
|
||||
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
|
||||
the bidirectional link from ever needing manual repair.
|
||||
|
||||
@@ -20,13 +20,13 @@ that tree.
|
||||
|
||||
## Stack delta (read this first — it changes how directly we can borrow)
|
||||
|
||||
| | vega/editor | Astrolabe |
|
||||
|---|---|---|
|
||||
| UI framework | **React** | **React** (moved off Preact before build start) |
|
||||
| State | Redux-ish single `State` in React context (`useState`) | Zustand **stores** — *not* Redux |
|
||||
| Monaco | `@monaco-editor/react` + `@monaco-editor/loader` (CDN-loaded Monaco, **workers auto-wired**) | **raw `monaco-editor`** via Vite (**we must wire workers ourselves**) |
|
||||
| Rendering | **hand-rolled** `vegaLite.compile` → `vega.parse` → `new vega.View().runAsync()` | **`vegaEmbed()`** (wraps that same pipeline) |
|
||||
| Schema validation | Monaco JSON worker **+** standalone `ajv ^8` (two independent layers) | same two-layer model planned |
|
||||
| | vega/editor | Astrolabe |
|
||||
| ----------------- | -------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- |
|
||||
| UI framework | **React** | **React** (moved off Preact before build start) |
|
||||
| State | Redux-ish single `State` in React context (`useState`) | Zustand **stores** — _not_ Redux |
|
||||
| Monaco | `@monaco-editor/react` + `@monaco-editor/loader` (CDN-loaded Monaco, **workers auto-wired**) | **raw `monaco-editor`** via Vite (**we must wire workers ourselves**) |
|
||||
| Rendering | **hand-rolled** `vegaLite.compile` → `vega.parse` → `new vega.View().runAsync()` | **`vegaEmbed()`** (wraps that same pipeline) |
|
||||
| Schema validation | Monaco JSON worker **+** standalone `ajv ^8` (two independent layers) | same two-layer model planned |
|
||||
|
||||
Because both apps are now React, vega/editor's **component lifecycle patterns port more or
|
||||
less directly** — the friction is only in (a) state (their Redux-flat-state → our Zustand
|
||||
@@ -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
|
||||
URL-dataset fetches"; (3) **determinism** — npm + `package-lock` is integrity-pinned and
|
||||
reproducible, a runtime CDN resolve is not. This axis is not a close call; vega/editor's CDN
|
||||
choice is right *for an online hosted tool* and wrong for an offline, installable, private app.
|
||||
choice is right _for an online hosted tool_ and wrong for an offline, installable, private app.
|
||||
|
||||
**Axis B — React integration: raw API, not `@monaco-editor/react`. (A lean, not forced.)**
|
||||
The wrapper helps with the easy 80% (mount a JSON editor, lifecycle) and adds nothing to the
|
||||
load-bearing 20% this app needs:
|
||||
|
||||
- **Workers** are still ours — the wrapper never manages `MonacoEnvironment` (see §1 gotcha).
|
||||
- The **M2 schema service** (`jsonDefaults.setDiagnosticsOptions`, `fileMatch`) is namespace-level;
|
||||
you reach *through* the wrapper via `onMount`, so it saves nothing there.
|
||||
you reach _through_ the wrapper via `onMount`, so it saves nothing there.
|
||||
- Its headline **`value`/`onChange` controlled-input model is a hazard**: driving Monaco's
|
||||
content from React state causes cursor jumps and undo-stack churn, against §10's "typing
|
||||
stays fluid" — you end up using it uncontrolled, i.e. the raw pattern anyway.
|
||||
@@ -77,11 +78,11 @@ 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:
|
||||
|
||||
| Import | What you get | Use? |
|
||||
|---|---|---|
|
||||
| `monaco-editor` (barrel) | All features **+ every basic language** (sql, abap, solidity, …) | ❌ language bloat (~20 dead chunks) |
|
||||
| `esm/vs/editor/editor.api` | The API surface only — **zero feature contributions** | ❌ a text box: no folding, suggest widget, `Cmd+Backspace`, find, bracket colorization |
|
||||
| `esm/vs/editor/edcore.main` | `editor.all` (all 59 feature contributions) + API, **no languages** | ✅ full editor UX, JSON-only weight |
|
||||
| Import | What you get | Use? |
|
||||
| --------------------------- | ------------------------------------------------------------------- | -------------------------------------------------------------------------------------- |
|
||||
| `monaco-editor` (barrel) | All features **+ every basic language** (sql, abap, solidity, …) | ❌ language bloat (~20 dead chunks) |
|
||||
| `esm/vs/editor/editor.api` | The API surface only — **zero feature contributions** | ❌ a text box: no folding, suggest widget, `Cmd+Backspace`, find, bracket colorization |
|
||||
| `esm/vs/editor/edcore.main` | `editor.all` (all 59 feature contributions) + API, **no languages** | ✅ full editor UX, JSON-only weight |
|
||||
|
||||
Import **`edcore.main`** and add only the JSON language service
|
||||
(`esm/vs/language/json/monaco.contribution`). `edcore.main` ships no `.d.ts` of its own —
|
||||
@@ -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
|
||||
> 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
|
||||
> version (`src/components/renderer/renderer.tsx`) is the best available documentation of the
|
||||
> 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:**
|
||||
|
||||
- ⚠️ **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
|
||||
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
|
||||
@@ -201,7 +202,7 @@ sorts errors into two tiers. Both are worth copying.
|
||||
|
||||
1. **Monaco JSON worker** → inline **squiggles, hovers, autocomplete** in the editor.
|
||||
2. **`ajv ^8`** (`src/utils/validate.ts`) → runs at parse time, feeds the **error/log pane**.
|
||||
It does *not* create editor markers.
|
||||
It does _not_ create editor markers.
|
||||
|
||||
**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`,
|
||||
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
|
||||
otherwise.
|
||||
@@ -227,15 +228,15 @@ throw=fatal).
|
||||
per keystroke is a perf killer.
|
||||
|
||||
**Where we improve:** ajv errors are shown as JSON-pointer text (e.g. `/encoding/x`) with **no
|
||||
editor position** — vega/editor does not map them to markers. Only JSON *syntax* errors get a
|
||||
editor position** — vega/editor does not map them to markers. Only JSON _syntax_ errors get a
|
||||
line/col (via jsonc-parser's visitor, `utils/jsonc-parser.ts:3-17`). If our §03E wants inline
|
||||
ajv markers, we map `instancePath` → editor offsets ourselves via jsonc-parser's node tree —
|
||||
something the reference does *not* do.
|
||||
something the reference does _not_ do.
|
||||
|
||||
## 4 · Data flow & debouncing (M1/M2 — translate to Zustand stores)
|
||||
|
||||
vega/editor keeps **`editorString` (the text) as the single source of truth**; the parsed spec
|
||||
and compiled Vega spec are *derived* and recomputed by a subscriber when text/mode/config change
|
||||
and compiled Vega spec are _derived_ and recomputed by a subscriber when text/mode/config change
|
||||
(`app.tsx:338-365`). Errors don't clobber the last-good derived specs.
|
||||
|
||||
**The Zustand-store translation (this is the shape to build):**
|
||||
@@ -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
|
||||
**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.)
|
||||
- **A manual-parse escape hatch** (Ctrl/Cmd+S re-parses without waiting) maps to a future
|
||||
live-vs-manual preview toggle (`renderer.tsx:89-111`).
|
||||
- **`LocalLogger` pattern** (`utils/logger.ts`): a logger that buffers `errors/warns/infos/debugs`
|
||||
into arrays instead of writing to console. This lets a **pure** `src/core` compile/validate
|
||||
step *return* structured diagnostics with zero browser coupling — e.g.
|
||||
step _return_ structured diagnostics with zero browser coupling — e.g.
|
||||
`validateSpec(spec) → { errors, warns }`. Ideal core-first fit.
|
||||
- **`json-stringify-pretty-compact`** for the format action and prettify-on-load — much nicer
|
||||
than `JSON.stringify(…, null, 2)` for VL specs.
|
||||
|
||||
**Persistence note:** vega/editor snapshots its whole state to localStorage on *every* change,
|
||||
**Persistence note:** vega/editor snapshots its whole state to localStorage on _every_ change,
|
||||
stripping non-serializable fields (`view`, `runtime`, editor refs) and restoring via
|
||||
`{ ...DEFAULT_STATE, ...parsed }` (`context/app-context.tsx`). Our **debounced auto-save to
|
||||
IndexedDB** (doc 01/02) is the better pattern — but the "strip non-serializable, restore with
|
||||
@@ -272,20 +273,20 @@ defaults-spread" discipline is worth keeping.
|
||||
|
||||
## Borrow list (where each lands)
|
||||
|
||||
| Technique | Lands in | Milestone |
|
||||
|---|---|---|
|
||||
| Bundle VL schema from package `build/`; `setDiagnosticsOptions` | `src/app/infrastructure/` Monaco setup | M2 |
|
||||
| `markdownDescription` patch + compact formatter | Monaco setup | M2 |
|
||||
| Explicit Vite worker wiring (`MonacoEnvironment.getWorker`) | Monaco setup | M2 |
|
||||
| `fileMatch` schema binding (improvement over `$schema`-only) | Monaco setup | M2 |
|
||||
| jsonc-parser tolerant parse + line/col syntax errors | `src/core/` | M1/M2 |
|
||||
| ajv wrapper (`strict:false`, draft-06, color-hex, compile-once) → structured diagnostics | `src/core/` | M2 |
|
||||
| `LocalLogger`-style buffered diagnostics from pure compile | `src/core/` | M2 |
|
||||
| Fatal-vs-advisory two-tier error model | rendering/store contract | M1/M2 |
|
||||
| `"container"` sizing + `ResizeObserver` → `view.resize()` | `rendering.ts` + LivePreview | M2 |
|
||||
| `finalize()`-before-reembed + **render-generation guard** | LivePreview | M1 |
|
||||
| theme = `vega-themes` config merged into `vegaEmbed` | preview + settings | M5 |
|
||||
| `json-stringify-pretty-compact` format action | editor | M2 |
|
||||
| Technique | Lands in | Milestone |
|
||||
| ---------------------------------------------------------------------------------------- | -------------------------------------- | --------- |
|
||||
| Bundle VL schema from package `build/`; `setDiagnosticsOptions` | `src/app/infrastructure/` Monaco setup | M2 |
|
||||
| `markdownDescription` patch + compact formatter | Monaco setup | M2 |
|
||||
| Explicit Vite worker wiring (`MonacoEnvironment.getWorker`) | Monaco setup | M2 |
|
||||
| `fileMatch` schema binding (improvement over `$schema`-only) | Monaco setup | M2 |
|
||||
| jsonc-parser tolerant parse + line/col syntax errors | `src/core/` | M1/M2 |
|
||||
| ajv wrapper (`strict:false`, draft-06, color-hex, compile-once) → structured diagnostics | `src/core/` | M2 |
|
||||
| `LocalLogger`-style buffered diagnostics from pure compile | `src/core/` | M2 |
|
||||
| Fatal-vs-advisory two-tier error model | rendering/store contract | M1/M2 |
|
||||
| `"container"` sizing + `ResizeObserver` → `view.resize()` | `rendering.ts` + LivePreview | M2 |
|
||||
| `finalize()`-before-reembed + **render-generation guard** | LivePreview | M1 |
|
||||
| theme = `vega-themes` config merged into `vegaEmbed` | preview + settings | M5 |
|
||||
| `json-stringify-pretty-compact` format action | editor | M2 |
|
||||
|
||||
## Where we deliberately do better than the reference
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# 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/`
|
||||
> (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,
|
||||
> 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
|
||||
**not a dependency** — we transcribe the values we want and reinterpret the
|
||||
principles in our own words. We borrow IBM's *engineered structure*; we keep
|
||||
*color and theming free*.
|
||||
principles in our own words. We borrow IBM's _engineered structure_; we keep
|
||||
_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
|
||||
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.
|
||||
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,
|
||||
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.
|
||||
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.
|
||||
|
||||
…plus our own, where we part ways with IBM:
|
||||
|
||||
5. **Structure is rigorous; color is free.** The grid, type scale, spacing, and
|
||||
square geometry are systematic and fixed. Color, accent, and theming are the
|
||||
*expressive* layer — open, swappable, and meant to be played with.
|
||||
_expressive_ layer — open, swappable, and meant to be played with.
|
||||
|
||||
---
|
||||
|
||||
@@ -45,15 +45,15 @@ the star; the UI is a thin, considered shell"). Restated for us:
|
||||
What we **borrow** vs. where we **diverge** — recorded so future readers know these
|
||||
were choices, not drift:
|
||||
|
||||
| Topic | IBM/Carbon | Astrolabe |
|
||||
|---|---|---|
|
||||
| Adoption | A framework + component lib | **Inspiration only.** Transcribed tokens, our own components |
|
||||
| Structure (grid, type, spacing) | 8px mini unit, modular type scale | **Borrowed wholesale** — it's the rigorous part worth having |
|
||||
| UI chrome corners | ~0–2px (near-square) | **Fully square, `radius: 0`** — one notch more austere/engineered |
|
||||
| Icons | Rounded exteriors, 2px soft corners + 90° interiors | **Kept rounded** (use Carbon's icon set) — the one warm, human touch |
|
||||
| Color | "Blue at the core"; other hues only for purpose | **Dropped.** Color/theming is free and expressive; accent is a token, many themes welcome |
|
||||
| Neutrals | Carbon gray ramp | **Borrowed** — accessible, well-tuned, a good legible base |
|
||||
| Motion | Productive vs. expressive | **Productive only** — subtle, purposeful, reduced-motion-aware |
|
||||
| Topic | IBM/Carbon | Astrolabe |
|
||||
| ------------------------------- | --------------------------------------------------- | ----------------------------------------------------------------------------------------- |
|
||||
| Adoption | A framework + component lib | **Inspiration only.** Transcribed tokens, our own components |
|
||||
| Structure (grid, type, spacing) | 8px mini unit, modular type scale | **Borrowed wholesale** — it's the rigorous part worth having |
|
||||
| UI chrome corners | ~0–2px (near-square) | **Fully square, `radius: 0`** — one notch more austere/engineered |
|
||||
| Icons | Rounded exteriors, 2px soft corners + 90° interiors | **Kept rounded** (use Carbon's icon set) — the one warm, human touch |
|
||||
| Color | "Blue at the core"; other hues only for purpose | **Dropped.** Color/theming is free and expressive; accent is a token, many themes welcome |
|
||||
| Neutrals | Carbon gray ramp | **Borrowed** — accessible, well-tuned, a good legible base |
|
||||
| Motion | Productive vs. expressive | **Productive only** — subtle, purposeful, reduced-motion-aware |
|
||||
|
||||
---
|
||||
|
||||
@@ -71,16 +71,16 @@ tokens/themes before porting them across.
|
||||
`@fontsource/ibm-plex-sans` + `@fontsource/ibm-plex-mono` (offline/PWA — never a
|
||||
CDN). The specimen uses a CDN purely for preview convenience.
|
||||
- **Scale (px), from Carbon's modular scale:** `12 · 14 · 16 · 18 · 20 · 24 · 28 ·
|
||||
32 · 42`. Body is **14/20** (already our `--font-size-base`). Captions/labels 12.
|
||||
32 · 42`. Body is **14/20** (already our `--font-size-base`). Captions/labels 12.
|
||||
- **Weights:** 400 regular, 600 semibold for emphasis/headings; 300 light reserved
|
||||
for large display only.
|
||||
- **Breathing room:** Plex *"requires space to breathe."* Don't over-tighten —
|
||||
- **Breathing room:** Plex _"requires space to breathe."_ Don't over-tighten —
|
||||
body line-height ≥ 1.4, default tracking (no negative letter-spacing on text).
|
||||
Flush-left, clear hierarchy.
|
||||
|
||||
### 3.2 Spacing — the 8px base unit
|
||||
|
||||
IBM's product/web rule: *"the 8px mini unit guides everything."* Every gap, pad,
|
||||
IBM's product/web rule: _"the 8px mini unit guides everything."_ Every gap, pad,
|
||||
and size is a relationship of 8 (with 2/4 as fine sub-steps):
|
||||
|
||||
`--space-1: 2px · --space-2: 4px · --space-3: 8px · --space-4: 12px · --space-5:
|
||||
@@ -94,15 +94,15 @@ and size is a relationship of 8 (with 2/4 as fine sub-steps):
|
||||
Color is expressed as **roles**, never raw hexes, so themes can repaint the whole
|
||||
UI by swapping one set of values. Borrowed from Carbon's layering model:
|
||||
|
||||
| Role token | Meaning |
|
||||
|---|---|
|
||||
| `--bg` | App canvas (lowest layer) |
|
||||
| `--layer-01` / `--layer-02` | Raised surfaces (panels, cards, popovers) — elevation by lightness step, not shadow |
|
||||
| `--border` / `--border-strong` | Subtle and prominent separators |
|
||||
| `--text` / `--text-secondary` / `--text-placeholder` | Text hierarchy |
|
||||
| `--accent` / `--accent-hover` / `--accent-contrast` | The expressive accent — **swappable**; UI must never hardcode a hue |
|
||||
| `--focus` | Focus-ring color (defaults to `--accent`) |
|
||||
| `--support-error / -success / -warning / -info` | Status only — color = meaning |
|
||||
| Role token | Meaning |
|
||||
| ---------------------------------------------------- | ----------------------------------------------------------------------------------- |
|
||||
| `--bg` | App canvas (lowest layer) |
|
||||
| `--layer-01` / `--layer-02` | Raised surfaces (panels, cards, popovers) — elevation by lightness step, not shadow |
|
||||
| `--border` / `--border-strong` | Subtle and prominent separators |
|
||||
| `--text` / `--text-secondary` / `--text-placeholder` | Text hierarchy |
|
||||
| `--accent` / `--accent-hover` / `--accent-contrast` | The expressive accent — **swappable**; UI must never hardcode a hue |
|
||||
| `--focus` | Focus-ring color (defaults to `--accent`) |
|
||||
| `--support-error / -success / -warning / -info` | Status only — color = meaning |
|
||||
|
||||
- **Neutrals** use the Carbon gray ramp (`#f4f4f4 … #161616`) — accessible and
|
||||
legible. **Accent and theming are open**: the specimen ships several accents
|
||||
@@ -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,
|
||||
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
|
||||
starting point, but not mandatory). Light and dark variants. This is where
|
||||
expressive color earns its keep.
|
||||
@@ -170,13 +170,13 @@ The chart `Config` is themed to match the app, per theme:
|
||||
|
||||
## 6. Implementation map
|
||||
|
||||
| Artifact | Role |
|
||||
|---|---|
|
||||
| [`visual-specimen.html`](./visual-specimen.html) | Living preview + token sandbox. Iterate here first |
|
||||
| `styles/tokens.css` | The settled tokens — ported from the specimen in M1.5 |
|
||||
| `styles/base.css` | Font wiring (`@fontsource`), reset, reduced-motion |
|
||||
| component `*.module.css` | Consume tokens only; no raw hexes, no hardcoded hue |
|
||||
| `src/core/vega-themes.ts` | Chart `Config` per theme; categorical palettes |
|
||||
| Artifact | Role |
|
||||
| ------------------------------------------------ | ----------------------------------------------------- |
|
||||
| [`visual-specimen.html`](./visual-specimen.html) | Living preview + token sandbox. Iterate here first |
|
||||
| `styles/tokens.css` | The settled tokens — ported from the specimen in M1.5 |
|
||||
| `styles/base.css` | Font wiring (`@fontsource`), reset, reduced-motion |
|
||||
| component `*.module.css` | Consume tokens only; no raw hexes, no hardcoded hue |
|
||||
| `src/core/vega-themes.ts` | Chart `Config` per theme; categorical palettes |
|
||||
|
||||
**Order of work:** settle the specimen → port tokens to `tokens.css` → self-host
|
||||
Plex in `base.css` → restyle existing M1 components against the tokens → align
|
||||
@@ -192,13 +192,13 @@ JS-rendered and don't fetch cleanly — **clone the repo and read it locally ins
|
||||
Convention: clone under `/Users/oleh/code/reference/` with
|
||||
`git clone --depth 1 https://github.com/carbon-design-system/<repo>.git`.
|
||||
|
||||
| Need | Repo | Where it lives |
|
||||
|---|---|---|
|
||||
| **Principles / the "why"** (philosophy, 2x grid, color rationale, type, motion, icon geometry) | `design-language-website` | `src/pages/`: `philosophy/principles.mdx`, `2x-grid.mdx`, `color.mdx`, `typography/*.mdx`, `animation/overview.mdx`, `iconography/ui-icons/design.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` |
|
||||
| **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` |
|
||||
| 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) |
|
||||
| **Token values** (gray/blue ramps, type scale, font families, motion durations/easings, theme role→value maps) | `carbon` | `packages/colors/src/colors.ts`, `packages/type/src/{scale,fontFamily,fontWeight}.ts`, `packages/motion/src/index.ts`, `packages/themes/src/{white,g100}.ts` |
|
||||
| **Component-level usage guidance** | `carbon-website` | `src/pages/**/*.mdx` |
|
||||
| **Data-viz categorical chart palette** (for `vega-themes.ts` `range.category`) | `carbon-charts` | cloned in M1.5 → `packages/core/scss/_color-palette.scss` (the `'14'` pairing, white + g100); token→hex resolved against `carbon` `packages/colors/src/colors.ts` |
|
||||
|
||||
> The decisions we made *from* these sources are captured above (§1–6) and in the
|
||||
> The decisions we made _from_ these sources are captured above (§1–6) and in the
|
||||
> specimen, so we don't need to re-derive them — only return to the repos to extend
|
||||
> the research (e.g. the chart palette, or a component pattern we haven't tackled).
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
# 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
|
||||
|
||||
@@ -8,7 +8,7 @@ Astrolabe is a local-first tool for authoring, organizing, and previewing Vega-L
|
||||
|
||||
## 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
|
||||
|
||||
@@ -22,39 +22,39 @@ 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.
|
||||
- **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.
|
||||
|
||||
## 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 collaboration or multi-user features.
|
||||
- No general BI/dashboarding — a snippet is a single Vega-Lite visualization, not a composed report.
|
||||
|
||||
## Key Concepts (Glossary)
|
||||
|
||||
- **Snippet** — a saved Vega-Lite specification plus metadata (name, comment, timestamps, tags, dataset references). The primary user-authored entity. See *Snippet Library* and *Data Model & Persistence*.
|
||||
- **Snippet** — a saved Vega-Lite specification plus metadata (name, comment, timestamps, tags, dataset references). The primary user-authored entity. See _Snippet Library_ and _Data Model & Persistence_.
|
||||
- **Spec** — the Vega-Lite JSON specification that defines one visualization.
|
||||
- **Draft vs Published** — each snippet holds a stable **published** spec and an editable **draft**; edits affect only the draft until the user publishes. See *Spec Editor & Draft/Published Workflow*.
|
||||
- **Dataset** — a named, reusable data source (JSON/CSV/TSV/TopoJSON; inline or URL) that snippets reference by name. See *Datasets*.
|
||||
- **Draft vs Published** — each snippet holds a stable **published** spec and an editable **draft**; edits affect only the draft until the user publishes. See _Spec Editor & Draft/Published Workflow_.
|
||||
- **Dataset** — a named, reusable data source (JSON/CSV/TSV/TopoJSON; inline or URL) that snippets reference by name. See _Datasets_.
|
||||
- **Dataset reference** — a Vega-Lite named-data reference, e.g. `{ "data": { "name": "MyDataset" } }`, linking a spec to a stored dataset.
|
||||
- **Live preview** — the rendered chart, updated automatically as the spec changes. See *Live Preview*.
|
||||
- **Live preview** — the rendered chart, updated automatically as the spec changes. See _Live Preview_.
|
||||
|
||||
## How This Specification Is Organized
|
||||
|
||||
| # | Section | Covers |
|
||||
|---|---------|--------|
|
||||
| 00 | Product Overview | This document — purpose, scope, glossary. |
|
||||
| 01 | Application Shell & Navigation | Layout, panes, header, modals, keyboard shortcuts, URL state, toasts, offline/installable. |
|
||||
| 02 | Snippet Library | Browsing, search, sort, metadata, create/duplicate/delete, storage monitor. |
|
||||
| 03 | Spec Editor & Draft/Published Workflow | Editing, auto-save, auto-render, draft/publish/revert, extract-to-dataset. |
|
||||
| 04 | Live Preview | Rendering, reference resolution, fit modes, error display. |
|
||||
| 05 | Datasets | Dataset manager, formats, sources, profiling, references, linking. |
|
||||
| 06 | Chart Builder | Visual no-JSON chart composition from a dataset. |
|
||||
| 07 | Settings | Appearance, editor, performance, and formatting preferences. |
|
||||
| 08 | Import & Export | Backup/transfer file format, import normalization and merging. |
|
||||
| 09 | Data Model & Persistence | Entity field definitions, storage tiers, relationships. |
|
||||
| 10 | Non-Functional Requirements | Platform, performance, accessibility, reliability, privacy. |
|
||||
| # | Section | Covers |
|
||||
| --- | -------------------------------------- | ------------------------------------------------------------------------------------------ |
|
||||
| 00 | Product Overview | This document — purpose, scope, glossary. |
|
||||
| 01 | Application Shell & Navigation | Layout, panes, header, modals, keyboard shortcuts, URL state, toasts, offline/installable. |
|
||||
| 02 | Snippet Library | Browsing, search, sort, metadata, create/duplicate/delete, storage monitor. |
|
||||
| 03 | Spec Editor & Draft/Published Workflow | Editing, auto-save, auto-render, draft/publish/revert, extract-to-dataset. |
|
||||
| 04 | Live Preview | Rendering, reference resolution, fit modes, error display. |
|
||||
| 05 | Datasets | Dataset manager, formats, sources, profiling, references, linking. |
|
||||
| 06 | Chart Builder | Visual no-JSON chart composition from a dataset. |
|
||||
| 07 | Settings | Appearance, editor, performance, and formatting preferences. |
|
||||
| 08 | Import & Export | Backup/transfer file format, import normalization and merging. |
|
||||
| 09 | Data Model & Persistence | Entity field definitions, storage tiers, relationships. |
|
||||
| 10 | Non-Functional Requirements | Platform, performance, accessibility, reliability, privacy. |
|
||||
|
||||
Read 00 first for orientation, then any section independently. Sections cross-reference one another by title where behavior spans more than one area.
|
||||
|
||||
@@ -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:
|
||||
|
||||
- **Snippet library** (left) — browse, search, select, and manage saved snippets (see *Snippet Library*).
|
||||
- **Spec editor** (center) — edit the Vega-Lite spec of the selected snippet (see *Spec Editor & Draft/Published Workflow*).
|
||||
- **Live preview** (right) — render the current spec (see *Live Preview*).
|
||||
- **Snippet library** (left) — browse, search, select, and manage saved snippets (see _Snippet Library_).
|
||||
- **Spec editor** (center) — edit the Vega-Lite spec of the selected snippet (see _Spec Editor & Draft/Published Workflow_).
|
||||
- **Live preview** (right) — render the current spec (see _Live Preview_).
|
||||
|
||||
Behavior:
|
||||
|
||||
@@ -27,19 +27,19 @@ A fixed header spans the top of the app.
|
||||
- **Left side**: the app icon, the app title ("Astrolabe"), and a version badge showing the current app version.
|
||||
- **Right side**: a row of text entry points. Each opens a destination:
|
||||
|
||||
| Entry point | Opens |
|
||||
|---|---|
|
||||
| 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*). |
|
||||
| Datasets | The Datasets manager modal (see *Datasets*). |
|
||||
| Settings | The Settings modal (see *Settings*). |
|
||||
| About & Privacy | The About & Help modal (keyboard shortcuts, about, and privacy information). |
|
||||
| Donate | The Donate modal. |
|
||||
| Entry point | Opens |
|
||||
| --------------- | --------------------------------------------------------------------------------------------------------------- |
|
||||
| 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_). |
|
||||
| Datasets | The Datasets manager modal (see _Datasets_). |
|
||||
| Settings | The Settings modal (see _Settings_). |
|
||||
| About & Privacy | The About & Help modal (keyboard shortcuts, about, and privacy information). |
|
||||
| Donate | The Donate modal. |
|
||||
|
||||
Notes:
|
||||
|
||||
- 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
|
||||
|
||||
@@ -48,20 +48,20 @@ 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.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
## D. Keyboard Shortcuts
|
||||
|
||||
Shortcuts are platform-aware: the modifier is **Cmd** on Mac and **Ctrl** on other platforms (shown below as Cmd/Ctrl).
|
||||
|
||||
| Shortcut | Action |
|
||||
|---|---|
|
||||
| Cmd/Ctrl + Shift + N | Create a new snippet (see *Snippet Library*) |
|
||||
| Cmd/Ctrl + K | Toggle the Datasets manager open/closed |
|
||||
| Cmd/Ctrl + S | Publish the current snippet's draft (see *Spec Editor & Draft/Published Workflow*) |
|
||||
| Cmd/Ctrl + , | Open the Settings modal |
|
||||
| Escape | Close the active modal |
|
||||
| Shortcut | Action |
|
||||
| -------------------- | ---------------------------------------------------------------------------------- |
|
||||
| Cmd/Ctrl + Shift + N | Create a new snippet (see _Snippet Library_) |
|
||||
| Cmd/Ctrl + K | Toggle the Datasets manager open/closed |
|
||||
| Cmd/Ctrl + S | Publish the current snippet's draft (see _Spec Editor & Draft/Published Workflow_) |
|
||||
| Cmd/Ctrl + , | Open the Settings modal |
|
||||
| Escape | Close the active modal |
|
||||
|
||||
Notes:
|
||||
|
||||
@@ -75,12 +75,12 @@ The app reflects its current location in the URL hash so that reloading restores
|
||||
|
||||
States and their hash forms:
|
||||
|
||||
| State | Hash |
|
||||
|---|---|
|
||||
| A selected snippet | `#snippet-<id>` |
|
||||
| Datasets manager (list) | `#datasets` |
|
||||
| A specific dataset | `#datasets/dataset-<id>` |
|
||||
| New-dataset form | `#datasets/new` |
|
||||
| State | Hash |
|
||||
| --------------------------- | ------------------------------ |
|
||||
| A selected snippet | `#snippet-<id>` |
|
||||
| Datasets manager (list) | `#datasets` |
|
||||
| A specific dataset | `#datasets/dataset-<id>` |
|
||||
| New-dataset form | `#datasets/new` |
|
||||
| Chart Builder for a dataset | `#datasets/dataset-<id>/build` |
|
||||
|
||||
Behavior:
|
||||
@@ -111,5 +111,5 @@ Events that raise toasts include:
|
||||
Astrolabe is local-first and usable without a network connection.
|
||||
|
||||
- After the first successful load, the app works fully offline; the interface and previously loaded content remain available with no connection.
|
||||
- All snippets, datasets, and settings are stored locally and remain accessible offline (see *Data Model*).
|
||||
- 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.
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
# 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 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*).
|
||||
- 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*).
|
||||
- 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_).
|
||||
- Selecting a snippet makes it the **active snippet**: it loads into the editor and preview, becomes highlighted in the list, and the URL updates to reflect the selected snippet so the state is shareable and survives a page reload (see _Application Shell & Navigation_).
|
||||
- Exactly one snippet is active at a time.
|
||||
- When no snippets match the current search, the list shows an empty-state message ("No snippets match your search"); 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.
|
||||
@@ -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.
|
||||
|
||||
- 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 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 **status indicator** distinguishing a snippet that has unpublished draft changes from one that is fully published (the indicator communicates "draft" vs "published"). The publish and revert actions themselves live in _Spec Editor & Draft/Published Workflow_.
|
||||
- Shows a small **dataset icon** when the snippet references one or more external datasets (see _Datasets_); the icon is omitted otherwise.
|
||||
- The active snippet is visually highlighted.
|
||||
|
||||
## Search
|
||||
@@ -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.
|
||||
- Default ordering is **Modified, descending** (newest changes first).
|
||||
- Name sorts alphabetically; Size sorts by stored snippet size; Created and Modified sort chronologically.
|
||||
- The **Modified** time advances on every save — including silent draft auto-saves (see *Spec Editor & Draft/Published Workflow*) and inline name/comment edits — so under the default Modified-descending sort the active snippet continually rises to the top while it is being edited.
|
||||
- The **Modified** time advances on every save — including silent draft auto-saves (see _Spec Editor & Draft/Published Workflow_) and inline name/comment edits — so under the default Modified-descending sort the active snippet continually rises to the top while it is being edited.
|
||||
- The selected sort field and direction persist across sessions.
|
||||
|
||||
## Selected-Snippet Metadata Panel
|
||||
@@ -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 a multiline **Comment** (free-form notes); edits save automatically.
|
||||
- Shows read-only **Created** and **Modified** timestamps, formatted per the user's date-format setting (see *Settings*).
|
||||
- When the snippet references external datasets, shows a **Linked Datasets** list of the referenced dataset names, each with a dataset icon (see *Datasets*). The list is omitted when there are no references.
|
||||
- The panel also exposes the Duplicate and Delete operations for the active snippet (see *Snippet Operations*).
|
||||
- Shows read-only **Created** and **Modified** timestamps, formatted per the user's date-format setting (see _Settings_).
|
||||
- When the snippet references external datasets, shows a **Linked Datasets** list of the referenced dataset names, each with a dataset icon (see _Datasets_). The list is omitted when there are no references.
|
||||
- The panel also exposes the Duplicate and Delete operations for the active snippet (see _Snippet Operations_).
|
||||
|
||||
## Snippet Operations
|
||||
|
||||
The library provides the lifecycle operations for snippets. 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.
|
||||
- **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.
|
||||
@@ -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.
|
||||
|
||||
- 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.
|
||||
|
||||
## 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.
|
||||
- A fill indicator reflects the percentage used.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 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
|
||||
|
||||
@@ -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.
|
||||
- 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.
|
||||
- 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's appearance and behavior — font size, editor theme, minimap visibility, word wrap, line numbers, and tab size — are configurable and read from _Settings_; this section does not redefine their defaults.
|
||||
- The editor always edits a single active snippet. Selecting a different snippet in the _Snippet Library_, or toggling the Draft/Published view, replaces the editor content with the corresponding spec.
|
||||
|
||||
## B. Auto-Save of the Draft
|
||||
|
||||
@@ -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.
|
||||
- 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.
|
||||
|
||||
## 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.
|
||||
- The delay before rendering is a configurable debounce (see *Settings* / *Live Preview*), letting the user trade responsiveness against churn while typing heavy specs.
|
||||
- A brief moment after the user stops typing, the current spec is sent to the _Live Preview_ for rendering.
|
||||
- The delay before rendering is a configurable debounce (see _Settings_ / _Live Preview_), letting the user trade responsiveness against churn while typing heavy specs.
|
||||
- Rendering also occurs immediately when a snippet is first loaded into the editor or the Draft/Published view is switched.
|
||||
- Rendering specifics (dataset reference resolution, fit modes) belong to *Live Preview*; the editor's role is to supply the current spec text on each settle.
|
||||
- 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
|
||||
|
||||
@@ -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.
|
||||
- **Published view** shows the last published version, for reference; the published version is never modified by ordinary editing.
|
||||
- Editing the draft never touches the published version until the user publishes.
|
||||
- The *Snippet Library* status indicator reflects whether a snippet currently has unpublished draft changes (draft differs from published); this section only produces that difference, it does not render the indicator.
|
||||
- The _Snippet Library_ status indicator reflects whether a snippet currently has unpublished draft changes (draft differs from published); this section only produces that difference, it does not render the indicator.
|
||||
|
||||
### Publish
|
||||
|
||||
- A **Publish** action promotes the current draft to become the published version (the two are made identical).
|
||||
- Publish is also triggered by the keyboard shortcut Cmd/Ctrl+S.
|
||||
- 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.
|
||||
- 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.
|
||||
- The error message is plainly legible (monospaced, distinct from normal content) and conveys what went wrong.
|
||||
- The editor remains fully usable while an error is shown, so the user can edit to fix it; the error clears automatically once a subsequent edit renders successfully.
|
||||
- This is the editor-side error affordance only; how a valid spec is drawn lives in *Live Preview*.
|
||||
- This is the editor-side error affordance only; how a valid spec is drawn lives in _Live Preview_.
|
||||
|
||||
## F. Extract Inline Data to a Dataset
|
||||
|
||||
When a snippet's spec embeds its data inline, the user can lift that data out into a reusable, named dataset and have the spec reference it instead. This keeps specs lean and lets the same data serve multiple snippets (see *Datasets*).
|
||||
When a snippet's spec embeds its data inline, the user can lift that data out into a reusable, named dataset and have the spec reference it instead. This keeps specs lean and lets the same data serve multiple snippets (see _Datasets_).
|
||||
|
||||
- When the active snippet's draft spec contains inline data, an **Extract to Dataset** action is available in the pane header; it is hidden when the spec has no inline data.
|
||||
- Choosing it opens a modal that shows a read-only preview of the inline data and asks the user for a dataset name (required).
|
||||
@@ -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.
|
||||
- A toast confirms the dataset was created, and the modal closes.
|
||||
- The user can cancel the modal at any time, leaving the spec unchanged.
|
||||
- Dataset-side specifics (formats, storage, the bidirectional snippet↔dataset link) are described in *Datasets*.
|
||||
- Dataset-side specifics (formats, storage, the bidirectional snippet↔dataset link) are described in _Datasets_.
|
||||
|
||||
@@ -5,19 +5,19 @@ The right pane renders the active snippet's current specification as a live Vega
|
||||
## Purpose & Live Updating
|
||||
|
||||
- Renders the active snippet's current spec as a Vega-Lite visualization.
|
||||
- Always reflects the version currently shown in the editor: while the user edits the draft, the preview renders the draft; once published/viewing the published version, it renders that (see *Spec Editor & Draft/Published Workflow*).
|
||||
- Updates automatically as the user edits, after a brief render debounce so rapid keystrokes do not trigger constant re-rendering. The debounce delay is user-configurable (see *Settings*).
|
||||
- Always reflects the version currently shown in the editor: while the user edits the draft, the preview renders the draft; once published/viewing the published version, it renders that (see _Spec Editor & Draft/Published Workflow_).
|
||||
- Updates automatically as the user edits, after a brief render debounce so rapid keystrokes do not trigger constant re-rendering. The debounce delay is user-configurable (see _Settings_).
|
||||
- A subtle busy indication may appear over the preview while a render is in progress; it clears when rendering completes.
|
||||
- When no snippet is active, or the editor content is empty/blank, the preview renders nothing (a clean, empty pane) rather than showing an error.
|
||||
|
||||
## Dataset Reference Resolution
|
||||
|
||||
When a spec uses inline data, the preview renders it directly. When a spec instead references a named dataset from the library, the preview resolves that reference and renders using the stored dataset's contents (see *Datasets*).
|
||||
When a spec uses inline data, the preview renders it directly. When a spec instead references a named dataset from the library, the preview resolves that reference and renders using the stored dataset's contents (see _Datasets_).
|
||||
|
||||
- A spec may point at a dataset from the library by name instead of embedding the data inline.
|
||||
- Before rendering, the preview substitutes the referenced dataset's stored contents into the spec.
|
||||
- URL-sourced datasets 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
|
||||
|
||||
@@ -28,38 +28,38 @@ 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.
|
||||
- **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:
|
||||
|
||||
- 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.
|
||||
|
||||
## Rendering Contract
|
||||
|
||||
Before the chart is drawn, the spec shown in the editor is transformed into the spec actually rendered. Two deterministic transforms are applied in order. They are specified here because reproducing them faithfully is what makes references and fit modes behave correctly; the result is observable as the rendered chart.
|
||||
|
||||
**1. Dataset reference resolution.** Any named-data reference (`data` with a `name`) is replaced in-place with the referenced dataset's actual contents, shaped by the dataset's source and format (see *Datasets*):
|
||||
**1. Dataset reference resolution.** Any named-data reference (`data` with a `name`) is replaced in-place with the referenced dataset's actual contents, shaped by the dataset's source and format (see _Datasets_):
|
||||
|
||||
| Dataset source / format | The reference's `data` becomes |
|
||||
|---|---|
|
||||
| URL (any format) | a URL reference to the dataset's address, tagged with its format |
|
||||
| Inline JSON | the parsed values, inlined |
|
||||
| Inline CSV / TSV | the raw text, inlined, tagged with its format (CSV or TSV) |
|
||||
| Inline TopoJSON | the value, inlined, tagged as TopoJSON |
|
||||
| Dataset source / format | The reference's `data` becomes |
|
||||
| ----------------------- | ---------------------------------------------------------------- |
|
||||
| URL (any format) | a URL reference to the dataset's address, tagged with its format |
|
||||
| Inline JSON | the parsed values, inlined |
|
||||
| Inline CSV / TSV | the raw text, inlined, tagged with its format (CSV or TSV) |
|
||||
| Inline TopoJSON | the value, inlined, tagged as TopoJSON |
|
||||
|
||||
- Resolution recurses into nested sub-specs (layered and concatenated specs, and a parent spec's child `spec`), so references anywhere in the spec are resolved.
|
||||
- If a referenced dataset does not exist, rendering fails with a "dataset not found" error (see *Error Display*).
|
||||
- If a referenced dataset does not exist, rendering fails with a "dataset not found" error (see _Error Display_).
|
||||
|
||||
**2. Fit-mode sizing.** The selected fit mode rewrites the spec's sizing using Vega-Lite's responsive `"container"` sizing keyword, recursing into the same nested sub-specs:
|
||||
|
||||
| Mode | Transform |
|
||||
|---|---|
|
||||
| Mode | Transform |
|
||||
| -------- | ------------------------------------------------------------------------------------------ |
|
||||
| Original | spec sizing left untouched (the spec's own `width`/`height`, or Vega-Lite defaults, apply) |
|
||||
| Width | set `width` to `"container"`; remove any explicit `height` |
|
||||
| Height | set `height` to `"container"`; remove any explicit `width` |
|
||||
| Full | set both `width` and `height` to `"container"` |
|
||||
| Width | set `width` to `"container"`; remove any explicit `height` |
|
||||
| Height | set `height` to `"container"`; remove any explicit `width` |
|
||||
| Full | set both `width` and `height` to `"container"` |
|
||||
|
||||
- For the responsive (non-Original) modes the chart's container-relative dimension follows the pane size, while the unconstrained dimension is recomputed naturally — this is why Width/Height do not preserve the original aspect ratio.
|
||||
- The transform operates on a copy; the user's stored spec is never modified by rendering.
|
||||
@@ -77,5 +77,5 @@ When a spec cannot be rendered, the preview replaces the chart area with a clear
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -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 persist locally across sessions in a high-capacity local store, far larger than the budget available to snippets — large datasets belong here, not inline in specs.
|
||||
- A snippet references a dataset using a Vega-Lite named-data reference, e.g. `{ "data": { "name": "MyDataset" } }`. When the *Live Preview* renders a spec, it resolves any such named reference against the dataset library (see *Live Preview*).
|
||||
- See *Data Model* for the stored shape of a dataset.
|
||||
- A snippet references a dataset using a Vega-Lite named-data reference, e.g. `{ "data": { "name": "MyDataset" } }`. When the _Live Preview_ renders a spec, it resolves any such named reference against the dataset library (see _Live Preview_).
|
||||
- See _Data Model_ for the stored shape of a dataset.
|
||||
|
||||
## Opening & Navigation
|
||||
|
||||
- Opened from a header control or via the keyboard shortcut Cmd/Ctrl+K.
|
||||
- The current view and the selected dataset are reflected in the URL, so a selected dataset produces a shareable/back-navigable location (see *Application Shell & Navigation*).
|
||||
- The current view and the selected dataset are reflected in the URL, so a selected dataset produces a shareable/back-navigable location (see _Application Shell & Navigation_).
|
||||
- Closing the modal clears the current selection and any open create form.
|
||||
|
||||
## Layout
|
||||
@@ -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:
|
||||
|
||||
- **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".
|
||||
|
||||
@@ -80,7 +80,7 @@ The detail pane for a selected dataset shows:
|
||||
- **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.
|
||||
- **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
|
||||
|
||||
@@ -94,14 +94,14 @@ Each action raises a confirming toast (or an error toast on failure).
|
||||
|
||||
## 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
|
||||
|
||||
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
|
||||
|
||||
- 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.
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
# 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
|
||||
|
||||
- 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*).
|
||||
- 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_).
|
||||
- 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
|
||||
@@ -27,7 +27,7 @@ A two-pane modal:
|
||||
|
||||
- Exactly four channels are offered, in this order: **X, Y, Color, Size**.
|
||||
- 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.
|
||||
- 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.
|
||||
@@ -39,11 +39,11 @@ A two-pane modal:
|
||||
### Dimensions (optional)
|
||||
|
||||
- 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
|
||||
|
||||
- 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.
|
||||
- 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.
|
||||
@@ -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.
|
||||
- Channels left unmapped are omitted; if no encodings exist the spec omits the encoding block entirely (prevented by validation here).
|
||||
- Creates a new snippet from that spec with an auto-generated descriptive name, adds it to the snippet library, and records that it was built from the dataset.
|
||||
- Links the snippet to the dataset by recording the dataset reference, so the bidirectional snippet↔dataset relationship is established (see *Datasets*).
|
||||
- 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.
|
||||
- Closes the builder; the newly created snippet becomes the active snippet in the library/editor.
|
||||
|
||||
|
||||
+23
-23
@@ -15,9 +15,9 @@ 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.
|
||||
|
||||
| Setting | Options | Default |
|
||||
| -------- | ------------- | ------- |
|
||||
| UI theme | Light, Dark | Light |
|
||||
| Setting | Options | Default |
|
||||
| -------- | ----------- | ------- |
|
||||
| UI theme | Light, Dark | Light |
|
||||
|
||||
The UI theme is also exposed as a **header toggle** for one-click switching; it
|
||||
reads and writes the same persisted `ui.theme` value as this Appearance control,
|
||||
@@ -25,16 +25,16 @@ so the two always agree. (The toggle shipped in M1.5, ahead of this modal.)
|
||||
|
||||
### 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 |
|
||||
| ------------- | ------------------------------------- | -------- |
|
||||
| Font size | 10–18 px (integer) | 12 px |
|
||||
| Editor theme | Auto + explicit overrides (provisional — see note) | Auto |
|
||||
| Minimap | On / Off | Off |
|
||||
| Word wrap | On / Off | On |
|
||||
| Line numbers | On / Off | On |
|
||||
| Tab size | Integer number of spaces | 2 |
|
||||
| Setting | Options / Range | Default |
|
||||
| ------------ | -------------------------------------------------- | ------- |
|
||||
| Font size | 10–18 px (integer) | 12 px |
|
||||
| Editor theme | Auto + explicit overrides (provisional — see note) | Auto |
|
||||
| Minimap | On / Off | Off |
|
||||
| Word wrap | On / Off | On |
|
||||
| Line numbers | On / Off | On |
|
||||
| Tab size | Integer number of spaces | 2 |
|
||||
|
||||
- Font size is chosen along a 10–18 range; the current value is shown alongside the control.
|
||||
- Editor theme controls the syntax/color presentation inside the editor. **Provisional (to be finalized as we implement the editor):** the default is **Auto**, which derives the editor theme from the app UI theme (light app theme → light editor theme, dark → dark), using custom Monaco themes that match the app chrome. The user may override Auto with an explicit editor theme; the exact override list (custom themes, and whether to include High Contrast or the stock Monaco themes) is deferred. Stored as `editor.theme` with an `'auto'` sentinel for the follow-the-app default.
|
||||
@@ -45,22 +45,22 @@ These settings configure the spec editor used to edit Vega-Lite specs (see *Spec
|
||||
|
||||
### Performance
|
||||
|
||||
| Setting | Range | Default |
|
||||
| --------------- | -------------------- | -------- |
|
||||
| Render debounce | 500–5000 ms | 1500 ms |
|
||||
| Setting | Range | Default |
|
||||
| --------------- | ----------- | ------- |
|
||||
| Render debounce | 500–5000 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.
|
||||
- The current value is shown alongside the control.
|
||||
|
||||
### Formatting
|
||||
|
||||
Governs how dates are rendered throughout the app, for example the timestamps shown in the *Snippet Library* list.
|
||||
Governs how dates are rendered throughout the app, for example the timestamps shown in the _Snippet Library_ list.
|
||||
|
||||
| Setting | Options | Default |
|
||||
| ------------------- | ----------------------------------------- | ------- |
|
||||
| Date format | Smart, ISO 8601, Custom | Smart |
|
||||
| Custom date format | Free-text format string | (empty) |
|
||||
| Setting | Options | Default |
|
||||
| ------------------ | ----------------------- | ------- |
|
||||
| Date format | Smart, ISO 8601, Custom | Smart |
|
||||
| Custom date format | Free-text format string | (empty) |
|
||||
|
||||
- **Smart**: relative, human-friendly rendering (e.g. "Today", "Yesterday", "3d ago", falling back to a full date for older items).
|
||||
- **ISO 8601**: a full ISO 8601 timestamp.
|
||||
@@ -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:
|
||||
|
||||
- **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*.
|
||||
- **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_.
|
||||
|
||||
## Behaviors
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ Astrolabe lets a user back up or transfer their entire workspace as a single JSO
|
||||
|
||||
## 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).
|
||||
- **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",
|
||||
"exportedAt": "2026-06-03T12:00:00.000Z",
|
||||
"exportedBy": "Astrolabe",
|
||||
"snippets": [ /* full snippet objects (see Data Model) */ ],
|
||||
"datasets": [ /* full dataset objects (see Data Model) */ ]
|
||||
"snippets": [
|
||||
/* full snippet objects (see Data Model) */
|
||||
],
|
||||
"datasets": [
|
||||
/* full dataset objects (see Data Model) */
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- `version` — export format version (currently `"1.0"`).
|
||||
- `exportedAt` — ISO 8601 timestamp of the export.
|
||||
- `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
|
||||
|
||||
@@ -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:
|
||||
- Alternative field names are mapped: `content` → spec, `draft` → draft spec, `createdAt` → creation timestamp.
|
||||
- Missing timestamps are generated at import time (creation and modification set to now, or derived from the source timestamp when present).
|
||||
- Missing identifiers, names, comments, tags, dataset references, metadata, and record `version` are filled with defaults (a missing `version` is treated as the earliest shape and migrated up on read — see *Data Model*).
|
||||
- Missing identifiers, names, comments, tags, dataset references, metadata, and record `version` are filled with defaults (a missing `version` is treated as the earliest shape and migrated up on read — see _Data Model_).
|
||||
- Such normalized imports are tagged `"imported"` so the user can find them.
|
||||
|
||||
A snippet is treated as already in current Astrolabe format when it carries an ISO-style creation timestamp; in that case its existing fields (id, name, timestamps, spec, draft spec, comment, tags, dataset references, metadata) are preserved as-is, with sensible fallbacks for any missing field.
|
||||
@@ -60,7 +64,7 @@ A snippet is treated as already in current Astrolabe format when it carries an I
|
||||
|
||||
### 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.
|
||||
- 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
|
||||
|
||||
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 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.
|
||||
|
||||
+59
-59
@@ -2,79 +2,79 @@
|
||||
|
||||
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** 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 |
|
||||
|-------|------|---------|
|
||||
| `id` | string | Unique, stable identifier for the snippet. |
|
||||
| `version` | number | Schema version of this record, used for read-time migration (see *Schema versioning* below). |
|
||||
| `name` | string | Human-readable title shown in the library. |
|
||||
| `created` | ISO-timestamp string | When the snippet was first created. |
|
||||
| `modified` | ISO-timestamp string | When the snippet was last saved. |
|
||||
| `spec` | JSON value | The **published** Vega-Lite spec. May be an object or a string. This is the version rendered and shared by default. |
|
||||
| `draftSpec` | JSON value | The **working draft** Vega-Lite spec being edited. May be an object or a string. |
|
||||
| `comment` | string | Free-form user note about the snippet. |
|
||||
| `tags` | string[] | User-assigned labels for filtering and organization. |
|
||||
| `datasetRefs` | string[] | Names of *Datasets* referenced by this spec (see relationships below). |
|
||||
| `meta` | object | Free-form, extensible metadata bag for app- or feature-specific data. |
|
||||
| Field | Type | Meaning |
|
||||
| ------------- | -------------------- | ------------------------------------------------------------------------------------------------------------------- |
|
||||
| `id` | string | Unique, stable identifier for the snippet. |
|
||||
| `version` | number | Schema version of this record, used for read-time migration (see _Schema versioning_ below). |
|
||||
| `name` | string | Human-readable title shown in the library. |
|
||||
| `created` | ISO-timestamp string | When the snippet was first created. |
|
||||
| `modified` | ISO-timestamp string | When the snippet was last saved. |
|
||||
| `spec` | JSON value | The **published** Vega-Lite spec. May be an object or a string. This is the version rendered and shared by default. |
|
||||
| `draftSpec` | JSON value | The **working draft** Vega-Lite spec being edited. May be an object or a string. |
|
||||
| `comment` | string | Free-form user note about the snippet. |
|
||||
| `tags` | string[] | User-assigned labels for filtering and organization. |
|
||||
| `datasetRefs` | string[] | Names of _Datasets_ referenced by this spec (see relationships below). |
|
||||
| `meta` | object | Free-form, extensible metadata bag for app- or feature-specific data. |
|
||||
|
||||
### Dual spec / draftSpec model
|
||||
|
||||
A snippet carries two specs at once. `draftSpec` is the editable working copy; `spec` is the last published copy. Editing affects only `draftSpec` until the user publishes, at which point `draftSpec` is promoted to `spec`. This separation backs the draft/published workflow described in *Spec Editor & Draft/Published Workflow* — it lets users experiment freely while keeping a known-good published version, and drives indicators for unpublished changes.
|
||||
A snippet carries two specs at once. `draftSpec` is the editable working copy; `spec` is the last published copy. Editing affects only `draftSpec` until the user publishes, at which point `draftSpec` is promoted to `spec`. This separation backs the draft/published workflow described in _Spec Editor & Draft/Published Workflow_ — it lets users experiment freely while keeping a known-good published version, and drives indicators for unpublished changes.
|
||||
|
||||
### datasetRefs
|
||||
|
||||
`datasetRefs` records the **names** of datasets the spec depends on. It is the link used to display a snippet's linked datasets and, conversely, to find which snippets use a given dataset (see *Cross-entity relationships*). It 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
|
||||
|
||||
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 |
|
||||
|-------|------|---------|
|
||||
| `id` | number | Unique numeric identifier. |
|
||||
| `version` | number | Schema version of this record, used for read-time migration (see *Schema versioning* below). |
|
||||
| `name` | string | Unique, human-readable name; the key snippets reference via `datasetRefs`. |
|
||||
| `data` | JSON value | The payload. 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`. |
|
||||
| `source` | string | One of `inline` (data embedded in the record) or `url` (data fetched from a remote address). |
|
||||
| `comment` | string | Free-form user note about the dataset. |
|
||||
| `rowCount` | number or null | Number of data rows, or null when unknown/not applicable. |
|
||||
| `columnCount` | number or null | Number of columns, or null when unknown/not applicable. |
|
||||
| `columns` | string[] | Column names, in order. |
|
||||
| `columnTypes` | array of `{ name, type }` | Per-column inferred type. `name` is the column; `type` is one of `number`, `string`, `date`, `boolean`. |
|
||||
| `size` | number | Approximate payload size in bytes. |
|
||||
| `created` | ISO-timestamp string | When the dataset was first added. |
|
||||
| `modified` | ISO-timestamp string | When the dataset was last changed. |
|
||||
| Field | Type | Meaning |
|
||||
| ------------- | ------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `id` | number | Unique numeric identifier. |
|
||||
| `version` | number | Schema version of this record, used for read-time migration (see _Schema versioning_ below). |
|
||||
| `name` | string | Unique, human-readable name; the key snippets reference via `datasetRefs`. |
|
||||
| `data` | JSON value | The payload. 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`. |
|
||||
| `source` | string | One of `inline` (data embedded in the record) or `url` (data fetched from a remote address). |
|
||||
| `comment` | string | Free-form user note about the dataset. |
|
||||
| `rowCount` | number or null | Number of data rows, or null when unknown/not applicable. |
|
||||
| `columnCount` | number or null | Number of columns, or null when unknown/not applicable. |
|
||||
| `columns` | string[] | Column names, in order. |
|
||||
| `columnTypes` | array of `{ name, type }` | Per-column inferred type. `name` is the column; `type` is one of `number`, `string`, `date`, `boolean`. |
|
||||
| `size` | number | Approximate payload size in bytes. |
|
||||
| `created` | ISO-timestamp string | When the dataset was first added. |
|
||||
| `modified` | ISO-timestamp string | When the dataset was last changed. |
|
||||
|
||||
The `rowCount`, `columnCount`, `columns`, `columnTypes`, and `size` fields are derived summaries computed when data is added or updated; they support previews and type display without re-parsing the full payload.
|
||||
|
||||
### Schema versioning
|
||||
|
||||
Both **Snippet** and **Dataset** records carry a numeric `version` recording the shape of that individual record. When a record is read from storage it is migrated up to the current shape before the app uses it; new writes always store the current version. A record written before versioning existed (no `version` field) is treated as version `1`. This is distinct from the storage container's own layout version, and from the *Import & Export* envelope `version` (which describes the file format, not a record). Records exported via *Import & Export* include their `version`.
|
||||
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
|
||||
|
||||
**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 |
|
||||
|-------|------|---------|
|
||||
| `version` | number | Schema version of the settings record, used for migration. |
|
||||
| `editor.fontSize` | number | Editor font size. |
|
||||
| `editor.theme` | string | Editor color theme identifier. |
|
||||
| `editor.minimap` | boolean | Whether the editor minimap is shown. |
|
||||
| `editor.wordWrap` | string | `on` or `off`. |
|
||||
| `editor.lineNumbers` | string | `on` or `off`. |
|
||||
| `editor.tabSize` | number | Spaces per indentation level. |
|
||||
| `performance.renderDebounce` | number | Delay (ms) before re-rendering the preview after edits. |
|
||||
| `ui.theme` | string | App theme: `light` or `dark`. |
|
||||
| `ui.previewFitMode` | string | Preview sizing: `default`, `width`, `height`, or `full`. |
|
||||
| `formatting.dateFormat` | string | Date display mode: `smart`, `iso`, or `custom`. |
|
||||
| `formatting.customDateFormat` | string | Pattern used when `dateFormat = custom`. |
|
||||
| Field | Type | Meaning |
|
||||
| ----------------------------- | ------- | ---------------------------------------------------------- |
|
||||
| `version` | number | Schema version of the settings record, used for migration. |
|
||||
| `editor.fontSize` | number | Editor font size. |
|
||||
| `editor.theme` | string | Editor color theme identifier. |
|
||||
| `editor.minimap` | boolean | Whether the editor minimap is shown. |
|
||||
| `editor.wordWrap` | string | `on` or `off`. |
|
||||
| `editor.lineNumbers` | string | `on` or `off`. |
|
||||
| `editor.tabSize` | number | Spaces per indentation level. |
|
||||
| `performance.renderDebounce` | number | Delay (ms) before re-rendering the preview after edits. |
|
||||
| `ui.theme` | string | App theme: `light` or `dark`. |
|
||||
| `ui.previewFitMode` | string | Preview sizing: `default`, `width`, `height`, or `full`. |
|
||||
| `formatting.dateFormat` | string | Date display mode: `smart`, `iso`, or `custom`. |
|
||||
| `formatting.customDateFormat` | string | Pattern used when `dateFormat = custom`. |
|
||||
|
||||
A reference shape:
|
||||
|
||||
@@ -82,20 +82,20 @@ UserSettings = { version, editor: { fontSize, theme, minimap, wordWrap, lineNumb
|
||||
|
||||
## 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.
|
||||
|
||||
## E. Persistence & limits
|
||||
|
||||
| Tier | What it holds | Capacity & behavior |
|
||||
|------|---------------|---------------------|
|
||||
| Snippet store | All *Snippet* records | Local, with a practical budget of about 5 MB. A storage monitor tracks usage and surfaces warnings as the budget fills (see *Snippet Library*). |
|
||||
| Dataset store | All *Dataset* records | Local, in a separate, much higher-capacity store, suited to larger payloads. |
|
||||
| Settings & preferences | *UserSettings* plus the app/UI preferences in (D) | Local, small. |
|
||||
| Tier | What it holds | Capacity & behavior |
|
||||
| ---------------------- | ------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Snippet store | All _Snippet_ records | Local, with a practical budget of about 5 MB. A storage monitor tracks usage and surfaces warnings as the budget fills (see _Snippet Library_). |
|
||||
| Dataset store | All _Dataset_ records | Local, in a separate, much higher-capacity store, suited to larger payloads. |
|
||||
| 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
|
||||
|
||||
@@ -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 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_.
|
||||
|
||||
@@ -1,54 +1,54 @@
|
||||
# 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
|
||||
|
||||
- **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.
|
||||
- **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
|
||||
|
||||
Astrolabe is specified as a standalone single-page app that owns its whole viewport. A team integrating these capabilities into a larger product should know which shared environment surfaces the app currently reserves, so they can decide how to reconcile each with the host. (Surfacing the assumption is the spec's job; choosing the reconciliation is the integrator's.)
|
||||
|
||||
- **Global keyboard shortcuts** — the shortcuts in *Application Shell & Navigation* are bound document-wide and override the browser default, regardless of which element has focus or which modal is open. In a host app they may collide with the host's own bindings.
|
||||
- **URL hash as view state** — the app stores its current view (selected snippet, open dataset, chart-builder target) in the URL hash and reads it on load (see *Navigation & Shareable URL State*). A host that owns routing will need to share or namespace the hash.
|
||||
- **Local browser storage** — all state persists to local browser storage across the tiers in *Data Model & Persistence*; storage keys are not namespaced against a co-resident host app.
|
||||
- **Global keyboard shortcuts** — the shortcuts in _Application Shell & Navigation_ are bound document-wide and override the browser default, regardless of which element has focus or which modal is open. In a host app they may collide with the host's own bindings.
|
||||
- **URL hash as view state** — the app stores its current view (selected snippet, open dataset, chart-builder target) in the URL hash and reads it on load (see _Navigation & Shareable URL State_). A host that owns routing will need to share or namespace the hash.
|
||||
- **Local browser storage** — all state persists to local browser storage across the tiers in _Data Model & Persistence_; storage keys are not namespaced against a co-resident host app.
|
||||
- **Full-window workspace** — the layout assumes a wide, app-owned viewport (header, three panes, and modals). Hosting it within a smaller region falls under the small-screen fallback above.
|
||||
|
||||
## Performance & Responsiveness
|
||||
|
||||
- **Live editing stays fluid**: typing in the editor must remain smooth regardless of spec size; rendering must never block input.
|
||||
- **Debounced rendering**: preview rendering is deferred until the user pauses typing, by a user-configurable delay (see *Settings* / *Live Preview*), so rapid keystrokes do not cause continuous re-rendering.
|
||||
- **Debounced rendering**: preview rendering is deferred until the user pauses typing, by a user-configurable delay (see _Settings_ / _Live Preview_), so rapid keystrokes do not cause continuous re-rendering.
|
||||
- **Non-blocking renders**: while a render is in progress the UI stays interactive; a busy indication may overlay the preview but must not freeze editing or navigation.
|
||||
- **Auto-save is cheap and silent**: persisting the working draft must not interrupt typing or cause visible stalls (see *Spec Editor & Draft/Published Workflow*).
|
||||
- **Scales with the library**: search, sort, and list rendering must stay responsive with a large number of snippets, and large datasets must be handled by the high-capacity dataset store rather than inflating snippet storage (see *Data Model & Persistence*).
|
||||
- **Auto-save is cheap and silent**: persisting the working draft must not interrupt typing or cause visible stalls (see _Spec Editor & Draft/Published Workflow_).
|
||||
- **Scales with the library**: search, sort, and list rendering must stay responsive with a large number of snippets, and large datasets must be handled by the high-capacity dataset store rather than inflating snippet storage (see _Data Model & Persistence_).
|
||||
|
||||
## Accessibility
|
||||
|
||||
- **Keyboard operable**: all primary actions are reachable from the keyboard — the global shortcuts (see *Application Shell & Navigation*) plus standard tab/focus traversal of controls, lists, and forms.
|
||||
- **Keyboard operable**: all primary actions are reachable from the keyboard — the global shortcuts (see _Application Shell & Navigation_) plus standard tab/focus traversal of controls, lists, and forms.
|
||||
- **Modal focus management**: opening a modal moves focus into it and returns focus sensibly on close; **Escape** closes the active modal; focus is contained within an open modal.
|
||||
- **Labelled controls**: form fields, toggles, and icon-only buttons carry accessible names so assistive technology can announce them.
|
||||
- **Reduced motion**: animations and transitions (toast fades, etc.) are suppressed when the user's system requests reduced motion.
|
||||
- **Contrast**: text and interactive elements meet legible contrast in every offered UI theme; a theme that cannot meet contrast in part of the UI is not considered complete (see *Settings*).
|
||||
- **Contrast**: text and interactive elements meet legible contrast in every offered UI theme; a theme that cannot meet contrast in part of the UI is not considered complete (see _Settings_).
|
||||
|
||||
## Reliability & Data Safety
|
||||
|
||||
- **No silent data loss**: edits are auto-saved as drafts; a known-good published version is always preserved separately (see *Spec Editor & Draft/Published Workflow*).
|
||||
- **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.
|
||||
- **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*).
|
||||
- **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*).
|
||||
- **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_).
|
||||
- **Resilient rendering**: an invalid or unrenderable spec produces a readable error and recovers automatically when fixed; it never leaves the app in a broken state (see _Live Preview_).
|
||||
- **State survives reload**: the current selection/view is restored from the URL, and all data persists across reloads and sessions (see _Application Shell & Navigation_, _Data Model & Persistence_).
|
||||
|
||||
## Privacy & Security
|
||||
|
||||
- **Local-only data**: all snippets, datasets, and settings stay in the browser. No user content is transmitted to any server, and the app requires no account or login.
|
||||
- **User-initiated network only**: the only outbound requests for user content are fetches of URL-sourced datasets or remote data referenced by a spec, which the user explicitly created (see *Datasets*). The app performs no background upload of user content.
|
||||
- **User-initiated network only**: the only outbound requests for user content are fetches of URL-sourced datasets or remote data referenced by a spec, which the user explicitly created (see _Datasets_). The app performs no background upload of user content.
|
||||
- **Client-side rendering of untrusted input**: specs and data are user-authored and rendered locally; rendering must fail safely on malformed input rather than crashing the app.
|
||||
|
||||
## Internationalization
|
||||
|
||||
- **Locale-aware formatting where it exists**: date rendering follows the user's chosen format mode (see *Settings*). Full UI translation is out of scope unless explicitly added later; the spec does not require multiple UI languages.
|
||||
- **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.
|
||||
|
||||
+15
-15
@@ -1,6 +1,6 @@
|
||||
# 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
|
||||
|
||||
@@ -12,20 +12,20 @@ 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.
|
||||
- **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
|
||||
|
||||
| # | Section |
|
||||
|---|---------|
|
||||
| 00 | [Product Overview](00-product-overview.md) |
|
||||
| 01 | [Application Shell & Navigation](01-application-shell.md) |
|
||||
| 02 | [Snippet Library](02-snippet-library.md) |
|
||||
| 03 | [Spec Editor & Draft/Published Workflow](03-editor-and-drafts.md) |
|
||||
| 04 | [Live Preview](04-live-preview.md) |
|
||||
| 05 | [Datasets](05-datasets.md) |
|
||||
| 06 | [Chart Builder](06-chart-builder.md) |
|
||||
| 07 | [Settings](07-settings.md) |
|
||||
| 08 | [Import & Export](08-import-export.md) |
|
||||
| 09 | [Data Model & Persistence](09-data-model.md) |
|
||||
| 10 | [Non-Functional Requirements](10-non-functional.md) |
|
||||
| # | Section |
|
||||
| --- | ----------------------------------------------------------------- |
|
||||
| 00 | [Product Overview](00-product-overview.md) |
|
||||
| 01 | [Application Shell & Navigation](01-application-shell.md) |
|
||||
| 02 | [Snippet Library](02-snippet-library.md) |
|
||||
| 03 | [Spec Editor & Draft/Published Workflow](03-editor-and-drafts.md) |
|
||||
| 04 | [Live Preview](04-live-preview.md) |
|
||||
| 05 | [Datasets](05-datasets.md) |
|
||||
| 06 | [Chart Builder](06-chart-builder.md) |
|
||||
| 07 | [Settings](07-settings.md) |
|
||||
| 08 | [Import & Export](08-import-export.md) |
|
||||
| 09 | [Data Model & Persistence](09-data-model.md) |
|
||||
| 10 | [Non-Functional Requirements](10-non-functional.md) |
|
||||
|
||||
@@ -16,7 +16,11 @@ function relativeDate(iso: string): string {
|
||||
const now = new Date();
|
||||
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
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 === 1) return 'Yesterday';
|
||||
if (days < 7) return `${days}d ago`;
|
||||
|
||||
@@ -17,7 +17,17 @@ import styles from './ThemeToggle.module.css';
|
||||
|
||||
function MoonIcon() {
|
||||
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" />
|
||||
</svg>
|
||||
);
|
||||
@@ -25,7 +35,17 @@ function MoonIcon() {
|
||||
|
||||
function SunIcon() {
|
||||
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" />
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user