Chart theming: custom named themes + Theme Builder

This commit is contained in:
2026-06-12 18:15:54 +03:00
parent 44a601affd
commit b193464f55
32 changed files with 2220 additions and 70 deletions
+116
View File
@@ -0,0 +1,116 @@
/**
* IndexedDB wrapper — store-layout verification and self-healing (the
* interrupted-upgrade recovery documented on `openDB`). Runs against
* fake-indexeddb; each test gets a pristine database.
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { IDBFactory } from 'fake-indexeddb';
import {
DATASETS_STORE,
SNIPPETS_STORE,
THEMES_STORE,
_resetDbForTests,
getAll,
openDB,
put,
} from './db';
const ALL_STORES = [SNIPPETS_STORE, DATASETS_STORE, THEMES_STORE];
/** Open the raw database at `version` with a custom (or absent) upgrade body. */
function rawOpen(version: number, upgrade?: (db: IDBDatabase) => void): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const req = indexedDB.open('astrolabe', version);
req.onupgradeneeded = () => upgrade?.(req.result);
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error ?? new Error('open failed'));
});
}
beforeEach(() => {
// A fresh factory per test — no databases survive between tests.
globalThis.indexedDB = new IDBFactory();
_resetDbForTests();
});
afterEach(() => {
_resetDbForTests();
});
describe('openDB', () => {
it('creates every expected store on first run', async () => {
const db = await openDB();
for (const store of ALL_STORES) expect(db.objectStoreNames.contains(store)).toBe(true);
});
it('upgrades a v1 database (snippets + datasets) to include the themes store', async () => {
const v1 = await rawOpen(1, (db) => {
db.createObjectStore(SNIPPETS_STORE, { keyPath: 'id' });
db.createObjectStore(DATASETS_STORE, { keyPath: 'id' });
});
v1.close();
const db = await openDB();
expect(db.objectStoreNames.contains(THEMES_STORE)).toBe(true);
});
it('self-heals a database stamped at the current version with stores missing', async () => {
// The interrupted-upgrade state: version already 2, but the themes store
// was never created (e.g. a hot reload opened v2 before the create-store
// code existed). onupgradeneeded will never fire again for v2.
const broken = await rawOpen(2, (db) => {
db.createObjectStore(SNIPPETS_STORE, { keyPath: 'id' });
db.createObjectStore(DATASETS_STORE, { keyPath: 'id' });
});
broken.close();
const db = await openDB();
expect(db.objectStoreNames.contains(THEMES_STORE)).toBe(true);
expect(db.version).toBe(3); // healed by a forced extra upgrade pass
// Transactions on every store now work.
await put(THEMES_STORE, { id: 1, name: 'ok' });
await expect(getAll(THEMES_STORE)).resolves.toEqual([{ id: 1, name: 'ok' }]);
});
it('opens a database whose version is already past DB_VERSION', async () => {
// A prior self-heal bump leaves the version above the code's constant; a
// versioned open would throw VersionError. All stores already exist here.
const ahead = await rawOpen(7, (db) => {
for (const store of ALL_STORES) db.createObjectStore(store, { keyPath: 'id' });
});
ahead.close();
const db = await openDB();
expect(db.version).toBe(7);
for (const store of ALL_STORES) expect(db.objectStoreNames.contains(store)).toBe(true);
});
it('self-heals an ahead-of-code database with stores missing', async () => {
const ahead = await rawOpen(5, (db) => {
db.createObjectStore(SNIPPETS_STORE, { keyPath: 'id' });
});
ahead.close();
const db = await openDB();
expect(db.version).toBe(6);
for (const store of ALL_STORES) expect(db.objectStoreNames.contains(store)).toBe(true);
});
it('preserves existing records across the self-heal upgrade', async () => {
const broken = await rawOpen(2, (db) => {
db.createObjectStore(SNIPPETS_STORE, { keyPath: 'id' });
db.createObjectStore(DATASETS_STORE, { keyPath: 'id' });
});
await new Promise<void>((resolve, reject) => {
const t = broken.transaction(SNIPPETS_STORE, 'readwrite');
t.objectStore(SNIPPETS_STORE).put({ id: 'a', name: 'kept' });
t.oncomplete = () => resolve();
t.onerror = () => reject(t.error ?? new Error('tx failed'));
});
broken.close();
await openDB();
await expect(getAll(SNIPPETS_STORE)).resolves.toEqual([{ id: 'a', name: 'kept' }]);
});
});
+49 -13
View File
@@ -11,35 +11,71 @@ const DB_NAME = 'astrolabe';
/**
* Store-layout version. Bump only when the set of object stores / indexes
* changes — independent of per-record schema versions (see snippet-migrations).
* v2 added the `themes` store (custom chart themes).
*/
const DB_VERSION = 1;
const DB_VERSION = 2;
export const SNIPPETS_STORE = 'snippets';
export const DATASETS_STORE = 'datasets';
export const THEMES_STORE = 'themes';
/** Every object store the app expects — the open-time verification checklist. */
const EXPECTED_STORES = [SNIPPETS_STORE, DATASETS_STORE, THEMES_STORE] as const;
let dbPromise: Promise<IDBDatabase> | null = null;
/** Open (and memoize) the database, creating object stores on first run. */
export function openDB(): Promise<IDBDatabase> {
if (dbPromise) return dbPromise;
dbPromise = new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, DB_VERSION);
/**
* One `indexedDB.open` as a promise. Omitting `version` opens at whatever
* version the database already has (never an upgrade). The upgrade handler
* creates every missing store — guarded per store, so it is idempotent across
* any old→new version jump.
*/
function openAt(version?: number): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const req = version === undefined ? indexedDB.open(DB_NAME) : indexedDB.open(DB_NAME, version);
req.onupgradeneeded = () => {
const db = req.result;
// Guard every create so upgrades stay idempotent.
if (!db.objectStoreNames.contains(SNIPPETS_STORE)) {
db.createObjectStore(SNIPPETS_STORE, { keyPath: 'id' });
}
if (!db.objectStoreNames.contains(DATASETS_STORE)) {
db.createObjectStore(DATASETS_STORE, { keyPath: 'id' });
for (const store of EXPECTED_STORES) {
if (!db.objectStoreNames.contains(store)) {
db.createObjectStore(store, { keyPath: 'id' });
}
}
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error ?? new Error('Failed to open IndexedDB'));
});
}
/**
* Open (and memoize) the database, creating object stores on first run.
*
* The open **verifies** the store layout instead of trusting the version
* number: an interrupted upgrade can stamp the new version without creating
* the new stores (observed in dev — a hot reload opened the bumped version
* before the store-creation code existed), after which `onupgradeneeded`
* never fires again and every transaction on the missing store throws
* NotFoundError. If any expected store is missing after a successful open,
* reopen at `version + 1` to force another (idempotent) upgrade pass — the
* database self-heals rather than being stuck until manually deleted.
*/
export function openDB(): Promise<IDBDatabase> {
if (dbPromise) return dbPromise;
dbPromise = openAt(DB_VERSION)
.catch((err: unknown) => {
// A database already past DB_VERSION (a prior self-heal bump) makes a
// versioned open throw VersionError; open at its current version instead.
if (err instanceof DOMException && err.name === 'VersionError') return openAt();
throw err;
})
.then((db) => {
if (EXPECTED_STORES.every((store) => db.objectStoreNames.contains(store))) return db;
const next = db.version + 1;
db.close();
return openAt(next);
});
return dbPromise;
}
+10 -6
View File
@@ -19,7 +19,7 @@
import type { FitMode } from '@core/rendering';
import { loadSettings, type UserSettings } from '@core/settings';
import type { UiTheme } from '@core/theme';
import { isChartThemeId, type ChartThemeId } from '@core/vega-themes';
import { isChartThemeSelection, type ChartThemeSelection } from '@core/vega-themes';
const KEY = 'astrolabe:settings';
@@ -30,7 +30,7 @@ const DEFAULT_THEME: UiTheme = 'light';
const DEFAULT_FIT_MODE: FitMode = 'default';
/** Spec §04 — the Chart theme picker defaults to the house style. */
const DEFAULT_CHART_THEME: ChartThemeId = 'astrolabe';
const DEFAULT_CHART_THEME: ChartThemeSelection = 'astrolabe';
/** The managed slice the per-pane settings clusters own (theme + fit live in their own slices). */
export type ManagedSettings = Pick<UserSettings, 'editor' | 'performance' | 'formatting'>;
@@ -122,14 +122,18 @@ export function loadPreviewFitMode(): FitMode {
return isFitMode(stored) ? stored : DEFAULT_FIT_MODE;
}
/** The persisted chart theme, or the default — unknown ids fall back. */
export function loadChartTheme(): ChartThemeId {
/**
* The persisted chart theme, or the default — unknown ids fall back. A
* `custom:<id>` passes on shape; whether the record still exists is resolved at
* render time (missing custom themes render as the house style).
*/
export function loadChartTheme(): ChartThemeSelection {
const stored = readRaw().ui?.chartTheme;
return isChartThemeId(stored) ? stored : DEFAULT_CHART_THEME;
return isChartThemeSelection(stored) ? stored : DEFAULT_CHART_THEME;
}
/** Persist the chart theme, preserving every other key already in the record. */
export function saveChartTheme(chartTheme: ChartThemeId): void {
export function saveChartTheme(chartTheme: ChartThemeSelection): void {
const current = readRaw();
writeRaw({ ...current, ui: { ...current.ui, chartTheme } });
}
@@ -0,0 +1,37 @@
import { describe, expect, it } from 'vitest';
import { CURRENT_THEME_VERSION } from '@core/custom-theme';
import { migrateCustomTheme } from './theme-migrations';
describe('migrateCustomTheme', () => {
it('passes a current record through unchanged (plus version stamp)', () => {
const record = {
id: 3,
version: CURRENT_THEME_VERSION,
name: 'Brand',
config: { font: 'Georgia' },
created: '2026-06-12T10:00:00.000Z',
modified: '2026-06-12T11:00:00.000Z',
};
expect(migrateCustomTheme(record)).toEqual(record);
});
it('fills missing or invalid fields with safe defaults', () => {
const migrated = migrateCustomTheme({ id: '7', config: ['not', 'an', 'object'] });
expect(migrated.id).toBe(7);
expect(migrated.version).toBe(CURRENT_THEME_VERSION);
expect(migrated.name).toBe('Untitled theme');
expect(migrated.config).toEqual({});
expect(typeof migrated.created).toBe('string');
expect(typeof migrated.modified).toBe('string');
});
it('keeps unknown fields written by a newer build', () => {
const migrated = migrateCustomTheme({
id: 1,
name: 'Next',
config: {},
futureField: 'kept',
});
expect((migrated as unknown as Record<string, unknown>).futureField).toBe('kept');
});
});
@@ -0,0 +1,25 @@
/**
* Read-time migration for CustomTheme records (docs/architecture/02 §4).
*
* Mirrors snippet/dataset migrations: every theme read from storage passes
* through `migrateCustomTheme`, which fills missing/invalid fields and stamps
* the current version. Unknown fields are tolerated (spread the original, only
* fill gaps) so a record written by a newer build round-trips without loss.
*/
import { CURRENT_THEME_VERSION, type CustomTheme } from '@core/custom-theme';
import { isJsonObject } from '@core/spec-config';
/** Upgrade a raw stored record to the current CustomTheme shape. */
export function migrateCustomTheme(raw: unknown): CustomTheme {
const r = { ...(raw as Record<string, unknown>) };
return {
...r,
id: typeof r.id === 'number' ? r.id : Number(r.id),
version: CURRENT_THEME_VERSION,
name: typeof r.name === 'string' ? r.name : 'Untitled theme',
config: isJsonObject(r.config) ? r.config : {},
created: typeof r.created === 'string' ? r.created : new Date(0).toISOString(),
modified: typeof r.modified === 'string' ? r.modified : new Date(0).toISOString(),
};
}
+27
View File
@@ -0,0 +1,27 @@
/**
* Custom theme persistence adapter (docs/architecture/02; scope doc §4.4).
*
* The typed seam between the CustomThemeStore and IndexedDB's `themes` object
* store. Exposes plain async functions returning domain `CustomTheme` objects
* and migrates every record on read — same contract as dataset-store.
*/
import { CURRENT_THEME_VERSION, type CustomTheme } from '@core/custom-theme';
import { THEMES_STORE, del, getAll, put } from './db';
import { migrateCustomTheme } from './theme-migrations';
/** Load every custom theme, upgrading each record to the current shape. */
export async function loadCustomThemes(): Promise<CustomTheme[]> {
const records = await getAll<unknown>(THEMES_STORE);
return records.map(migrateCustomTheme);
}
/** Persist a custom theme at the current schema version. Propagates failures. */
export async function saveCustomTheme(theme: CustomTheme): Promise<void> {
await put(THEMES_STORE, { ...theme, version: CURRENT_THEME_VERSION });
}
/** Permanently remove a custom theme by id. */
export async function deleteCustomTheme(id: number): Promise<void> {
await del(THEMES_STORE, id);
}