mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Implement M1.5 visual design foundation: tokens, IBM Plex, theme toggle, chart themes
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { loadUiTheme, saveUiTheme } from './settings-store';
|
||||
|
||||
const KEY = 'astrolabe:settings';
|
||||
|
||||
/**
|
||||
* In-memory localStorage stub. The adapter is tested against a stub rather than
|
||||
* the ambient global (docs/architecture/02 §5) — doubly necessary here because
|
||||
* Node ships a non-functional `localStorage` global that shadows happy-dom's.
|
||||
*/
|
||||
function makeStorageStub() {
|
||||
const map = new Map<string, string>();
|
||||
return {
|
||||
getItem: (k: string) => (map.has(k) ? map.get(k)! : null),
|
||||
setItem: (k: string, v: string) => void map.set(k, String(v)),
|
||||
removeItem: (k: string) => void map.delete(k),
|
||||
clear: () => map.clear(),
|
||||
key: (i: number) => [...map.keys()][i] ?? null,
|
||||
get length() {
|
||||
return map.size;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('settings-store · ui.theme', () => {
|
||||
beforeEach(() => vi.stubGlobal('localStorage', makeStorageStub()));
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
describe('loadUiTheme', () => {
|
||||
it('defaults to light when nothing is stored', () => {
|
||||
expect(loadUiTheme()).toBe('light');
|
||||
});
|
||||
|
||||
it('returns a stored valid theme', () => {
|
||||
localStorage.setItem(KEY, JSON.stringify({ ui: { theme: 'dark' } }));
|
||||
expect(loadUiTheme()).toBe('dark');
|
||||
});
|
||||
|
||||
it('falls back to light on malformed JSON', () => {
|
||||
localStorage.setItem(KEY, '{ not valid json');
|
||||
expect(loadUiTheme()).toBe('light');
|
||||
});
|
||||
|
||||
it('falls back for an unrecognized/legacy value (e.g. retired "experimental")', () => {
|
||||
localStorage.setItem(KEY, JSON.stringify({ ui: { theme: 'experimental' } }));
|
||||
expect(loadUiTheme()).toBe('light');
|
||||
});
|
||||
|
||||
it('tolerates a record with no ui group', () => {
|
||||
localStorage.setItem(KEY, JSON.stringify({ formatting: { dateFormat: 'iso' } }));
|
||||
expect(loadUiTheme()).toBe('light');
|
||||
});
|
||||
});
|
||||
|
||||
describe('saveUiTheme', () => {
|
||||
it('round-trips through load', () => {
|
||||
saveUiTheme('dark');
|
||||
expect(loadUiTheme()).toBe('dark');
|
||||
});
|
||||
|
||||
it('preserves other keys already in the settings record (forward-compatible merge)', () => {
|
||||
// Simulate a future/full UserSettings record written by M5.
|
||||
localStorage.setItem(
|
||||
KEY,
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
editor: { fontSize: 14 },
|
||||
ui: { theme: 'light', previewFitMode: 'width' },
|
||||
}),
|
||||
);
|
||||
|
||||
saveUiTheme('dark');
|
||||
|
||||
const stored = JSON.parse(localStorage.getItem(KEY)!);
|
||||
expect(stored.ui.theme).toBe('dark');
|
||||
// Everything else survives — nothing clobbered.
|
||||
expect(stored.version).toBe(1);
|
||||
expect(stored.editor.fontSize).toBe(14);
|
||||
expect(stored.ui.previewFitMode).toBe('width');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Settings persistence (localStorage) — docs/architecture/02 §5.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* Per the architecture rule, this is one of the only modules that may touch
|
||||
* `localStorage`; everything else goes through these typed functions.
|
||||
*/
|
||||
|
||||
import type { UiTheme } from '@core/theme';
|
||||
|
||||
const KEY = 'astrolabe:settings';
|
||||
|
||||
/** Spec §07 Appearance default. */
|
||||
const DEFAULT_THEME: UiTheme = 'light';
|
||||
|
||||
/** Loose view of the stored record — M5 will give this its full typed shape. */
|
||||
interface StoredSettings {
|
||||
ui?: { theme?: unknown; [k: string]: unknown };
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
/** localStorage can be absent or throw (private mode, SSR, blocked storage). */
|
||||
function available(): boolean {
|
||||
try {
|
||||
return typeof localStorage !== 'undefined' && typeof localStorage.getItem === 'function';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function readRaw(): StoredSettings {
|
||||
if (!available()) return {};
|
||||
try {
|
||||
const raw = localStorage.getItem(KEY);
|
||||
if (!raw) return {};
|
||||
const parsed = JSON.parse(raw);
|
||||
return parsed && typeof parsed === 'object' ? (parsed as StoredSettings) : {};
|
||||
} catch (err) {
|
||||
console.warn('[settings] failed to read, using defaults', err);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function writeRaw(next: StoredSettings): void {
|
||||
if (!available()) return;
|
||||
try {
|
||||
localStorage.setItem(KEY, JSON.stringify(next));
|
||||
} catch (err) {
|
||||
console.warn('[settings] failed to write', err);
|
||||
}
|
||||
}
|
||||
|
||||
function isUiTheme(v: unknown): v is UiTheme {
|
||||
return v === 'light' || v === 'dark';
|
||||
}
|
||||
|
||||
/** The persisted UI theme, or the default — unknown/legacy values fall back. */
|
||||
export function loadUiTheme(): UiTheme {
|
||||
const stored = readRaw().ui?.theme;
|
||||
// An unrecognized value (a future theme, or the retired 'experimental') falls
|
||||
// back rather than breaking — the load-with-fallback contract (doc §5).
|
||||
return isUiTheme(stored) ? stored : DEFAULT_THEME;
|
||||
}
|
||||
|
||||
/** Persist the UI theme, preserving every other key already in the record. */
|
||||
export function saveUiTheme(theme: UiTheme): void {
|
||||
const current = readRaw();
|
||||
writeRaw({ ...current, ui: { ...current.ui, theme } });
|
||||
}
|
||||
Reference in New Issue
Block a user