diff --git a/docs/architecture/01-state-and-stores.md b/docs/architecture/01-state-and-stores.md index be7789b..4cf626c 100644 --- a/docs/architecture/01-state-and-stores.md +++ b/docs/architecture/01-state-and-stores.md @@ -351,6 +351,23 @@ test('deleting the active snippet selects the next one', () => { > action. Local, throwaway UI state (a dropdown's open flag) may stay in component > `useState`; anything another component reads belongs in a store behind an action. +### Change detection: reference identity, not serialization + +Because every action replaces a state object via spread (never mutates it), +"has this changed since X" is **reference identity** against the object captured +at X. The Chart Builder's dataset switch keeps the exact config `init` produced +(`initialConfig`) and asks `config === initialConfig` to tell an untouched +opening default from built-on work. Field-level comparison against the source +record (the Theme Builder's draft-dirty selector) is the equivalent for forms +seeded from a saved record. + +> Rule: never detect change by serializing and comparing +> (`JSON.stringify(a) === JSON.stringify(b)`) — it silently depends on key +> order, costs proportionally to state size, and a store that replaces objects +> immutably already has a cheaper, exact signal. The one sanctioned +> serialization is the modal coordinator's unsaved-change **snapshot** +> (architecture 03), where a cross-store, store-agnostic baseline is the point. + --- ## 5. Effects: Persistence and External Sync @@ -518,3 +535,5 @@ reset: () => set({ snippets: [], activeSnippetId: null, draftSpec: '' }); - Don't touch IndexedDB/`localStorage`/URL adapters from components. - Don't thread global state down through props; don't pass per-instance or presentational data via store imports. +- Don't detect change by serialize-and-compare; immutable replacement makes + reference identity the exact, cheap signal (§4 → Change detection). diff --git a/docs/architecture/02-persistence.md b/docs/architecture/02-persistence.md index 50fb4d9..02ea39a 100644 --- a/docs/architecture/02-persistence.md +++ b/docs/architecture/02-persistence.md @@ -469,7 +469,8 @@ single transaction spanning many records across stores**. So a bulk operation li the **service boundary** instead: write the new records to IndexedDB first, tracking what succeeded, and only on full success commit to the Zustand stores. On any write failure (typically `QuotaExceededError`) it **rolls back best-effort** — deletes the records written -so far (`Promise.allSettled`) and removes any datasets already added to the store — so the +so far (`Promise.allSettled`) and removes any datasets and custom themes already added to +their stores (their persistence subscribers propagate the removals to IDB) — so the spec §08 "no partial import is committed" contract holds and the user gets an actionable "storage full, delete and retry" message. diff --git a/docs/architecture/04-routing-and-events.md b/docs/architecture/04-routing-and-events.md index 3b14dd3..1c4becc 100644 --- a/docs/architecture/04-routing-and-events.md +++ b/docs/architecture/04-routing-and-events.md @@ -33,18 +33,22 @@ 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-` | -| Datasets manager (list) | `#datasets` | -| A specific dataset | `#datasets/dataset-` | -| New-dataset form | `#datasets/new` | -| Chart Builder for a dataset | `#datasets/dataset-/build` | +| State | Hash | +| ------------------------------ | ------------------------------ | +| Default snippets view | _(empty / absent)_ | +| A selected snippet | `#snippet-` | +| Datasets manager (list) | `#datasets` | +| A specific dataset | `#datasets/dataset-` | +| New-dataset form | `#datasets/new` | +| Chart Builder for a dataset | `#datasets/dataset-/build` | +| Chart Builder, no dataset open | `#build` | Snippet `id` is an opaque string; dataset `id` is the numeric dataset id rendered as a decimal string. The hash is the **only** persisted view-routing -state — there is no in-memory "current route" that can drift from it. +state — there is no in-memory "current route" that can drift from it. `#build` +serializes the builder's no-datasets state only: an un-targeted builder open +picks a dataset itself when any exist, so the derived view immediately +self-corrects to the `dataset-build` form. ### 1.2 The adapter: `infrastructure/url-hash.ts` @@ -62,7 +66,8 @@ export type ViewState = | { kind: 'datasets' } // #datasets | { kind: 'dataset'; datasetId: number } // #datasets/dataset- | { kind: 'dataset-new' } // #datasets/new - | { kind: 'dataset-build'; datasetId: number }; // .../build + | { kind: 'dataset-build'; datasetId: number } // .../build + | { kind: 'build' }; // #build — builder, no dataset loaded export function parseHash(rawHash: string): ViewState { const hash = rawHash.replace(/^#/, ''); @@ -71,6 +76,8 @@ export function parseHash(rawHash: string): ViewState { const snippet = /^snippet-(.+)$/.exec(hash); if (snippet) return { kind: 'snippet', snippetId: snippet[1] }; + if (hash === 'build') return { kind: 'build' }; + const parts = hash.split('/').filter(Boolean); if (parts[0] === 'datasets') { if (parts.length === 1) return { kind: 'datasets' }; @@ -100,6 +107,8 @@ export function serializeHash(view: ViewState): string { return '#datasets/new'; case 'dataset-build': return `#datasets/dataset-${view.datasetId}/build`; + case 'build': + return '#build'; } } diff --git a/docs/architecture/10-interaction-and-feedback.md b/docs/architecture/10-interaction-and-feedback.md index 259b76a..6b95b0a 100644 --- a/docs/architecture/10-interaction-and-feedback.md +++ b/docs/architecture/10-interaction-and-feedback.md @@ -445,7 +445,14 @@ refocuses the trigger; outside press closes; open lands focus on the selected op Arrow/Home/End rove). The selected option carries `aria-current` and a visible ✓, never colour alone. The same control doubles as an **action picker** (no `value`; e.g. "Add field to which channel?"). A custom `triggerClassName` _replaces_ the default trigger styling, so -chip-styled triggers (the pill's type chip, the shelf's field chips) stay chips. +chip-styled triggers (the pill's type chip, the shelf's field chips) stay chips; the default +trigger sits on the **32px compact control scale** (arch 09 §6), like the sort trigger and +search input it shares surfaces with. A long option list can carry **group separators**: an +option's `dividerBefore` draws a `role="presentation"` rule above it — purely visual, never +in the keyboard order, never a heading. **The boundary is set where the list is built**: the +module that decides the option order marks the divider-carrying option (e.g. +`chartThemeOptions` stamps the first preset); a consumer must never recompute a group +boundary by index arithmetic, which silently misplaces when the producer's ordering changes. The single-open registry means **disclosures cannot nest**: a SelectControl inside a settings popover would close — and unmount — its own parent on open. A control that needs its own popover sits beside the gear in the pane header, never inside the panel.