Add distributed settings and workspace import/export (M5)

This commit is contained in:
2026-06-07 15:51:00 +03:00
parent 80bedd2a8d
commit 548aa199d9
38 changed files with 3150 additions and 101 deletions
+27
View File
@@ -0,0 +1,27 @@
/**
* File transfer adapter — the browser side of Import/Export (spec §08).
*
* Per the architecture rule, DOM/file APIs are confined to infrastructure: this
* is the only module that builds a download or reads a picked file, so the
* transfer *service* stays about orchestration (normalize, merge, notify) rather
* than Blobs and anchors.
*/
/** Trigger a client-side download of `json` text as `filename`. */
export function downloadJson(filename: string, json: string): void {
const blob = new Blob([json], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
// Firefox needs the anchor in the document for the click to register.
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
/** Read a picked file's text content (rejects on an unreadable file). */
export function readTextFile(file: File): Promise<string> {
return file.text();
}
+47 -4
View File
@@ -1,5 +1,13 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { loadPreviewFitMode, loadUiTheme, savePreviewFitMode, saveUiTheme } from './settings-store';
import { defaultSettings } from '@core/settings';
import {
loadPreviewFitMode,
loadUiTheme,
loadUserSettings,
saveManagedSettings,
savePreviewFitMode,
saveUiTheme,
} from './settings-store';
const KEY = 'astrolabe:settings';
@@ -85,7 +93,7 @@ describe('settings-store · ui.theme', () => {
});
});
describe('settings-store · preview.fitMode', () => {
describe('settings-store · ui.previewFitMode', () => {
beforeEach(() => vi.stubGlobal('localStorage', makeStorageStub()));
afterEach(() => vi.unstubAllGlobals());
@@ -94,12 +102,12 @@ describe('settings-store · preview.fitMode', () => {
});
it('returns a stored valid fit mode', () => {
localStorage.setItem(KEY, JSON.stringify({ preview: { fitMode: 'full' } }));
localStorage.setItem(KEY, JSON.stringify({ ui: { previewFitMode: 'full' } }));
expect(loadPreviewFitMode()).toBe('full');
});
it('falls back to default for an unrecognized value', () => {
localStorage.setItem(KEY, JSON.stringify({ preview: { fitMode: 'cover' } }));
localStorage.setItem(KEY, JSON.stringify({ ui: { previewFitMode: 'cover' } }));
expect(loadPreviewFitMode()).toBe('default');
});
@@ -110,3 +118,38 @@ describe('settings-store · preview.fitMode', () => {
expect(loadUiTheme()).toBe('dark'); // the other slice survives the merge
});
});
describe('settings-store · full UserSettings record', () => {
beforeEach(() => vi.stubGlobal('localStorage', makeStorageStub()));
afterEach(() => vi.unstubAllGlobals());
it('loadUserSettings returns defaults when nothing is stored', () => {
expect(loadUserSettings()).toEqual(defaultSettings());
});
it('loadUserSettings normalizes a partial stored record', () => {
localStorage.setItem(KEY, JSON.stringify({ editor: { fontSize: 99 } }));
const loaded = loadUserSettings();
expect(loaded.editor.fontSize).toBe(18); // clamped to the 1018 range
expect(loaded.performance.renderDebounce).toBe(1500); // gap filled from defaults
});
it('saveManagedSettings round-trips and leaves the ui slice untouched', () => {
saveUiTheme('dark');
savePreviewFitMode('width');
const managed = defaultSettings();
managed.editor.fontSize = 16;
managed.performance.renderDebounce = 800;
managed.formatting.dateFormat = 'iso';
saveManagedSettings(managed);
const loaded = loadUserSettings();
expect(loaded.editor.fontSize).toBe(16);
expect(loaded.performance.renderDebounce).toBe(800);
expect(loaded.formatting.dateFormat).toBe('iso');
// The ui slice (written by the narrow slice writers) survives the managed merge.
expect(loaded.ui.theme).toBe('dark');
expect(loaded.ui.previewFitMode).toBe('width');
});
});
+41 -13
View File
@@ -1,19 +1,23 @@
/**
* Settings persistence (localStorage) — docs/architecture/02 §5.
* Settings persistence (localStorage) — docs/architecture/02 §5, spec §07/§09C.
*
* The authoritative home for `ui.theme` is the *UserSettings* record under the
* `astrolabe:settings` key (spec §09C). M1.5 pulls the **theme** slice forward
* (the toggle ships before the Settings modal), so this adapter currently wires
* only `ui.theme`. It reads/writes with **load-with-fallback + write-through
* merge**: a partial record written now is preserved key-for-key, so when M5
* builds the full UserSettings adapter on this same key it upgrades cleanly
* rather than clobbering anything.
* The home for the *UserSettings* record (spec §09C) under `astrolabe:settings`.
* M1.5/M2 pulled the **theme** and **preview fit mode** slices forward (their
* controls shipped first); M5 adds the full record — editor, performance, and
* formatting groups — on the same key.
*
* Everything reads with **load-with-fallback** (the pure `loadSettings` in
* `@core/settings` normalizes whatever is stored back onto a valid record) and
* writes with a **field-level write-through merge**, so the narrow per-slice
* writers (theme, fit mode) and the managed-settings write never clobber each
* other: each merges into the stored record and leaves the other groups intact.
*
* Per the architecture rule, this is one of the only modules that may touch
* `localStorage`; everything else goes through these typed functions.
*/
import type { FitMode } from '@core/rendering';
import { loadSettings, type UserSettings } from '@core/settings';
import type { UiTheme } from '@core/theme';
const KEY = 'astrolabe:settings';
@@ -24,10 +28,12 @@ const DEFAULT_THEME: UiTheme = 'light';
/** Spec §04 — the Fit control defaults to Original. */
const DEFAULT_FIT_MODE: FitMode = 'default';
/** Loose view of the stored record — M5 will give this its full typed shape. */
/** The managed slice the per-pane settings clusters own (theme + fit live in their own slices). */
export type ManagedSettings = Pick<UserSettings, 'editor' | 'performance' | 'formatting'>;
/** Loose view of the stored record for the per-slice write-through merges. */
interface StoredSettings {
ui?: { theme?: unknown; [k: string]: unknown };
preview?: { fitMode?: unknown; [k: string]: unknown };
ui?: { theme?: unknown; previewFitMode?: unknown; [k: string]: unknown };
[k: string]: unknown;
}
@@ -70,6 +76,28 @@ function isFitMode(v: unknown): v is FitMode {
return v === 'default' || v === 'width' || v === 'height' || v === 'full';
}
/**
* The full, normalized UserSettings record — the load-with-fallback gate
* (spec §07 "Startup load"). Used to hydrate the Settings store at startup.
*/
export function loadUserSettings(): UserSettings {
return loadSettings(readRaw());
}
/**
* Persist the managed groups (editor / performance / formatting),
* merging into the stored record so the `ui` slice (theme, fit mode) is untouched.
*/
export function saveManagedSettings(managed: ManagedSettings): void {
const current = readRaw();
writeRaw({
...current,
editor: managed.editor,
performance: managed.performance,
formatting: managed.formatting,
});
}
/** The persisted UI theme, or the default — unknown/legacy values fall back. */
export function loadUiTheme(): UiTheme {
const stored = readRaw().ui?.theme;
@@ -86,12 +114,12 @@ export function saveUiTheme(theme: UiTheme): void {
/** The persisted preview fit mode, or the default — unknown values fall back. */
export function loadPreviewFitMode(): FitMode {
const stored = readRaw().preview?.fitMode;
const stored = readRaw().ui?.previewFitMode;
return isFitMode(stored) ? stored : DEFAULT_FIT_MODE;
}
/** Persist the preview fit mode, preserving every other key already in the record. */
export function savePreviewFitMode(fitMode: FitMode): void {
const current = readRaw();
writeRaw({ ...current, preview: { ...current.preview, fitMode } });
writeRaw({ ...current, ui: { ...current.ui, previewFitMode: fitMode } });
}