Chart theming: selectable chart theme + spec↔config merge/extract

This commit is contained in:
2026-06-12 16:48:48 +03:00
parent fe9d588103
commit 44a601affd
27 changed files with 1103 additions and 121 deletions
+5 -2
View File
@@ -20,7 +20,7 @@ describe('defaultSettings', () => {
tabSize: 2,
},
performance: { renderDebounce: 1500 },
ui: { theme: 'light', previewFitMode: 'default' },
ui: { theme: 'light', previewFitMode: 'default', chartTheme: 'astrolabe' },
formatting: { dateFormat: 'smart', customDateFormat: '' },
});
});
@@ -64,7 +64,7 @@ describe('loadSettings — valid records', () => {
tabSize: 4,
},
performance: { renderDebounce: 2500 },
ui: { theme: 'dark', previewFitMode: 'full' },
ui: { theme: 'dark', previewFitMode: 'full', chartTheme: 'fivethirtyeight' },
formatting: { dateFormat: 'custom', customDateFormat: 'yyyy-MM-dd' },
};
expect(loadSettings(record)).toEqual(record);
@@ -160,6 +160,7 @@ describe('loadSettings — enum validation', () => {
expect(loadSettings({ ui: { previewFitMode: 'tall' } }).ui.previewFitMode).toBe(
d.ui.previewFitMode,
);
expect(loadSettings({ ui: { chartTheme: 'comic-sans' } }).ui.chartTheme).toBe(d.ui.chartTheme);
expect(loadSettings({ formatting: { dateFormat: 'relative' } }).formatting.dateFormat).toBe(
d.formatting.dateFormat,
);
@@ -171,6 +172,8 @@ describe('loadSettings — enum validation', () => {
expect(loadSettings({ ui: { theme: 'dark' } }).ui.theme).toBe('dark');
expect(loadSettings({ ui: { previewFitMode: 'width' } }).ui.previewFitMode).toBe('width');
expect(loadSettings({ ui: { previewFitMode: 'height' } }).ui.previewFitMode).toBe('height');
expect(loadSettings({ ui: { chartTheme: 'stock' } }).ui.chartTheme).toBe('stock');
expect(loadSettings({ ui: { chartTheme: 'latimes' } }).ui.chartTheme).toBe('latimes');
expect(loadSettings({ formatting: { dateFormat: 'iso' } }).formatting.dateFormat).toBe('iso');
});
+11
View File
@@ -13,6 +13,8 @@
* guarantees that (the read-time migration, mirroring `migrateSnippet`).
*/
import { isChartThemeId, type ChartThemeId } from './vega-themes';
/** Current schema version for a UserSettings record (read-time migration target). */
export const CURRENT_SETTINGS_VERSION = 1;
@@ -57,6 +59,13 @@ export interface UserSettings {
* Preview_). Default `'default'`.
*/
previewFitMode: 'default' | 'width' | 'height' | 'full';
/**
* Chart theme — which config is injected when charts render (spec §04;
* docs/chart-theming-scope.md §4.2). `'astrolabe'` (default) is the house
* style following the UI theme; `'stock'` injects nothing; other ids are
* vega-themes presets. Set by the preview's settings cluster.
*/
chartTheme: ChartThemeId;
};
/** Date-rendering preferences (spec §07 → Formatting). */
formatting: {
@@ -97,6 +106,7 @@ export function defaultSettings(): UserSettings {
ui: {
theme: 'light',
previewFitMode: 'default',
chartTheme: 'astrolabe',
},
formatting: {
dateFormat: 'smart',
@@ -194,6 +204,7 @@ export function loadSettings(raw: unknown): UserSettings {
['default', 'width', 'height', 'full'] as const,
d.ui.previewFitMode,
),
chartTheme: isChartThemeId(ui.chartTheme) ? ui.chartTheme : d.ui.chartTheme,
},
formatting: {
dateFormat: asEnum(
+82
View File
@@ -0,0 +1,82 @@
import { describe, expect, it } from 'vitest';
import { extractConfigFromSpec, isJsonObject, mergeConfigIntoSpec } from './spec-config';
describe('mergeConfigIntoSpec', () => {
const theme = {
background: 'transparent',
font: 'IBM Plex Sans',
axis: { labelColor: '#525252', gridDash: [2, 2] },
};
it('adds the config block to a spec without one', () => {
const spec = { mark: 'bar', data: { values: [] } };
const out = mergeConfigIntoSpec(spec, theme);
expect(out.config).toEqual(theme);
expect(out.mark).toBe('bar');
expect(spec).not.toHaveProperty('config'); // input untouched
});
it('the specs existing config wins key-by-key, deep', () => {
const spec = {
mark: 'bar',
config: { font: 'Georgia', axis: { labelColor: 'red' } },
};
const out = mergeConfigIntoSpec(spec, theme);
expect(out.config).toEqual({
background: 'transparent', // from the theme
font: 'Georgia', // spec wins
axis: { labelColor: 'red', gridDash: [2, 2] }, // merged: spec wins inside
});
});
it('arrays are replaced, not merged', () => {
const spec = { config: { axis: { gridDash: [8] } } };
const out = mergeConfigIntoSpec(spec, theme) as { config: { axis: { gridDash: number[] } } };
expect(out.config.axis.gridDash).toEqual([8]);
});
it('an empty config into a config-less spec stays config-less', () => {
expect(mergeConfigIntoSpec({ mark: 'bar' }, {})).toEqual({ mark: 'bar' });
});
it('a non-object spec config is replaced by the merge', () => {
const out = mergeConfigIntoSpec({ config: 'junk' }, theme);
expect(out.config).toEqual(theme);
});
});
describe('extractConfigFromSpec', () => {
it('removes and returns the config block', () => {
const spec = { mark: 'bar', config: { font: 'Georgia' } };
const out = extractConfigFromSpec(spec);
expect(out.config).toEqual({ font: 'Georgia' });
expect(out.spec).toEqual({ mark: 'bar' });
expect(spec).toHaveProperty('config'); // input untouched
});
it('returns null config when the spec has none', () => {
const out = extractConfigFromSpec({ mark: 'bar' });
expect(out.config).toBeNull();
expect(out.spec).toEqual({ mark: 'bar' });
});
it('an empty or non-object config extracts as null but is still removed', () => {
expect(extractConfigFromSpec({ mark: 'bar', config: {} })).toEqual({
spec: { mark: 'bar' },
config: null,
});
expect(extractConfigFromSpec({ mark: 'bar', config: 7 })).toEqual({
spec: { mark: 'bar' },
config: null,
});
});
});
describe('isJsonObject', () => {
it('accepts plain objects only', () => {
expect(isJsonObject({})).toBe(true);
expect(isJsonObject([])).toBe(false);
expect(isJsonObject(null)).toBe(false);
expect(isJsonObject('x')).toBe(false);
});
});
+78
View File
@@ -0,0 +1,78 @@
/**
* Spec ↔ config operations (docs/chart-theming-scope.md §4.3).
*
* The two halves of making the injected chart theme portable, mirroring the
* Vega editor's "Merge Config Into Spec" / "Extract Config From Spec" pair:
*
* - **Merge** bakes a config into the spec's own `config` block — for
* publishing a snippet somewhere the app's theme won't follow it. The spec's
* existing `config` wins on conflicts, matching the render-time precedence
* (vega-lite layers `spec.config` over the injected config), so baking never
* changes how the chart looks.
* - **Extract** lifts the `config` block out of a spec — for cleaning styling
* out of a pasted-in spec (the caller decides where the extracted config
* goes: clipboard today, a saved theme later).
*
* Pure object-in/object-out; JSON text handling (parse, format, undo) is the
* editor integration's job.
*/
/** A parsed JSON object (the only spec shape these operations accept). */
export type JsonObject = Record<string, unknown>;
/** Is the value a plain JSON object (not an array, not null)? */
export function isJsonObject(value: unknown): value is JsonObject {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
/**
* Deep-merge `upper` over `lower`: plain objects merge recursively, everything
* else (arrays, scalars) is replaced by the upper value. The same shape of
* merge vega-lite applies between the embed-time config and `spec.config`.
*/
function deepMerge(lower: JsonObject, upper: JsonObject): JsonObject {
const out: JsonObject = { ...lower };
for (const [key, upperValue] of Object.entries(upper)) {
const lowerValue = out[key];
out[key] =
isJsonObject(lowerValue) && isJsonObject(upperValue)
? deepMerge(lowerValue, upperValue)
: upperValue;
}
return out;
}
/**
* Bake `config` into the spec's `config` block. The spec's existing `config`
* takes precedence key-by-key (deep), so the rendered result is unchanged —
* the theme just travels with the spec now. Returns a new object; the input
* is not mutated. An empty merge result still writes `config: {}` only when
* the spec already had one; baking an empty config into a config-less spec is
* a no-op.
*/
export function mergeConfigIntoSpec(spec: JsonObject, config: JsonObject): JsonObject {
const specConfig = isJsonObject(spec.config) ? spec.config : {};
const merged = deepMerge(config, specConfig);
if (Object.keys(merged).length === 0 && !('config' in spec)) return { ...spec };
return { ...spec, config: merged };
}
/** Result of `extractConfigFromSpec`. */
export interface ExtractedConfig {
/** The spec without its `config` block (new object; input not mutated). */
spec: JsonObject;
/** The removed `config`, or null when the spec had none worth extracting. */
config: JsonObject | null;
}
/**
* Remove the spec's `config` block and hand it back separately. A missing,
* empty, or non-object `config` extracts as `null` (an empty/junk block is
* still removed from the spec — there is nothing to keep).
*/
export function extractConfigFromSpec(spec: JsonObject): ExtractedConfig {
if (!('config' in spec)) return { spec: { ...spec }, config: null };
const { config, ...rest } = spec;
const extracted = isJsonObject(config) && Object.keys(config).length > 0 ? config : null;
return { spec: rest, config: extracted };
}
+87 -1
View File
@@ -1,5 +1,17 @@
import { describe, expect, it } from 'vitest';
import { chartConfigFor, darkChartConfig, lightChartConfig } from './vega-themes';
import {
CHART_THEME_OPTIONS,
chartConfigFor,
chartConfigForSelection,
isChartThemeId,
darkBaseConfig,
darkChartConfig,
darkExpressiveConfig,
lightBaseConfig,
lightChartConfig,
lightExpressiveConfig,
mergeChartLayers,
} from './vega-themes';
import type { UiTheme } from './theme';
/**
@@ -37,3 +49,77 @@ describe('chartConfigFor', () => {
expect(lightChartConfig.range?.category).not.toEqual(darkChartConfig.range?.category);
});
});
/**
* The layer split (docs/chart-theming-scope.md §1): base carries only the
* legibility minimum (background + guide colors); everything brand-flavored
* (font, palette, grid dash, sizes/weights, view stroke) lives in expressive.
* A future stock/custom chart style keeps base and swaps expressive.
*/
describe('chart config layers', () => {
const layers = [
{ base: lightBaseConfig, expressive: lightExpressiveConfig, full: lightChartConfig },
{ base: darkBaseConfig, expressive: darkExpressiveConfig, full: darkChartConfig },
];
it.each(layers)('base stays free of house style', ({ base }) => {
expect(base.font).toBeUndefined();
expect(base.range).toBeUndefined();
expect(base.view).toBeUndefined();
expect(base.axis?.gridDash).toBeUndefined();
expect(base.title).not.toHaveProperty('fontSize');
});
it.each(layers)('expressive stays free of legibility colors', ({ expressive }) => {
expect(expressive.background).toBeUndefined();
expect(expressive.axis?.labelColor).toBeUndefined();
expect(expressive.title).not.toHaveProperty('color');
});
it.each(layers)('layers merge into the full theme config', ({ base, expressive, full }) => {
expect(mergeChartLayers(base, expressive)).toEqual(full);
});
it('merges the nested title and axis groups instead of replacing them', () => {
const merged = mergeChartLayers(lightBaseConfig, lightExpressiveConfig);
// One property from each layer survives in the same nested object.
expect(merged.title).toMatchObject({ color: '#161616', fontSize: 16 });
expect(merged.axis).toMatchObject({ labelColor: '#525252', gridDash: [2, 2] });
});
});
describe('chartConfigForSelection', () => {
it('astrolabe follows the UI theme', () => {
expect(chartConfigForSelection('astrolabe', 'light')).toBe(lightChartConfig);
expect(chartConfigForSelection('astrolabe', 'dark')).toBe(darkChartConfig);
});
it('stock injects nothing (vega-lite defaults apply)', () => {
expect(chartConfigForSelection('stock', 'light')).toEqual({});
expect(chartConfigForSelection('stock', 'dark')).toEqual({});
});
it('presets resolve to a non-empty config independent of UI theme', () => {
const light = chartConfigForSelection('fivethirtyeight', 'light');
expect(Object.keys(light).length).toBeGreaterThan(0);
expect(chartConfigForSelection('fivethirtyeight', 'dark')).toBe(light);
});
it('every option id resolves to a config', () => {
for (const { value } of CHART_THEME_OPTIONS) {
expect(chartConfigForSelection(value, 'light')).toBeTruthy();
}
});
});
describe('isChartThemeId', () => {
it('accepts every option id', () => {
for (const { value } of CHART_THEME_OPTIONS) expect(isChartThemeId(value)).toBe(true);
});
it('rejects unknown and non-string values', () => {
expect(isChartThemeId('comic-sans')).toBe(false);
expect(isChartThemeId(undefined)).toBe(false);
expect(isChartThemeId(7)).toBe(false);
});
});
+149 -21
View File
@@ -5,17 +5,29 @@
* visually belong to the app rather than looking like stock Vega-Lite. This is
* the single source of truth mapping a `UiTheme` to a config; it is applied at
* embed time (never baked into the user's stored spec). Adding a UI theme = one
* config object plus one map entry here.
* base + expressive pair plus one map entry here.
*
* Values track the design language: IBM Plex font, axis/grid colors from the
* Carbon neutral ramp (matching `--text-secondary` / `--border`), and a
* categorical `range.category` palette transcribed from Carbon's data-viz
* 14-color pairing (white theme for light, g100 for dark — see
* carbon-charts `packages/core/scss/_color-palette.scss`). This is the
* expressive "free color" layer (doc §3.5, §5).
* Each theme's config is two layers (docs/chart-theming-scope.md §1):
*
* - **Base** — the legibility/integration minimum: transparent background (the
* pane color shows through) and guide colors readable on the app's surfaces.
* Without this layer, stock black-on-white chart text is illegible on the
* dark pane. Colors match the app tokens (`--text`, `--text-secondary`,
* `--border`, `--border-strong`).
* - **Expressive** — the house style: IBM Plex, the Carbon data-viz categorical
* palette (white theme for light, g100 for dark — see carbon-charts
* `packages/core/scss/_color-palette.scss`), dotted grid, bumped guide
* sizes/weights, no plot border. This is the "free color" layer (doc §3.5,
* §5); charts render fine without it, just stock-looking.
*
* The split exists so a non-house chart style (stock preview, future custom
* themes) can keep the base layer while replacing the expressive one.
*/
import type { Config } from 'vega-lite';
// Preset chart styles from the vega-themes package (already in the dependency
// tree via vega-embed). Pure data — config objects only — so portable for core.
import * as presets from 'vega-themes';
import type { UiTheme } from './theme';
const PLEX = '"IBM Plex Sans", system-ui, -apple-system, sans-serif';
@@ -56,42 +68,74 @@ const darkCategory = [
'#d4bbff', // purple 30
];
export const lightChartConfig: Config = {
/** Base layer — light: transparent background + guide colors on app tokens. */
export const lightBaseConfig: Config = {
background: 'transparent',
font: PLEX,
title: { fontSize: 16, fontWeight: 600, color: '#161616' },
title: { color: '#161616' }, // --text (light)
axis: {
domainColor: '#c6c6c6', // --border-strong (light)
gridColor: '#e0e0e0', // --border (light)
gridDash: [2, 2],
labelColor: '#525252', // --text-secondary (light)
titleColor: '#161616', // --text (light)
labelFontSize: 11,
titleFontSize: 12,
titleFontWeight: 600,
},
range: { category: lightCategory },
view: { stroke: 'transparent' },
};
export const darkChartConfig: Config = {
/** Base layer — dark: transparent background + guide colors on app tokens. */
export const darkBaseConfig: Config = {
background: 'transparent',
font: PLEX,
title: { fontSize: 16, fontWeight: 600, color: '#f4f4f4' },
title: { color: '#f4f4f4' }, // --text (dark)
axis: {
domainColor: '#525252', // --border-strong (dark)
gridColor: '#393939', // --border (dark)
gridDash: [2, 2],
labelColor: '#a8a8a8', // --text-secondary (dark)
titleColor: '#f4f4f4', // --text (dark)
},
};
/** Expressive layer parts shared by both themes (everything but the palette). */
const sharedExpressive: Config = {
font: PLEX,
title: { fontSize: 16, fontWeight: 600 },
axis: {
gridDash: [2, 2],
labelFontSize: 11,
titleFontSize: 12,
titleFontWeight: 600,
},
range: { category: darkCategory },
view: { stroke: 'transparent' },
};
/** Expressive layer — light: house style + the light categorical palette. */
export const lightExpressiveConfig: Config = {
...sharedExpressive,
range: { category: lightCategory },
};
/** Expressive layer — dark: house style + the dark categorical palette. */
export const darkExpressiveConfig: Config = {
...sharedExpressive,
range: { category: darkCategory },
};
/**
* Merge a base and an expressive layer into one chart config. Shallow spread
* plus the two nested objects both layers contribute to (`title`, `axis`);
* the expressive layer wins on conflicts (there are none today — the layers
* own disjoint properties).
*/
export function mergeChartLayers(base: Config, expressive: Config): Config {
return {
...base,
...expressive,
title: { ...base.title, ...expressive.title },
axis: { ...base.axis, ...expressive.axis },
};
}
export const lightChartConfig: Config = mergeChartLayers(lightBaseConfig, lightExpressiveConfig);
export const darkChartConfig: Config = mergeChartLayers(darkBaseConfig, darkExpressiveConfig);
const CHART_CONFIG: Record<UiTheme, Config> = {
light: lightChartConfig,
dark: darkChartConfig,
@@ -101,3 +145,87 @@ const CHART_CONFIG: Record<UiTheme, Config> = {
export function chartConfigFor(theme: UiTheme): Config {
return CHART_CONFIG[theme];
}
/**
* Selectable chart themes (docs/chart-theming-scope.md §4.2) — the user-facing
* choice of how charts render, distinct from (and composed with) the UI theme:
*
* - `'astrolabe'` — the house style above; resolves per UI theme. Default.
* - `'stock'` — no injected config at all: charts render exactly as Vega-Lite
* defaults would anywhere else (white background, tableau10, sans-serif).
* - a `vega-themes` preset id — that preset's config verbatim, UI-theme
* independent, exactly as it would render in the Vega editor's theme dropdown.
*
* The spec's own `config` overrides whatever is selected, property by property
* (vega-lite merges `opt.config` under `spec.config`), so a snippet can always
* opt out locally.
*/
export type ChartThemeId = 'astrolabe' | 'stock' | ChartThemePresetId;
/** The vega-themes presets we surface, in display order. */
const PRESET_IDS = [
'excel',
'ggplot2',
'quartz',
'vox',
'fivethirtyeight',
'latimes',
'urbaninstitute',
'googlecharts',
'powerbi',
'carbonwhite',
'carbong10',
'carbong90',
'carbong100',
'dark',
] as const;
export type ChartThemePresetId = (typeof PRESET_IDS)[number];
/** Empty config — the stock sentinel resolves to "inject nothing". */
const STOCK_CONFIG: Config = {};
export interface ChartThemeOption {
value: ChartThemeId;
label: string;
/** Secondary line for pickers (what the choice means). */
detail?: string;
}
/** Display metadata for every selectable chart theme, in display order. */
export const CHART_THEME_OPTIONS: ReadonlyArray<ChartThemeOption> = [
{ value: 'astrolabe', label: 'Astrolabe', detail: 'House style, follows light/dark' },
{ value: 'stock', label: 'Stock Vega-Lite', detail: 'No theme applied' },
{ value: 'excel', label: 'Excel' },
{ value: 'ggplot2', label: 'ggplot2' },
{ value: 'quartz', label: 'Quartz' },
{ value: 'vox', label: 'Vox' },
{ value: 'fivethirtyeight', label: 'FiveThirtyEight' },
{ value: 'latimes', label: 'LA Times' },
{ value: 'urbaninstitute', label: 'Urban Institute' },
{ value: 'googlecharts', label: 'Google Charts' },
{ value: 'powerbi', label: 'Power BI' },
{ value: 'carbonwhite', label: 'Carbon — White' },
{ value: 'carbong10', label: 'Carbon — G10' },
{ value: 'carbong90', label: 'Carbon — G90' },
{ value: 'carbong100', label: 'Carbon — G100' },
{ value: 'dark', label: 'Vega Dark' },
];
const CHART_THEME_IDS: ReadonlySet<string> = new Set(['astrolabe', 'stock', ...PRESET_IDS]);
/** Type guard for persisted values (load-with-fallback; unknown ids fall back). */
export function isChartThemeId(value: unknown): value is ChartThemeId {
return typeof value === 'string' && CHART_THEME_IDS.has(value);
}
/**
* Resolve the user's chart-theme selection to the config to inject at embed
* time. `'astrolabe'` follows the UI theme; presets ignore it (their look is
* fixed — that's the point of previewing a destination style).
*/
export function chartConfigForSelection(selection: ChartThemeId, uiTheme: UiTheme): Config {
if (selection === 'astrolabe') return CHART_CONFIG[uiTheme];
if (selection === 'stock') return STOCK_CONFIG;
return presets[selection] as Config;
}