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:
@@ -386,12 +386,13 @@ entries).
|
||||
|
||||
**App**
|
||||
|
||||
- Settings **modal** (Appearance/Editor/Performance/Formatting), Apply/Cancel/Reset,
|
||||
dirty indicator; wire render-debounce + theme + date-format through to the app.
|
||||
Theme already switches (M1.5 header toggle + chart/editor themes) — M5 surfaces
|
||||
it inside the modal too (sharing the same `ui.theme`) and wires the rest.
|
||||
- **Distributed settings** — not a modal (design review, see spec §07 + arch 10). Each
|
||||
cluster is a per-pane disclosure popover that applies **live**: Editor settings in the
|
||||
editor toolbar, render debounce in the preview, date format in the library; theme stays
|
||||
the header toggle. A shared `SettingsPopover` primitive (gear + non-modal popover, APG
|
||||
disclosure) backs all three. Wire render-debounce + editor options + date-format through.
|
||||
- Header **Import**/**Export** (direct file dialog / download, no modal).
|
||||
- Date formatting util (smart/iso/custom) used by the library list.
|
||||
- Date formatting util (smart/iso/custom) used by the library list + metadata panel.
|
||||
|
||||
**Tests**
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+16
-15
@@ -1,13 +1,15 @@
|
||||
# 07 · Settings
|
||||
|
||||
Astrolabe provides a **Settings** modal where users tune appearance, the spec editor, preview performance, and date formatting. All settings persist locally and apply across sessions on the same device. Settings load at startup; any unknown or missing value falls back to its factory default, so older or partial saved settings never break the app.
|
||||
Astrolabe lets users tune appearance, the spec editor, preview performance, and date formatting. Rather than a separate Settings modal, **each preference lives next to what it affects and applies immediately** — the appearance theme is a header toggle, editor preferences sit in the editor pane, render performance in the preview pane, and date formatting in the library. All settings persist locally and apply across sessions on the same device. Settings load at startup; any unknown or missing value falls back to its factory default, so older or partial saved settings never break the app.
|
||||
|
||||
## Opening the modal
|
||||
> **Why distributed, not a modal (resolved).** Settings were originally specified as one central modal with an explicit Apply/Cancel commit. A design review (see _Architecture 10 · Interaction & Feedback_) moved them to per-pane, live-applied controls: it matches how theme and preview fit mode already work, lets a change’s effect be seen in the very pane being configured, and keeps each settings block independently extensible. The settings, options, and defaults below are unchanged — only their presentation and commit model changed.
|
||||
|
||||
- An entry in the application header opens the Settings modal.
|
||||
- The keyboard shortcut **Cmd/Ctrl+,** also opens it.
|
||||
- The modal is grouped into clearly titled sections: Appearance, Editor, Performance, and Formatting.
|
||||
- The modal can be dismissed with a Cancel action or the standard modal-close affordance; dismissing without applying discards any pending edits and restores the last saved values.
|
||||
## Opening the settings
|
||||
|
||||
- **Appearance (UI theme)** is a one-click toggle in the application header.
|
||||
- **Editor**, **Performance**, and **Formatting** clusters are each opened by a small **settings (gear) control** in the toolbar of the pane they govern — the editor pane, the preview pane, and the library, respectively. The control discloses a popover of that cluster’s controls.
|
||||
- The keyboard shortcut **Cmd/Ctrl+,** opens the **Editor** settings cluster (the primary configuration surface).
|
||||
- A disclosed popover is dismissed with **Esc** (which returns focus to its gear) or by clicking outside it; at most one settings popover is open at a time.
|
||||
|
||||
## Settings
|
||||
|
||||
@@ -19,9 +21,9 @@ Controls the overall UI theme. Choosing the Dark theme switches the whole applic
|
||||
| -------- | ----------- | ------- |
|
||||
| UI theme | Light, Dark | Light |
|
||||
|
||||
The UI theme is also exposed as a **header toggle** for one-click switching; it
|
||||
reads and writes the same persisted `ui.theme` value as this Appearance control,
|
||||
so the two always agree. (The toggle shipped in M1.5, ahead of this modal.)
|
||||
The UI theme is a one-click **header toggle** (shipped in M1.5). It reads and
|
||||
writes the persisted `ui.theme` value directly and applies immediately — there is
|
||||
no separate Appearance control to keep in sync.
|
||||
|
||||
### Editor
|
||||
|
||||
@@ -69,15 +71,14 @@ Governs how dates are rendered throughout the app, for example the timestamps sh
|
||||
|
||||
## Related persisted preferences (documented elsewhere)
|
||||
|
||||
The following preferences also persist locally across sessions but are managed outside this modal and are documented in their own sections:
|
||||
The following preferences also persist locally across sessions and, like the clusters above, are managed by controls in the pane they affect; they are documented in their own sections:
|
||||
|
||||
- **Preview fit mode** — how the preview is sized/fit; see _Live Preview_.
|
||||
- **Snippet sort preference** — the snippet list's sort field and direction; see _Snippet Library_.
|
||||
|
||||
## Behaviors
|
||||
|
||||
- **Apply / save**: An explicit Apply action writes all changes; they take effect immediately (e.g. the UI theme switches at once).
|
||||
- **Dirty indication**: While the form differs from the last saved state, the modal shows an "Unsaved changes" indicator.
|
||||
- **Cancel / dismiss**: Closing without applying reverts the form to the last saved values and leaves stored settings untouched.
|
||||
- **Reset to defaults**: A Reset action restores every setting to its factory default. It requires explicit confirmation before applying, then saves the defaults.
|
||||
- **Startup load**: Settings are read on startup and applied to the UI and editor; missing or unrecognized values silently use their defaults.
|
||||
- **Live apply**: Every control applies its change immediately — there is no Apply/Cancel commit step. The effect is visible in the pane being configured (the editor reflows, the preview re-renders at the new debounce, the library re-formats its dates), so no separate confirmation is needed. This matches the always-live header theme toggle and preview Fit control.
|
||||
- **Reset to defaults**: The **Editor** cluster offers a Reset that restores the editor settings to their factory defaults. (Other clusters are single, self-evident controls; there is no global "reset everything" — each control is individually reversible.)
|
||||
- **No dirty / discard state**: Because changes commit as made, there is no "unsaved changes" indicator and nothing to discard on dismiss; closing a settings popover simply hides it.
|
||||
- **Startup load**: Settings are read on startup and applied to the UI, editor, preview, and library; missing or unrecognized values silently use their defaults.
|
||||
|
||||
Reference in New Issue
Block a user