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:
@@ -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
Reference in New Issue
Block a user