Docs: build hash grammar, theme rollback, reference-identity change detection, option-list boundary rule

This commit is contained in:
2026-06-12 21:53:42 +03:00
parent 5fe5aed50f
commit af9cc8a716
4 changed files with 48 additions and 12 deletions
+19
View File
@@ -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 > 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. > `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 ## 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 touch IndexedDB/`localStorage`/URL adapters from components.
- Don't thread global state down through props; don't pass per-instance or - Don't thread global state down through props; don't pass per-instance or
presentational data via store imports. 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).
+2 -1
View File
@@ -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 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 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 (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 spec §08 "no partial import is committed" contract holds and the user gets an actionable
"storage full, delete and retry" message. "storage full, delete and retry" message.
+19 -10
View File
@@ -33,18 +33,22 @@ Zustand stores. Components never read `location.hash` or attach
The hash is the serialized view. Astrolabe's forms: The hash is the serialized view. Astrolabe's forms:
| State | Hash | | State | Hash |
| --------------------------- | ------------------------------ | | ------------------------------ | ------------------------------ |
| Default snippets view | _(empty / absent)_ | | Default snippets view | _(empty / absent)_ |
| A selected snippet | `#snippet-<id>` | | A selected snippet | `#snippet-<id>` |
| Datasets manager (list) | `#datasets` | | Datasets manager (list) | `#datasets` |
| A specific dataset | `#datasets/dataset-<id>` | | A specific dataset | `#datasets/dataset-<id>` |
| New-dataset form | `#datasets/new` | | New-dataset form | `#datasets/new` |
| Chart Builder for a dataset | `#datasets/dataset-<id>/build` | | Chart Builder for a dataset | `#datasets/dataset-<id>/build` |
| Chart Builder, no dataset open | `#build` |
Snippet `id` is an opaque string; dataset `id` is the numeric dataset id 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 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` ### 1.2 The adapter: `infrastructure/url-hash.ts`
@@ -62,7 +66,8 @@ export type ViewState =
| { kind: 'datasets' } // #datasets | { kind: 'datasets' } // #datasets
| { kind: 'dataset'; datasetId: number } // #datasets/dataset-<id> | { kind: 'dataset'; datasetId: number } // #datasets/dataset-<id>
| { kind: 'dataset-new' } // #datasets/new | { 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 { export function parseHash(rawHash: string): ViewState {
const hash = rawHash.replace(/^#/, ''); const hash = rawHash.replace(/^#/, '');
@@ -71,6 +76,8 @@ export function parseHash(rawHash: string): ViewState {
const snippet = /^snippet-(.+)$/.exec(hash); const snippet = /^snippet-(.+)$/.exec(hash);
if (snippet) return { kind: 'snippet', snippetId: snippet[1] }; if (snippet) return { kind: 'snippet', snippetId: snippet[1] };
if (hash === 'build') return { kind: 'build' };
const parts = hash.split('/').filter(Boolean); const parts = hash.split('/').filter(Boolean);
if (parts[0] === 'datasets') { if (parts[0] === 'datasets') {
if (parts.length === 1) return { kind: 'datasets' }; if (parts.length === 1) return { kind: 'datasets' };
@@ -100,6 +107,8 @@ export function serializeHash(view: ViewState): string {
return '#datasets/new'; return '#datasets/new';
case 'dataset-build': case 'dataset-build':
return `#datasets/dataset-${view.datasetId}/build`; return `#datasets/dataset-${view.datasetId}/build`;
case 'build':
return '#build';
} }
} }
@@ -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 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 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 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 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 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. its own popover sits beside the gear in the pane header, never inside the panel.