Implement M1.5 visual design foundation: tokens, IBM Plex, theme toggle, chart themes

This commit is contained in:
2026-06-05 01:29:10 +03:00
parent 547edb85e4
commit 22f0556ef8
31 changed files with 713 additions and 136 deletions
+1 -1
View File
@@ -7,4 +7,4 @@
* must never import from `src/app/`. The store and the document `data-theme`
* mirror this value; this is its single definition.
*/
export type UiTheme = 'light' | 'experimental';
export type UiTheme = 'light' | 'dark';
+39
View File
@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest';
import { chartConfigFor, darkChartConfig, lightChartConfig } from './vega-themes';
import type { UiTheme } from './theme';
/**
* Light smoke coverage for the chart theme — the design is mostly visual and
* verified by eye (docs/IMPLEMENTATION-PLAN.md M1.5). These guard the contract:
* every theme resolves to a config, charts stay transparent (inherit the
* surface), and the expressive categorical palette is present per theme.
*/
describe('chartConfigFor', () => {
const themes: UiTheme[] = ['light', 'dark'];
it('maps every UiTheme to its config', () => {
expect(chartConfigFor('light')).toBe(lightChartConfig);
expect(chartConfigFor('dark')).toBe(darkChartConfig);
});
it.each(themes)('keeps the chart background transparent (%s)', (theme) => {
expect(chartConfigFor(theme).background).toBe('transparent');
});
it.each(themes)('uses IBM Plex for the chart font (%s)', (theme) => {
expect(chartConfigFor(theme).font).toContain('IBM Plex');
});
it.each(themes)('ships a multi-color categorical palette (%s)', (theme) => {
const category = chartConfigFor(theme).range?.category as string[];
expect(Array.isArray(category)).toBe(true);
expect(category.length).toBeGreaterThanOrEqual(8);
// All entries are hex colors and distinct (no accidental duplicate slot).
expect(category.every((c) => /^#[0-9a-f]{6}$/i.test(c))).toBe(true);
expect(new Set(category).size).toBe(category.length);
});
it('uses distinct palettes per theme', () => {
expect(lightChartConfig.range?.category).not.toEqual(darkChartConfig.range?.category);
});
});
+66 -23
View File
@@ -1,57 +1,100 @@
/**
* Vega-Lite chart config per UI theme (docs/architecture/05 §3).
* Vega-Lite chart config per UI theme (docs/architecture/05 §3, 09 §5).
*
* Portable core: a Vega-Lite `Config` styles every chart globally so charts
* 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.
*
* 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).
*/
import type { Config } from 'vega-lite';
import type { UiTheme } from './theme';
const PLEX = '"IBM Plex Sans", system-ui, -apple-system, sans-serif';
/** Carbon data-viz 14-color categorical palette — white (light) theme. */
const lightCategory = [
'#6929c4', // purple 70
'#1192e8', // cyan 50
'#005d5d', // teal 70
'#9f1853', // magenta 70
'#fa4d56', // red 50
'#520408', // red 90
'#198038', // green 60
'#002d9c', // blue 80
'#ee5396', // magenta 50
'#b28600', // yellow 50
'#009d9a', // teal 50
'#012749', // cyan 90
'#8a3800', // orange 70
'#a56eff', // purple 50
];
/** Carbon data-viz 14-color categorical palette — g100 (dark) theme. */
const darkCategory = [
'#8a3ffc', // purple 60
'#33b1ff', // cyan 40
'#007d79', // teal 60
'#ff7eb6', // magenta 40
'#fa4d56', // red 50
'#fff1f1', // red 10
'#6fdc8c', // green 30
'#4589ff', // blue 50
'#d02670', // magenta 60
'#d2a106', // yellow 40
'#08bdba', // teal 40
'#bae6ff', // cyan 20
'#ba4e00', // orange 60
'#d4bbff', // purple 30
];
export const lightChartConfig: Config = {
background: 'transparent',
font: '"Inter", system-ui, sans-serif',
title: { fontSize: 15, fontWeight: 600, color: '#1c1c1e' },
font: PLEX,
title: { fontSize: 16, fontWeight: 600, color: '#161616' },
axis: {
domainColor: '#1c1c1e',
gridColor: '#e4e4e7',
gridDash: [3, 3],
labelColor: '#52525b',
titleColor: '#1c1c1e',
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: ['#2f6df6', '#f5a524', '#17b890', '#e5484d', '#8b5cf6', '#0ea5e9'],
},
range: { category: lightCategory },
view: { stroke: 'transparent' },
};
export const experimentalChartConfig: Config = {
export const darkChartConfig: Config = {
background: 'transparent',
font: '"Inter", system-ui, sans-serif',
title: { fontSize: 15, fontWeight: 600, color: '#f4f4f5' },
font: PLEX,
title: { fontSize: 16, fontWeight: 600, color: '#f4f4f4' },
axis: {
domainColor: '#a1a1aa',
gridColor: '#3f3f46',
gridDash: [3, 3],
labelColor: '#a1a1aa',
titleColor: '#f4f4f5',
domainColor: '#525252', // --border-strong (dark)
gridColor: '#393939', // --border (dark)
gridDash: [2, 2],
labelColor: '#a8a8a8', // --text-secondary (dark)
titleColor: '#f4f4f4', // --text (dark)
labelFontSize: 11,
titleFontSize: 12,
titleFontWeight: 600,
},
range: {
category: ['#5b8def', '#f5a524', '#2dd4a7', '#f0666b', '#a78bfa', '#38bdf8'],
},
range: { category: darkCategory },
view: { stroke: 'transparent' },
};
const CHART_CONFIG: Record<UiTheme, Config> = {
light: lightChartConfig,
experimental: experimentalChartConfig,
dark: darkChartConfig,
};
/** The Vega-Lite config for a UI theme — the only theme → config mapping. */