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:
+1
-1
@@ -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';
|
||||
|
||||
@@ -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
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user