mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Add distributed settings and workspace import/export (M5)
This commit is contained in:
@@ -179,8 +179,13 @@ Each cohesive feature owns a store holding its durable domain state.
|
||||
- **`useSnippetStore`** — the snippet library: `snippets`, `activeSnippetId`, the
|
||||
working `draftSpec`, and its actions.
|
||||
- **`useDatasetStore`** — loaded datasets, the active dataset, inferred fields.
|
||||
- **`useSettingsStore`** — user preferences (editor options, render debounce,
|
||||
date format, theme); mirrors what gets persisted to `localStorage`.
|
||||
- **`useUserSettingsStore`** — the **managed** user preferences applied _live_ as
|
||||
`saved` (editor options, render debounce, date format). Its per-cluster setters
|
||||
are called by the per-pane settings popovers; the editor/preview/library read
|
||||
`saved.*`. There is **no draft/Apply** — settings are distributed and commit on
|
||||
change (spec §07). **UI theme and preview fit mode are NOT here** — they're
|
||||
cross-cutting and live in `useAppStore` (header toggle, Fit control); all three
|
||||
persist to the one `astrolabe:settings` record via slice writers (arch 02 §5).
|
||||
|
||||
### Global overlay stores (imperative trigger)
|
||||
|
||||
@@ -189,6 +194,10 @@ ephemeral request state, not durable data:
|
||||
|
||||
- **`useConfirmStore`** — the blocking confirm dialog (the `window.confirm` replacement).
|
||||
- **`useNotificationStore`** — non-blocking toasts (failed saves, etc.).
|
||||
- **`useSettingsPopoverStore`** — which per-pane settings disclosure is open (one
|
||||
at a time); its imperative `openSettingsPopover(id)` lets the Cmd/Ctrl+, shortcut
|
||||
open the editor cluster. The disclosure widget contract (gear + non-modal popover,
|
||||
not an ARIA menu; Esc/focus rules) is [10 · Interaction & Feedback](10-interaction-and-feedback.md) §5.
|
||||
|
||||
Each pairs its store with a thin **imperative trigger** exported alongside the hook —
|
||||
`confirm(opts): Promise<boolean>` and `notify(opts): string` — so orchestration/services can
|
||||
|
||||
@@ -227,10 +227,10 @@ export async function saveSnippet(s: Snippet): Promise<void> {
|
||||
|
||||
Small, frequently-read structured records live in `localStorage`, not IndexedDB: **UserSettings** (one record) and **app/UI preferences** (snippet sort, panel layout). Why split them from `UserSettings`? UI prefs change often (drag a panel divider, toggle a sort) and shouldn't force a rewrite of the whole settings blob on every interaction.
|
||||
|
||||
The pattern is **load-with-fallback, write-through on change.**
|
||||
The pattern is **load-with-fallback, per-slice write-through merge.**
|
||||
|
||||
- **Load-with-fallback:** merge the parsed stored object over a complete `DEFAULTS` constant. Missing keys (a setting added in a later build) and malformed JSON silently fall back to defaults — the app always gets a fully-populated object and never `undefined`-crashes on a new field.
|
||||
- **Write-through:** every update reads current, applies the change, and writes the whole record back immediately. No dirty-tracking, no flush step.
|
||||
- **Load-with-fallback:** merge the parsed stored object over a complete `DEFAULTS` constant. Missing keys (a setting added in a later build) and malformed JSON silently fall back to defaults — the app always gets a fully-populated object and never `undefined`-crashes on a new field. For `UserSettings`, the normalization is **pure and lives in `@core/settings` (`loadSettings(raw)` / `defaultSettings()`)** — it clamps ranges and validates enums; the infra adapter is just the thin localStorage reader (`loadUserSettings()` = `loadSettings(readRaw())`).
|
||||
- **Per-slice write-through merge:** every update reads current, merges in **just its slice**, and writes back. **The one `astrolabe:settings` record has multiple independent writers**, because settings are distributed and live-applied (spec §07 — no central save, no Apply step): the header theme toggle writes `ui.theme`, the preview Fit control writes `ui.previewFitMode`, and the per-pane settings clusters write `editor`/`performance`/`formatting`. **A writer that replaced the whole record would clobber the slices it doesn't own** — so each must field-merge. (There is deliberately no whole-record `saveSettings`.)
|
||||
- **Environment-guarded:** `localStorage` is absent or throws in some test/SSR contexts; guard access and degrade to defaults rather than throwing.
|
||||
|
||||
```ts
|
||||
@@ -307,16 +307,18 @@ export function loadSettings(): UserSettings {
|
||||
}
|
||||
}
|
||||
|
||||
export function saveSettings(s: UserSettings): void {
|
||||
if (!available()) return;
|
||||
try {
|
||||
localStorage.setItem(KEY, JSON.stringify({ ...s, version: CURRENT_SETTINGS_VERSION }));
|
||||
} catch (err) {
|
||||
console.warn('[settings] failed to save', err);
|
||||
}
|
||||
}
|
||||
// No whole-record save — each live control merges only its slice into the shared
|
||||
// record, so the others survive (see the per-slice rule above):
|
||||
// saveUiTheme(theme) -> { ...current, ui: { ...current.ui, theme } }
|
||||
// savePreviewFitMode(mode) -> { ...current, ui: { ...current.ui, previewFitMode } }
|
||||
// saveManagedSettings(managed) -> { ...current, editor, performance, formatting }
|
||||
```
|
||||
|
||||
> The above sketch keeps the `DEFAULTS`/merge shape inline for illustration, but
|
||||
> the **authoritative** defaults + normalization now live in `@core/settings`
|
||||
> (pure); the infra adapter calls them and owns only the localStorage IO + the
|
||||
> slice writers. Keep the two in sync via that one core source, not a second copy here.
|
||||
|
||||
App/UI prefs follow the identical guard+fallback pattern, but live in **one
|
||||
record under their own key**, `astrolabe:ux-prefs` (the snippet sort and the
|
||||
panel layout together — the plan's "ux-prefs for sort + panel layout", §09D):
|
||||
|
||||
@@ -310,6 +310,31 @@ export function dedupeIncomingDatasetNames(
|
||||
> §6 over the imported snippet set, or run `renameDatasetEverywhere` per applied
|
||||
> rename after the import is committed.
|
||||
|
||||
### 5.1 Where the import/export flow lives, and its rules
|
||||
|
||||
**Flow:** header (Import/Export buttons in `App.tsx`) → `services/transfer.ts`
|
||||
(the only store-touching layer) → pure core (`core/import-normalize.ts` shape
|
||||
detection + normalization + the dedupe/rename/id-reassign helpers; `core/export-envelope.ts`)
|
||||
|
||||
- browser IO (`infrastructure/file-transfer.ts`). The pure helpers are unit-tested
|
||||
hardest; `transfer.ts` only orchestrates (read stores → call core → commit →
|
||||
notify). The behavioral contract is spec §08.
|
||||
|
||||
Three rules a future change must keep:
|
||||
|
||||
- **Datasets commit before snippets** (`DatasetStore.addDatasets` then
|
||||
`SnippetStore.addSnippets`) so a snippet's by-name reference resolves against the
|
||||
just-added (possibly suffixed) dataset.
|
||||
- **Imported datasets get fresh monotonic numeric ids** (`addDatasets`), not their
|
||||
envelope ids. Safe — and necessary — because datasets are linked **by name, not
|
||||
id** (§1): id reuse would collide in IndexedDB, but renaming the _id_ breaks
|
||||
nothing. (This is why the old `Date.now()`-collision TODO on `add` doesn't bite import.)
|
||||
- **Rename propagation reads the spec, not only `datasetRefs`.**
|
||||
`applyDatasetRenamesToSnippets` finds the referenced name via
|
||||
`extractDatasetRefs(spec)` ∪ `datasetRefs`, so an imported snippet whose
|
||||
`datasetRefs` is absent/stale (a hand-crafted or foreign file) still gets its
|
||||
spec rewritten — the renderer resolves by spec, so a missed rename would break it.
|
||||
|
||||
**Do**
|
||||
|
||||
- Reserve each chosen name immediately so collisions _within_ one import are
|
||||
|
||||
@@ -207,8 +207,8 @@ contract; cite it, not the external source.)_
|
||||
other shows the text visually with no live role. Two live regions would announce the same
|
||||
message twice.
|
||||
|
||||
**Resolved — feature-modal dismissal & initial focus.** A feature modal (Datasets, and
|
||||
later Settings/Chart Builder) is a **passive** `dialog-modal`: dismissed by the close
|
||||
**Resolved — feature-modal dismissal & initial focus.** A feature modal (Datasets, Chart
|
||||
Builder) is a **passive** `dialog-modal`: dismissed by the close
|
||||
button, Escape, or a backdrop click (a passive modal carries no in-flight transaction, so
|
||||
an outside click is a safe cancel — unlike the `alertdialog` confirm, where backdrop-dismiss
|
||||
is forbidden). `role="dialog"` + `aria-modal` + `aria-labelledby` the title; focus is trapped
|
||||
@@ -220,6 +220,24 @@ content's start is perceived rather than skipped to the first control; a small f
|
||||
(Extract) focuses its **primary field**. _(Consulted via /council → WAI-ARIA APG
|
||||
`dialog-modal`. This bullet is the contract; cite it, not the APG file.)_
|
||||
|
||||
**Resolved — settings are distributed, not a modal; each cluster is a disclosure popover.**
|
||||
Preferences (spec §07) live next to what they affect and apply **live**: theme is the header
|
||||
toggle, editor settings open from the editor toolbar, render debounce from the preview, date
|
||||
format from the library. This matches the already-distributed theme + fit-mode controls,
|
||||
makes a change's effect visible in the pane being configured, and keeps each block
|
||||
independently extensible — so there is **no central Settings modal and no Apply/Cancel/dirty
|
||||
commit step** (changes are individually reversible; the editor cluster offers a Reset). The
|
||||
disclosure mechanism is a **gear button + non-modal popover**, _not_ an ARIA menu: a menu
|
||||
lists actions/commands (`menuitem`/`menuitemcheckbox`/`menuitemradio`), but these panels hold
|
||||
sliders, number/text inputs, and radio groups, so the container is a labelled `group`. The
|
||||
gear carries `aria-expanded` + `aria-controls`; Enter/Space toggle; **Esc closes and returns
|
||||
focus to the gear**; an outside click closes; at most one is open at a time; focus moves to
|
||||
the first control on open (so `Cmd/Ctrl+,`, which opens the editor cluster, lands inside it).
|
||||
Non-modal — **no focus trap** (unlike the feature modal above). The panel is portaled to
|
||||
`<body>` and positioned `fixed` because the panes clip their content. _(Consulted via /council
|
||||
→ NN/g #4 consistency, #6 recognition-over-recall, #8 minimalist; WAI-ARIA APG disclosure +
|
||||
menu-and-menubar; Carbon popover/overflow-menu/text-toolbar. This bullet is the contract.)_
|
||||
|
||||
**Resolved — an error names the right fix, not a boilerplate one.** Don't staple a generic
|
||||
remedy onto every failure. A missing dataset reference is **not** a JSON/spec syntax problem,
|
||||
so the preview gives it a tailored, fixable line — _"Dataset «X» not found. Create it from
|
||||
|
||||
Reference in New Issue
Block a user