mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Docs: build hash grammar, theme rollback, reference-identity change detection, option-list boundary rule
This commit is contained in:
@@ -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).
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user