@opendata-ai/openchart-core 8.0.0-rc.2 → 8.0.0-rc.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +490 -25
- package/dist/index.js +57 -19
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/dist/styles.css.map +1 -1
- package/package.json +1 -2
- package/schema/chart.schema.json +485 -35
- package/schema/table.schema.json +35 -1
- package/schema/vizspec.schema.json +1305 -120
- package/src/accessibility/__tests__/alt-text.test.ts +0 -110
- package/src/accessibility/__tests__/aria.test.ts +0 -125
- package/src/accessibility/alt-text.ts +0 -155
- package/src/accessibility/aria.ts +0 -75
- package/src/accessibility/index.ts +0 -6
- package/src/colors/__tests__/colorblind.test.ts +0 -75
- package/src/colors/__tests__/contrast.test.ts +0 -119
- package/src/colors/__tests__/palettes.test.ts +0 -74
- package/src/colors/colorblind.ts +0 -131
- package/src/colors/contrast.ts +0 -143
- package/src/colors/index.ts +0 -36
- package/src/colors/palettes.ts +0 -206
- package/src/helpers/__tests__/deep-merge.test.ts +0 -54
- package/src/helpers/__tests__/spec-builders.test.ts +0 -338
- package/src/helpers/deep-merge.ts +0 -29
- package/src/helpers/spec-builders.ts +0 -479
- package/src/index.ts +0 -215
- package/src/labels/__tests__/collision.test.ts +0 -310
- package/src/labels/collision.ts +0 -249
- package/src/labels/index.ts +0 -13
- package/src/layout/__tests__/chrome.test.ts +0 -208
- package/src/layout/__tests__/text-measure.test.ts +0 -57
- package/src/layout/chrome.ts +0 -470
- package/src/layout/index.ts +0 -18
- package/src/layout/text-measure.ts +0 -404
- package/src/layout/text-wrap.ts +0 -93
- package/src/locale/__tests__/format.test.ts +0 -606
- package/src/locale/format.ts +0 -459
- package/src/locale/index.ts +0 -21
- package/src/responsive/__tests__/breakpoints.test.ts +0 -146
- package/src/responsive/__tests__/metrics.test.ts +0 -52
- package/src/responsive/breakpoints.ts +0 -172
- package/src/responsive/index.ts +0 -53
- package/src/responsive/metrics.ts +0 -299
- package/src/schema/__tests__/generated-artifacts.test.ts +0 -42
- package/src/schema/__tests__/schema.test.ts +0 -179
- package/src/styles/__tests__/reduced-motion-selectors.test.ts +0 -28
- package/src/styles/animation.css +0 -199
- package/src/styles/base.css +0 -102
- package/src/styles/chrome.css +0 -174
- package/src/styles/dark.css +0 -44
- package/src/styles/graph.css +0 -135
- package/src/styles/index.css +0 -28
- package/src/styles/keyframes.css +0 -135
- package/src/styles/legend.css +0 -18
- package/src/styles/reduced-motion.css +0 -67
- package/src/styles/series-search.css +0 -153
- package/src/styles/sparkline.css +0 -16
- package/src/styles/table-animation.css +0 -93
- package/src/styles/table.css +0 -398
- package/src/styles/token-definitions.ts +0 -199
- package/src/styles/tokens.css +0 -156
- package/src/styles/tooltip.css +0 -126
- package/src/styles/you-draw-it.css +0 -126
- package/src/theme/__tests__/dark-mode.test.ts +0 -232
- package/src/theme/__tests__/defaults.test.ts +0 -73
- package/src/theme/__tests__/resolve.test.ts +0 -277
- package/src/theme/dark-mode.ts +0 -258
- package/src/theme/defaults.ts +0 -110
- package/src/theme/index.ts +0 -8
- package/src/theme/presets.ts +0 -64
- package/src/theme/resolve.ts +0 -312
- package/src/types/__tests__/encoding.test.ts +0 -368
- package/src/types/__tests__/gradient.test.ts +0 -95
- package/src/types/__tests__/spec.test.ts +0 -499
- package/src/types/encoding.ts +0 -280
- package/src/types/events.ts +0 -154
- package/src/types/index.ts +0 -287
- package/src/types/layout.ts +0 -1966
- package/src/types/schema-roots.ts +0 -32
- package/src/types/spec.ts +0 -2867
- package/src/types/table.ts +0 -108
- package/src/types/theme.ts +0 -209
|
@@ -1,73 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import { contrastRatio } from '../../colors/contrast';
|
|
3
|
-
import { ACHROMATIC_RAMP } from '../../colors/palettes';
|
|
4
|
-
import { adaptColorForDarkMode } from '../dark-mode';
|
|
5
|
-
import { DEFAULT_THEME } from '../defaults';
|
|
6
|
-
|
|
7
|
-
describe('DEFAULT_THEME', () => {
|
|
8
|
-
it('has all required top-level fields', () => {
|
|
9
|
-
expect(DEFAULT_THEME.colors).toBeDefined();
|
|
10
|
-
expect(DEFAULT_THEME.fonts).toBeDefined();
|
|
11
|
-
expect(DEFAULT_THEME.spacing).toBeDefined();
|
|
12
|
-
expect(DEFAULT_THEME.borderRadius).toBeDefined();
|
|
13
|
-
expect(DEFAULT_THEME.chrome).toBeDefined();
|
|
14
|
-
});
|
|
15
|
-
|
|
16
|
-
it('uses Inter Variable as primary font', () => {
|
|
17
|
-
expect(DEFAULT_THEME.fonts.family).toContain('Inter Variable');
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
it('title is 26px demi (590 weight)', () => {
|
|
21
|
-
expect(DEFAULT_THEME.chrome.title.fontSize).toBe(26);
|
|
22
|
-
expect(DEFAULT_THEME.chrome.title.fontWeight).toBe(590);
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
it('subtitle is 14px normal weight', () => {
|
|
26
|
-
expect(DEFAULT_THEME.chrome.subtitle.fontSize).toBe(14);
|
|
27
|
-
expect(DEFAULT_THEME.chrome.subtitle.fontWeight).toBe(400);
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
it('source is 11px normal weight', () => {
|
|
31
|
-
expect(DEFAULT_THEME.chrome.source.fontSize).toBe(11);
|
|
32
|
-
expect(DEFAULT_THEME.chrome.source.fontWeight).toBe(400);
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
it('borderRadius is 2px (square aesthetic)', () => {
|
|
36
|
-
expect(DEFAULT_THEME.borderRadius).toBe(2);
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
it('font weights include 550 (medium) and 590 (demi)', () => {
|
|
40
|
-
expect(DEFAULT_THEME.fonts.weights.medium).toBe(550);
|
|
41
|
-
expect(DEFAULT_THEME.fonts.weights.semibold).toBe(590);
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
it('categorical palette is non-empty and primary accent is cyan', () => {
|
|
45
|
-
// Palette is a designed OKLCH multi-hue ramp tuned for dark surfaces
|
|
46
|
-
// (L~=0.70). It does not guarantee WCAG-AA-large-text contrast on
|
|
47
|
-
// pure white; light-mode line strokes route through the strong
|
|
48
|
-
// accent token (`adaptForLightLineStroke`) instead. Per-token
|
|
49
|
-
// accessibility is enforced via dedicated contrast helpers, not
|
|
50
|
-
// here.
|
|
51
|
-
expect(DEFAULT_THEME.colors.categorical.length).toBeGreaterThanOrEqual(5);
|
|
52
|
-
expect(DEFAULT_THEME.colors.categorical[0]).toBe('#06b6d4');
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
it('dark canvas surface tokens are part of the achromatic ramp', () => {
|
|
56
|
-
expect(ACHROMATIC_RAMP.bg).toBe('#09090b');
|
|
57
|
-
expect(ACHROMATIC_RAMP.fg).toBe('#f7f8f8');
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
it('dark-mode adapter preserves contrast monotonicity for cyan accent', () => {
|
|
61
|
-
// The cyan accent on white has a low ratio (~2.4); the adapter's
|
|
62
|
-
// job is to produce a similar ratio against the dark canvas, not
|
|
63
|
-
// to raise it. Just confirm the adapter returns something.
|
|
64
|
-
const adapted = adaptColorForDarkMode('#06b6d4', '#ffffff', ACHROMATIC_RAMP.bg);
|
|
65
|
-
expect(adapted).toMatch(/^#[0-9a-f]{6}$/i);
|
|
66
|
-
expect(contrastRatio(adapted, ACHROMATIC_RAMP.bg)).toBeGreaterThan(1.5);
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
it('has sequential and diverging palette entries', () => {
|
|
70
|
-
expect(Object.keys(DEFAULT_THEME.colors.sequential).length).toBeGreaterThan(0);
|
|
71
|
-
expect(Object.keys(DEFAULT_THEME.colors.diverging).length).toBeGreaterThan(0);
|
|
72
|
-
});
|
|
73
|
-
});
|
|
@@ -1,277 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import { DEFAULT_THEME } from '../defaults';
|
|
3
|
-
import { resolveTheme } from '../resolve';
|
|
4
|
-
|
|
5
|
-
describe('resolveTheme', () => {
|
|
6
|
-
it('returns default theme when no overrides given', () => {
|
|
7
|
-
const resolved = resolveTheme();
|
|
8
|
-
expect(resolved.colors.background).toBe(DEFAULT_THEME.colors.background);
|
|
9
|
-
expect(resolved.fonts.family).toBe(DEFAULT_THEME.fonts.family);
|
|
10
|
-
expect(resolved.isDark).toBe(false);
|
|
11
|
-
});
|
|
12
|
-
|
|
13
|
-
it('deep merges color overrides without losing other color fields', () => {
|
|
14
|
-
const resolved = resolveTheme({
|
|
15
|
-
colors: { background: '#111111' },
|
|
16
|
-
});
|
|
17
|
-
expect(resolved.colors.background).toBe('#111111');
|
|
18
|
-
// Other color fields preserved from defaults
|
|
19
|
-
expect(resolved.colors.text).toBe(DEFAULT_THEME.colors.text);
|
|
20
|
-
expect(resolved.colors.categorical).toEqual(DEFAULT_THEME.colors.categorical);
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
it('overrides font family', () => {
|
|
24
|
-
const resolved = resolveTheme({
|
|
25
|
-
fonts: { family: 'Helvetica' },
|
|
26
|
-
});
|
|
27
|
-
expect(resolved.fonts.family).toBe('Helvetica');
|
|
28
|
-
// Mono should still be default
|
|
29
|
-
expect(resolved.fonts.mono).toBe(DEFAULT_THEME.fonts.mono);
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
it('overrides spacing partially', () => {
|
|
33
|
-
const resolved = resolveTheme({
|
|
34
|
-
spacing: { padding: 24 },
|
|
35
|
-
});
|
|
36
|
-
expect(resolved.spacing.padding).toBe(24);
|
|
37
|
-
expect(resolved.spacing.chromeGap).toBe(DEFAULT_THEME.spacing.chromeGap);
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
it('overrides border radius', () => {
|
|
41
|
-
const resolved = resolveTheme({ borderRadius: 8 });
|
|
42
|
-
expect(resolved.borderRadius).toBe(8);
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
it('overrides a chrome element color, preserving its size and weight', () => {
|
|
46
|
-
const resolved = resolveTheme({ chrome: { subtitle: '#d0d6e0' } });
|
|
47
|
-
expect(resolved.chrome.subtitle.color).toBe('#d0d6e0');
|
|
48
|
-
// Non-color chrome fields come from the typography scale, untouched.
|
|
49
|
-
expect(resolved.chrome.subtitle.fontSize).toBe(14);
|
|
50
|
-
expect(resolved.chrome.subtitle.fontWeight).toBe(400);
|
|
51
|
-
// Other chrome elements keep their defaults.
|
|
52
|
-
expect(resolved.chrome.title.color).toBe('#09090b');
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
it('overrides categorical palette completely', () => {
|
|
56
|
-
const custom = ['#ff0000', '#00ff00', '#0000ff'];
|
|
57
|
-
const resolved = resolveTheme({
|
|
58
|
-
colors: { categorical: custom },
|
|
59
|
-
});
|
|
60
|
-
expect(resolved.colors.categorical).toEqual(custom);
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
it('accepts a custom base theme', () => {
|
|
64
|
-
const customBase = {
|
|
65
|
-
...DEFAULT_THEME,
|
|
66
|
-
borderRadius: 12,
|
|
67
|
-
};
|
|
68
|
-
const resolved = resolveTheme(undefined, customBase);
|
|
69
|
-
expect(resolved.borderRadius).toBe(12);
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
it('accepts flat string[] as shorthand for categorical colors', () => {
|
|
73
|
-
const colors = ['#ff0000', '#94a3b8', '#94a3b8'];
|
|
74
|
-
const resolved = resolveTheme({ colors });
|
|
75
|
-
expect(resolved.colors.categorical).toEqual(colors);
|
|
76
|
-
// Other color fields preserved from defaults
|
|
77
|
-
expect(resolved.colors.background).toBe(DEFAULT_THEME.colors.background);
|
|
78
|
-
expect(resolved.colors.text).toBe(DEFAULT_THEME.colors.text);
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
it('flat color array does not clobber non-color theme fields', () => {
|
|
82
|
-
const resolved = resolveTheme({ colors: ['#ff0000'] });
|
|
83
|
-
expect(resolved.fonts.family).toBe(DEFAULT_THEME.fonts.family);
|
|
84
|
-
expect(resolved.spacing.padding).toBe(DEFAULT_THEME.spacing.padding);
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
it('fonts.sizes overrides propagate to chrome element fontSizes', () => {
|
|
88
|
-
const resolved = resolveTheme({
|
|
89
|
-
fonts: { sizes: { title: 40, subtitle: 20, small: 16, axisTick: 16 } },
|
|
90
|
-
});
|
|
91
|
-
expect(resolved.chrome.title.fontSize).toBe(40);
|
|
92
|
-
expect(resolved.chrome.subtitle.fontSize).toBe(20);
|
|
93
|
-
expect(resolved.chrome.source.fontSize).toBe(16);
|
|
94
|
-
expect(resolved.chrome.byline.fontSize).toBe(16);
|
|
95
|
-
expect(resolved.chrome.footer.fontSize).toBe(16);
|
|
96
|
-
expect(resolved.chrome.eyebrow.fontSize).toBe(DEFAULT_THEME.chrome.eyebrow.fontSize);
|
|
97
|
-
expect(resolved.fonts.sizes.body).toBe(DEFAULT_THEME.fonts.sizes.body);
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
it('fonts.sizes partial override only changes specified sizes', () => {
|
|
101
|
-
const resolved = resolveTheme({ fonts: { sizes: { title: 36 } } });
|
|
102
|
-
expect(resolved.chrome.title.fontSize).toBe(36);
|
|
103
|
-
expect(resolved.chrome.subtitle.fontSize).toBe(DEFAULT_THEME.chrome.subtitle.fontSize);
|
|
104
|
-
expect(resolved.chrome.source.fontSize).toBe(DEFAULT_THEME.chrome.source.fontSize);
|
|
105
|
-
});
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
// ---------------------------------------------------------------------------
|
|
109
|
-
// Deep merge behavior hardening
|
|
110
|
-
// ---------------------------------------------------------------------------
|
|
111
|
-
|
|
112
|
-
describe('resolveTheme deep merge edge cases', () => {
|
|
113
|
-
it('replaces categorical array entirely, does not concatenate', () => {
|
|
114
|
-
const custom = ['#aaa', '#bbb'];
|
|
115
|
-
const resolved = resolveTheme({ colors: { categorical: custom } });
|
|
116
|
-
expect(resolved.colors.categorical).toEqual(custom);
|
|
117
|
-
expect(resolved.colors.categorical).toHaveLength(2);
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
it('skips undefined values, preserving defaults', () => {
|
|
121
|
-
const resolved = resolveTheme({ borderRadius: undefined });
|
|
122
|
-
expect(resolved.borderRadius).toBe(DEFAULT_THEME.borderRadius);
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
it('applies multiple overrides at different depths in one call', () => {
|
|
126
|
-
const resolved = resolveTheme({
|
|
127
|
-
colors: { background: '#222222', text: '#eeeeee' },
|
|
128
|
-
fonts: { family: 'Georgia' },
|
|
129
|
-
spacing: { padding: 32 },
|
|
130
|
-
borderRadius: 4,
|
|
131
|
-
});
|
|
132
|
-
expect(resolved.colors.background).toBe('#222222');
|
|
133
|
-
expect(resolved.colors.text).toBe('#eeeeee');
|
|
134
|
-
expect(resolved.fonts.family).toBe('Georgia');
|
|
135
|
-
expect(resolved.spacing.padding).toBe(32);
|
|
136
|
-
expect(resolved.borderRadius).toBe(4);
|
|
137
|
-
// Non-overridden values preserved
|
|
138
|
-
expect(resolved.fonts.mono).toBe(DEFAULT_THEME.fonts.mono);
|
|
139
|
-
expect(resolved.spacing.chromeGap).toBe(DEFAULT_THEME.spacing.chromeGap);
|
|
140
|
-
expect(resolved.colors.categorical).toEqual(DEFAULT_THEME.colors.categorical);
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
it('empty object override returns defaults unchanged', () => {
|
|
144
|
-
const resolved = resolveTheme({});
|
|
145
|
-
expect(resolved.colors).toEqual(expect.objectContaining(DEFAULT_THEME.colors));
|
|
146
|
-
expect(resolved.fonts).toEqual(DEFAULT_THEME.fonts);
|
|
147
|
-
expect(resolved.spacing).toEqual(DEFAULT_THEME.spacing);
|
|
148
|
-
expect(resolved.borderRadius).toBe(DEFAULT_THEME.borderRadius);
|
|
149
|
-
});
|
|
150
|
-
|
|
151
|
-
it('dark background adapts chrome colors without losing chrome structure', () => {
|
|
152
|
-
const resolved = resolveTheme({ colors: { background: '#111111', text: '#ffffff' } });
|
|
153
|
-
expect(resolved.isDark).toBe(true);
|
|
154
|
-
// Chrome structure should still be fully populated
|
|
155
|
-
expect(resolved.chrome.title).toBeDefined();
|
|
156
|
-
expect(resolved.chrome.subtitle).toBeDefined();
|
|
157
|
-
expect(resolved.chrome.source).toBeDefined();
|
|
158
|
-
expect(resolved.chrome.byline).toBeDefined();
|
|
159
|
-
expect(resolved.chrome.footer).toBeDefined();
|
|
160
|
-
// Title color should be adapted (not the light-mode default)
|
|
161
|
-
expect(resolved.chrome.title.color).not.toBe(DEFAULT_THEME.chrome.title.color);
|
|
162
|
-
});
|
|
163
|
-
});
|
|
164
|
-
|
|
165
|
-
// ---------------------------------------------------------------------------
|
|
166
|
-
// TokenValue resolution
|
|
167
|
-
// ---------------------------------------------------------------------------
|
|
168
|
-
|
|
169
|
-
describe('resolveTheme TokenValue handling', () => {
|
|
170
|
-
it('resolves plain string TokenValue to same string', () => {
|
|
171
|
-
const resolved = resolveTheme({ colors: { background: '#faf8f5' } });
|
|
172
|
-
expect(resolved.colors.background).toBe('#faf8f5');
|
|
173
|
-
});
|
|
174
|
-
|
|
175
|
-
it('resolves light/dark TokenValue pair to the light value', () => {
|
|
176
|
-
const resolved = resolveTheme({
|
|
177
|
-
colors: { background: { light: '#faf8f5', dark: '#1a1816' } },
|
|
178
|
-
});
|
|
179
|
-
expect(resolved.colors.background).toBe('#faf8f5');
|
|
180
|
-
});
|
|
181
|
-
|
|
182
|
-
it('stores token pairs on _tokenPairs for adaptTheme consumption', () => {
|
|
183
|
-
const resolved = resolveTheme({
|
|
184
|
-
colors: {
|
|
185
|
-
background: { light: '#fff', dark: '#111' },
|
|
186
|
-
text: { light: '#000', dark: '#eee' },
|
|
187
|
-
},
|
|
188
|
-
});
|
|
189
|
-
expect(resolved._tokenPairs).toBeDefined();
|
|
190
|
-
expect(resolved._tokenPairs?.['colors.background']).toEqual({ light: '#fff', dark: '#111' });
|
|
191
|
-
expect(resolved._tokenPairs?.['colors.text']).toEqual({ light: '#000', dark: '#eee' });
|
|
192
|
-
});
|
|
193
|
-
|
|
194
|
-
it('does not create _tokenPairs when all colors are plain strings', () => {
|
|
195
|
-
const resolved = resolveTheme({
|
|
196
|
-
colors: { background: '#fff', text: '#000' },
|
|
197
|
-
});
|
|
198
|
-
expect(resolved._tokenPairs).toBeUndefined();
|
|
199
|
-
});
|
|
200
|
-
|
|
201
|
-
it('resolves TokenValue on semantic color fields', () => {
|
|
202
|
-
const resolved = resolveTheme({
|
|
203
|
-
colors: {
|
|
204
|
-
positive: { light: '#10b981', dark: '#34d399' },
|
|
205
|
-
negative: { light: '#e11d48', dark: '#fb7185' },
|
|
206
|
-
},
|
|
207
|
-
});
|
|
208
|
-
expect(resolved.colors.positive).toBe('#10b981');
|
|
209
|
-
expect(resolved.colors.negative).toBe('#e11d48');
|
|
210
|
-
expect(resolved._tokenPairs?.['colors.positive']).toEqual({
|
|
211
|
-
light: '#10b981',
|
|
212
|
-
dark: '#34d399',
|
|
213
|
-
});
|
|
214
|
-
expect(resolved._tokenPairs?.['colors.negative']).toEqual({
|
|
215
|
-
light: '#e11d48',
|
|
216
|
-
dark: '#fb7185',
|
|
217
|
-
});
|
|
218
|
-
});
|
|
219
|
-
});
|
|
220
|
-
|
|
221
|
-
// ---------------------------------------------------------------------------
|
|
222
|
-
// New ThemeConfig fields
|
|
223
|
-
// ---------------------------------------------------------------------------
|
|
224
|
-
|
|
225
|
-
describe('resolveTheme widened ThemeConfig', () => {
|
|
226
|
-
it('overrides fonts.weights partially', () => {
|
|
227
|
-
const resolved = resolveTheme({
|
|
228
|
-
fonts: { weights: { normal: 400, bold: 700 } },
|
|
229
|
-
});
|
|
230
|
-
expect(resolved.fonts.weights.normal).toBe(400);
|
|
231
|
-
expect(resolved.fonts.weights.bold).toBe(700);
|
|
232
|
-
expect(resolved.fonts.weights.medium).toBe(DEFAULT_THEME.fonts.weights.medium);
|
|
233
|
-
});
|
|
234
|
-
|
|
235
|
-
it('overrides new spacing fields', () => {
|
|
236
|
-
const resolved = resolveTheme({
|
|
237
|
-
spacing: { chromeToChart: 16, chartToFooter: 20, axisMargin: 8 },
|
|
238
|
-
});
|
|
239
|
-
expect(resolved.spacing.chromeToChart).toBe(16);
|
|
240
|
-
expect(resolved.spacing.chartToFooter).toBe(20);
|
|
241
|
-
expect(resolved.spacing.axisMargin).toBe(8);
|
|
242
|
-
expect(resolved.spacing.padding).toBe(DEFAULT_THEME.spacing.padding);
|
|
243
|
-
});
|
|
244
|
-
|
|
245
|
-
it('overrides chrome typography with ChromeThemeOverride object', () => {
|
|
246
|
-
const resolved = resolveTheme({
|
|
247
|
-
chrome: {
|
|
248
|
-
title: { fontWeight: 700, fontSize: 32, lineHeight: 1.1 },
|
|
249
|
-
},
|
|
250
|
-
});
|
|
251
|
-
expect(resolved.chrome.title.fontWeight).toBe(700);
|
|
252
|
-
expect(resolved.chrome.title.fontSize).toBe(32);
|
|
253
|
-
expect(resolved.chrome.title.lineHeight).toBe(1.1);
|
|
254
|
-
expect(resolved.chrome.subtitle.fontWeight).toBe(DEFAULT_THEME.chrome.subtitle.fontWeight);
|
|
255
|
-
});
|
|
256
|
-
|
|
257
|
-
it('ChromeThemeOverride color accepts TokenValue', () => {
|
|
258
|
-
const resolved = resolveTheme({
|
|
259
|
-
chrome: {
|
|
260
|
-
title: { color: { light: '#111', dark: '#eee' }, fontWeight: 600 },
|
|
261
|
-
},
|
|
262
|
-
});
|
|
263
|
-
expect(resolved.chrome.title.color).toBe('#111');
|
|
264
|
-
expect(resolved.chrome.title.fontWeight).toBe(600);
|
|
265
|
-
expect(resolved._tokenPairs?.['chrome.title.color']).toEqual({ light: '#111', dark: '#eee' });
|
|
266
|
-
});
|
|
267
|
-
|
|
268
|
-
it('passes seriesStrategy through to resolved theme', () => {
|
|
269
|
-
const resolved = resolveTheme({ seriesStrategy: 'accent-neutral' });
|
|
270
|
-
expect(resolved.seriesStrategy).toBe('accent-neutral');
|
|
271
|
-
});
|
|
272
|
-
|
|
273
|
-
it('defaults seriesStrategy to palette', () => {
|
|
274
|
-
const resolved = resolveTheme();
|
|
275
|
-
expect(resolved.seriesStrategy).toBe('palette');
|
|
276
|
-
});
|
|
277
|
-
});
|
package/src/theme/dark-mode.ts
DELETED
|
@@ -1,258 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Dark mode theme adaptation.
|
|
3
|
-
*
|
|
4
|
-
* Preserves hue of colors while adjusting lightness and saturation
|
|
5
|
-
* to maintain the same relative contrast ratios on a dark background.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import { hsl, rgb } from 'd3-color';
|
|
9
|
-
import { contrastRatio } from '../colors/contrast';
|
|
10
|
-
import { ACHROMATIC_RAMP } from '../colors/palettes';
|
|
11
|
-
import type { ResolvedTheme } from '../types/theme';
|
|
12
|
-
import { DEFAULT_THEME } from './defaults';
|
|
13
|
-
|
|
14
|
-
// ---------------------------------------------------------------------------
|
|
15
|
-
// Dark mode background
|
|
16
|
-
// ---------------------------------------------------------------------------
|
|
17
|
-
|
|
18
|
-
/** Default dark mode background color (zinc-based canvas). */
|
|
19
|
-
const DARK_BG = ACHROMATIC_RAMP.bg;
|
|
20
|
-
/** Default dark mode text color. */
|
|
21
|
-
const DARK_TEXT = ACHROMATIC_RAMP.fg;
|
|
22
|
-
|
|
23
|
-
// ---------------------------------------------------------------------------
|
|
24
|
-
// Color adaptation
|
|
25
|
-
// ---------------------------------------------------------------------------
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* Adapt a single color for dark mode.
|
|
29
|
-
*
|
|
30
|
-
* Preserves the hue and adjusts lightness/saturation so the adapted
|
|
31
|
-
* color has the same contrast ratio against darkBg as the original
|
|
32
|
-
* had against lightBg.
|
|
33
|
-
*/
|
|
34
|
-
export function adaptColorForDarkMode(color: string, lightBg: string, darkBg: string): string {
|
|
35
|
-
// Adapter only handles hex/rgb-style inputs. Raw oklch() and other
|
|
36
|
-
// CSS Color 4 strings parse unreliably through d3-color in happy-dom,
|
|
37
|
-
// so guard early instead of silently falling back to a default.
|
|
38
|
-
if (rgb(color) == null) {
|
|
39
|
-
if (typeof console !== 'undefined' && console.warn) {
|
|
40
|
-
console.warn(
|
|
41
|
-
`[openchart] adaptColorForDarkMode: unparseable color "${color}", returning unchanged. Use precomputed sRGB hex.`,
|
|
42
|
-
);
|
|
43
|
-
}
|
|
44
|
-
return color;
|
|
45
|
-
}
|
|
46
|
-
const originalRatio = contrastRatio(color, lightBg);
|
|
47
|
-
const c = hsl(color);
|
|
48
|
-
if (c == null || Number.isNaN(c.h)) {
|
|
49
|
-
// Achromatic or invalid color: just adjust lightness
|
|
50
|
-
const r = rgb(color);
|
|
51
|
-
if (r == null) return color;
|
|
52
|
-
const darkBgLum = _luminanceFromHex(darkBg);
|
|
53
|
-
const isLight = darkBgLum < 0.5;
|
|
54
|
-
if (isLight) return color;
|
|
55
|
-
// Invert the lightness
|
|
56
|
-
const inverted = hsl(color);
|
|
57
|
-
if (inverted == null) return color;
|
|
58
|
-
inverted.l = 1 - inverted.l;
|
|
59
|
-
return inverted.formatHex();
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
// Binary search for lightness that gives equivalent contrast on dark bg
|
|
63
|
-
let lo = 0.0;
|
|
64
|
-
let hi = 1.0;
|
|
65
|
-
let bestColor = color;
|
|
66
|
-
let bestDiff = Infinity;
|
|
67
|
-
|
|
68
|
-
for (let i = 0; i < 20; i++) {
|
|
69
|
-
const mid = (lo + hi) / 2;
|
|
70
|
-
const candidate = hsl(c.h, c.s, mid);
|
|
71
|
-
const hex = candidate.formatHex();
|
|
72
|
-
const ratio = contrastRatio(hex, darkBg);
|
|
73
|
-
const diff = Math.abs(ratio - originalRatio);
|
|
74
|
-
|
|
75
|
-
if (diff < bestDiff) {
|
|
76
|
-
bestDiff = diff;
|
|
77
|
-
bestColor = hex;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
if (ratio < originalRatio) {
|
|
81
|
-
// Need more contrast = more lightness on dark bg
|
|
82
|
-
lo = mid;
|
|
83
|
-
} else {
|
|
84
|
-
hi = mid;
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
return bestColor;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/** Quick luminance estimation from a hex color. */
|
|
92
|
-
function _luminanceFromHex(color: string): number {
|
|
93
|
-
const c = rgb(color);
|
|
94
|
-
if (c == null) return 0;
|
|
95
|
-
return (0.2126 * c.r + 0.7152 * c.g + 0.0722 * c.b) / 255;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
// ---------------------------------------------------------------------------
|
|
99
|
-
// Full theme adaptation
|
|
100
|
-
// ---------------------------------------------------------------------------
|
|
101
|
-
|
|
102
|
-
/**
|
|
103
|
-
* Adapt an entire resolved theme for dark mode.
|
|
104
|
-
*
|
|
105
|
-
* Swaps background/text, adapts categorical and annotation colors,
|
|
106
|
-
* adjusts gridline and axis colors for the dark background.
|
|
107
|
-
*/
|
|
108
|
-
export function adaptTheme(theme: ResolvedTheme): ResolvedTheme {
|
|
109
|
-
const pairs = theme._tokenPairs;
|
|
110
|
-
const inputBg = theme.colors.background;
|
|
111
|
-
// "transparent" preserves the background token but must still adopt dark
|
|
112
|
-
// text/gridline/axis colors — the container is dark, not the chart canvas.
|
|
113
|
-
const isTransparent = inputBg === 'transparent';
|
|
114
|
-
const alreadyDark = isTransparent || _luminanceFromHex(inputBg) < 0.2;
|
|
115
|
-
|
|
116
|
-
// Preserve user-supplied background (including transparent) but always
|
|
117
|
-
// apply dark-mode text/axis/gridline colors so labels are readable on the
|
|
118
|
-
// dark host surface.
|
|
119
|
-
const darkBg = pairs?.['colors.background']
|
|
120
|
-
? pairs['colors.background'].dark
|
|
121
|
-
: alreadyDark
|
|
122
|
-
? inputBg
|
|
123
|
-
: DARK_BG;
|
|
124
|
-
const darkMuted = ACHROMATIC_RAMP.fgMuted;
|
|
125
|
-
|
|
126
|
-
// Per the "sensible defaults, full override" convention (see spec-grammar),
|
|
127
|
-
// dark-mode adaptation only swaps in dark defaults for colors the spec
|
|
128
|
-
// left at their light-mode default. An explicit override is preserved.
|
|
129
|
-
// When a TokenValue pair has an explicit dark value, use it directly.
|
|
130
|
-
const light = DEFAULT_THEME.colors;
|
|
131
|
-
const lightChrome = DEFAULT_THEME.chrome;
|
|
132
|
-
const overridden = <T>(current: T, lightDefault: T, darkDefault: T): T =>
|
|
133
|
-
current === lightDefault ? darkDefault : current;
|
|
134
|
-
|
|
135
|
-
const darkText = pairs?.['colors.text']
|
|
136
|
-
? pairs['colors.text'].dark
|
|
137
|
-
: overridden(theme.colors.text, light.text, DARK_TEXT);
|
|
138
|
-
const darkGridline = pairs?.['colors.gridline']
|
|
139
|
-
? pairs['colors.gridline'].dark
|
|
140
|
-
: overridden(theme.colors.gridline, light.gridline, 'rgba(255,255,255,0.05)');
|
|
141
|
-
// axis is also tick-label fill — needs WCAG AA contrast on dark bg.
|
|
142
|
-
// Zinc-400 (`#a1a1aa`) hits ~6:1 against #09090b.
|
|
143
|
-
const darkAxis = pairs?.['colors.axis']
|
|
144
|
-
? pairs['colors.axis'].dark
|
|
145
|
-
: overridden(theme.colors.axis, light.axis, '#a1a1aa');
|
|
146
|
-
|
|
147
|
-
// Categorical palette is pinned to design-system tokens. The same vibrant
|
|
148
|
-
// hex values render in both light and dark modes — adapting them via
|
|
149
|
-
// contrast-equivalence dulls them on dark backgrounds (cyan -> teal),
|
|
150
|
-
// which is the opposite of what the design system calls for.
|
|
151
|
-
const categorical = theme.colors.categorical;
|
|
152
|
-
|
|
153
|
-
return {
|
|
154
|
-
...theme,
|
|
155
|
-
isDark: true,
|
|
156
|
-
colors: {
|
|
157
|
-
...theme.colors,
|
|
158
|
-
background: darkBg,
|
|
159
|
-
text: darkText,
|
|
160
|
-
gridline: darkGridline,
|
|
161
|
-
axis: darkAxis,
|
|
162
|
-
annotationFill: pairs?.['colors.annotationFill']
|
|
163
|
-
? pairs['colors.annotationFill'].dark
|
|
164
|
-
: overridden(theme.colors.annotationFill, light.annotationFill, 'rgba(255,255,255,0.06)'),
|
|
165
|
-
annotationText: pairs?.['colors.annotationText']
|
|
166
|
-
? pairs['colors.annotationText'].dark
|
|
167
|
-
: overridden(theme.colors.annotationText, light.annotationText, darkMuted),
|
|
168
|
-
categorical,
|
|
169
|
-
positive: pairs?.['colors.positive']
|
|
170
|
-
? pairs['colors.positive'].dark
|
|
171
|
-
: theme.colors.positive !== '#16a34a'
|
|
172
|
-
? theme.colors.positive
|
|
173
|
-
: '#34d399',
|
|
174
|
-
negative: pairs?.['colors.negative']
|
|
175
|
-
? pairs['colors.negative'].dark
|
|
176
|
-
: theme.colors.negative !== '#dc2626'
|
|
177
|
-
? theme.colors.negative
|
|
178
|
-
: '#f87171',
|
|
179
|
-
},
|
|
180
|
-
chrome: {
|
|
181
|
-
// Eyebrow keeps its accent tint (cyan in both modes) unless the user
|
|
182
|
-
// gave an explicit dark value; the other chrome elements desaturate
|
|
183
|
-
// to a muted gray on the dark canvas. Each color only adapts if the
|
|
184
|
-
// spec left it at the light default, so explicit chrome color
|
|
185
|
-
// overrides survive dark-mode adaptation. When a TokenValue pair has
|
|
186
|
-
// an explicit dark value, use it directly.
|
|
187
|
-
eyebrow: pairs?.['chrome.eyebrow.color']
|
|
188
|
-
? { ...theme.chrome.eyebrow, color: pairs['chrome.eyebrow.color'].dark }
|
|
189
|
-
: theme.chrome.eyebrow,
|
|
190
|
-
title: {
|
|
191
|
-
...theme.chrome.title,
|
|
192
|
-
color: pairs?.['chrome.title.color']
|
|
193
|
-
? pairs['chrome.title.color'].dark
|
|
194
|
-
: overridden(theme.chrome.title.color, lightChrome.title.color, darkText),
|
|
195
|
-
},
|
|
196
|
-
subtitle: {
|
|
197
|
-
...theme.chrome.subtitle,
|
|
198
|
-
color: pairs?.['chrome.subtitle.color']
|
|
199
|
-
? pairs['chrome.subtitle.color'].dark
|
|
200
|
-
: overridden(theme.chrome.subtitle.color, lightChrome.subtitle.color, darkMuted),
|
|
201
|
-
},
|
|
202
|
-
source: {
|
|
203
|
-
...theme.chrome.source,
|
|
204
|
-
color: pairs?.['chrome.source.color']
|
|
205
|
-
? pairs['chrome.source.color'].dark
|
|
206
|
-
: overridden(theme.chrome.source.color, lightChrome.source.color, darkMuted),
|
|
207
|
-
},
|
|
208
|
-
byline: {
|
|
209
|
-
...theme.chrome.byline,
|
|
210
|
-
color: pairs?.['chrome.byline.color']
|
|
211
|
-
? pairs['chrome.byline.color'].dark
|
|
212
|
-
: overridden(theme.chrome.byline.color, lightChrome.byline.color, darkMuted),
|
|
213
|
-
},
|
|
214
|
-
footer: {
|
|
215
|
-
...theme.chrome.footer,
|
|
216
|
-
color: pairs?.['chrome.footer.color']
|
|
217
|
-
? pairs['chrome.footer.color'].dark
|
|
218
|
-
: overridden(theme.chrome.footer.color, lightChrome.footer.color, darkMuted),
|
|
219
|
-
},
|
|
220
|
-
},
|
|
221
|
-
};
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
// ---------------------------------------------------------------------------
|
|
225
|
-
// Light mode line stroke darkening
|
|
226
|
-
// ---------------------------------------------------------------------------
|
|
227
|
-
|
|
228
|
-
/**
|
|
229
|
-
* Returns a darker variant of a color for use as a foreground stroke on
|
|
230
|
-
* light backgrounds, where mid-lightness palette colors (e.g. cyan-500
|
|
231
|
-
* `#06b6d4`) lack contrast against white. Drops HSL lightness by ~12%
|
|
232
|
-
* absolute (clamped to >= 0) while preserving hue and saturation, which
|
|
233
|
-
* reproduces the cyan-500 → cyan-600 step the cyan accent originally
|
|
234
|
-
* needed and works for any other palette accent the user picks.
|
|
235
|
-
*
|
|
236
|
-
* Returns the input unchanged when:
|
|
237
|
-
* - the color is already dark enough (L <= ~0.40) — further darkening
|
|
238
|
-
* just muddies the stroke without adding contrast
|
|
239
|
-
* - the color isn't parseable
|
|
240
|
-
* - the color is achromatic (NaN hue) — HSL lightness on grays drifts
|
|
241
|
-
* toward black instead of staying neutral
|
|
242
|
-
*
|
|
243
|
-
* Exposed via the `--oc-accent-strong` CSS token.
|
|
244
|
-
*/
|
|
245
|
-
export function adaptForLightLineStroke(color: string): string {
|
|
246
|
-
if (rgb(color) == null) return color;
|
|
247
|
-
const c = hsl(color);
|
|
248
|
-
if (c == null) return color;
|
|
249
|
-
// Achromatic check: NaN hue OR low saturation. d3-color reports a valid
|
|
250
|
-
// hue for grays whose RGB channels aren't perfectly equal (e.g. zinc
|
|
251
|
-
// `#a1a1aa` has s≈0.05), so saturation is the more reliable signal.
|
|
252
|
-
// Threshold 0.10 catches near-grays without tripping on real palette
|
|
253
|
-
// colors (palette saturation is ≥ ~0.5).
|
|
254
|
-
if (Number.isNaN(c.h) || c.s < 0.1) return color;
|
|
255
|
-
if (c.l <= 0.4) return color;
|
|
256
|
-
c.l = Math.max(0, c.l - 0.12);
|
|
257
|
-
return c.formatHex();
|
|
258
|
-
}
|