@opendata-ai/openchart-core 7.6.0 → 7.7.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opendata-ai/openchart-core",
3
- "version": "7.6.0",
3
+ "version": "7.7.0",
4
4
  "description": "Types, theme, colors, accessibility, and utilities for openchart",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Riley Hilliard",
@@ -8,9 +8,9 @@
8
8
  * binary search. Source OKLCH values are documented inline so the ramp
9
9
  * can be regenerated if needed.
10
10
  *
11
- * Convention for low-cardinality charts (1 / 2-4 / 5+ series) is enforced
12
- * by chart-type renderer logic, not the palette itself. The palette stays
13
- * a flat 9-color ramp consumed by index. The convention is:
11
+ * Convention for low-cardinality charts (1 / 2-4 / 5+ series) is available
12
+ * as an opt-in via `theme.seriesStrategy`. The default strategy is 'palette'
13
+ * (full categorical ramp always). The accent-neutral convention is:
14
14
  * - 1 series: cyan only
15
15
  * - 2-4 series: cyan + zinc-400/500/600 (achromatic)
16
16
  * - 5+ series: full multi-hue ramp below
@@ -425,8 +425,10 @@ export function dataTable(data: DataRow[], options?: TableBuilderOptions): Table
425
425
  export interface TileMapBuilderOptions {
426
426
  /** Encoding channels (required when data is DataRow[]). */
427
427
  encoding?: TileMapEncoding;
428
- /** Sequential color palette name. */
428
+ /** Sequential color palette name (quantitative mode only). */
429
429
  palette?: TileMapPalette;
430
+ /** Custom category-to-color mapping. Forces categorical mode. */
431
+ colors?: Record<string, string>;
430
432
  /** Editorial chrome. */
431
433
  chrome?: Chrome;
432
434
  /** Legend display configuration. */
@@ -457,7 +459,7 @@ export interface TileMapBuilderOptions {
457
459
  * ```
458
460
  */
459
461
  export function tileMap(
460
- data: Record<string, number | null> | DataRow[],
462
+ data: Record<string, number | string | null> | DataRow[],
461
463
  options?: TileMapBuilderOptions,
462
464
  ): TileMapSpec {
463
465
  return {
@@ -465,6 +467,7 @@ export function tileMap(
465
467
  data,
466
468
  ...(options?.encoding && { encoding: options.encoding }),
467
469
  ...(options?.palette && { palette: options.palette }),
470
+ ...(options?.colors && { colors: options.colors }),
468
471
  ...(options?.chrome && { chrome: options.chrome }),
469
472
  ...(options?.legend && { legend: options.legend }),
470
473
  ...(options?.theme && { theme: options.theme }),
package/src/index.ts CHANGED
@@ -27,6 +27,7 @@ export type {
27
27
  SequentialPalette,
28
28
  } from './colors/index';
29
29
  export {
30
+ ACHROMATIC_RAMP,
30
31
  CATEGORICAL_PALETTE,
31
32
  checkPaletteDistinguishability,
32
33
  contrastRatio,
@@ -44,9 +45,13 @@ export {
44
45
 
45
46
  export {
46
47
  adaptColorForDarkMode,
48
+ adaptForLightLineStroke,
47
49
  adaptTheme,
48
50
  DEFAULT_THEME,
51
+ editorial,
52
+ essay,
49
53
  resolveTheme,
54
+ wire,
50
55
  } from './theme/index';
51
56
 
52
57
  // ---------------------------------------------------------------------------
@@ -1,5 +1,9 @@
1
1
  /* ---------------------------------------------------------------------------
2
- * Dark mode overrides
2
+ * Dark mode overrides (fallback defaults)
3
+ *
4
+ * At runtime, the JS theme engine stamps dark-adapted --oc-* custom
5
+ * properties on each .oc-root container. These CSS values serve as
6
+ * fallbacks for contexts where JS hasn't mounted yet.
3
7
  * --------------------------------------------------------------------------- */
4
8
 
5
9
  .oc-dark {
@@ -1,5 +1,11 @@
1
1
  /* ---------------------------------------------------------------------------
2
2
  * Custom properties (light mode defaults)
3
+ *
4
+ * These are FALLBACK defaults. At runtime, the JS theme engine stamps the
5
+ * resolved theme as inline --oc-* custom properties on each .oc-root
6
+ * container (see packages/vanilla/src/theme-tokens.ts). These CSS values
7
+ * serve as fallbacks for contexts where JS hasn't mounted yet (SSR, static
8
+ * HTML examples).
3
9
  * --------------------------------------------------------------------------- */
4
10
 
5
11
  .oc-root,
@@ -149,4 +149,84 @@ describe('adaptTheme', () => {
149
149
  // Unset chrome colors fall to the muted dark default, not the light one.
150
150
  expect(dark.chrome.subtitle.color).not.toBe('#71717a');
151
151
  });
152
+
153
+ it('uses explicit dark pair value for background instead of algorithmic adaptation', () => {
154
+ const light = resolveTheme({
155
+ colors: { background: { light: '#fff1e5', dark: '#1a1311' } },
156
+ });
157
+ const dark = adaptTheme(light);
158
+ expect(dark.colors.background).toBe('#1a1311');
159
+ });
160
+
161
+ it('uses explicit dark pair value for text instead of algorithmic adaptation', () => {
162
+ const light = resolveTheme({
163
+ colors: {
164
+ background: { light: '#ffffff', dark: '#111111' },
165
+ text: { light: '#1c1917', dark: '#e7e5e4' },
166
+ },
167
+ });
168
+ const dark = adaptTheme(light);
169
+ expect(dark.colors.text).toBe('#e7e5e4');
170
+ });
171
+
172
+ it('uses explicit dark pair for gridline and axis', () => {
173
+ const light = resolveTheme({
174
+ colors: {
175
+ background: { light: '#fff', dark: '#111' },
176
+ gridline: { light: 'rgba(0,0,0,0.08)', dark: 'rgba(255,255,255,0.06)' },
177
+ axis: { light: '#78716c', dark: '#a8a29e' },
178
+ },
179
+ });
180
+ const dark = adaptTheme(light);
181
+ expect(dark.colors.gridline).toBe('rgba(255,255,255,0.06)');
182
+ expect(dark.colors.axis).toBe('#a8a29e');
183
+ });
184
+
185
+ it('uses explicit dark pair for semantic colors', () => {
186
+ const light = resolveTheme({
187
+ colors: {
188
+ background: { light: '#fff', dark: '#111' },
189
+ positive: { light: '#10b981', dark: '#34d399' },
190
+ negative: { light: '#e11d48', dark: '#fb7185' },
191
+ },
192
+ });
193
+ const dark = adaptTheme(light);
194
+ expect(dark.colors.positive).toBe('#34d399');
195
+ expect(dark.colors.negative).toBe('#fb7185');
196
+ });
197
+
198
+ it('uses explicit dark pair for chrome element color', () => {
199
+ const light = resolveTheme({
200
+ colors: { background: { light: '#fff', dark: '#111' } },
201
+ chrome: {
202
+ title: { color: { light: '#33302e', dark: '#f2dfce' } },
203
+ },
204
+ });
205
+ const dark = adaptTheme(light);
206
+ expect(dark.chrome.title.color).toBe('#f2dfce');
207
+ });
208
+
209
+ it('uses explicit dark pair for eyebrow color (otherwise kept as-is)', () => {
210
+ const light = resolveTheme({
211
+ chrome: {
212
+ eyebrow: { color: { light: '#e3120b', dark: '#ff6b64' } },
213
+ },
214
+ });
215
+ const dark = adaptTheme(light);
216
+ expect(dark.chrome.eyebrow.color).toBe('#ff6b64');
217
+
218
+ // Without a pair, the eyebrow keeps its accent tint across modes.
219
+ const noPair = adaptTheme(resolveTheme({ chrome: { eyebrow: '#e3120b' } }));
220
+ expect(noPair.chrome.eyebrow.color).toBe('#e3120b');
221
+ });
222
+
223
+ it('falls back to algorithmic adaptation when no explicit dark pair is given', () => {
224
+ const light = resolveTheme({
225
+ colors: { background: '#ffffff' },
226
+ });
227
+ const dark = adaptTheme(light);
228
+ // Text should be adapted to a light color for dark bg readability
229
+ expect(dark.colors.text).not.toBe(light.colors.text);
230
+ expect(dark.colors.text).toBeDefined();
231
+ });
152
232
  });
@@ -161,3 +161,117 @@ describe('resolveTheme deep merge edge cases', () => {
161
161
  expect(resolved.chrome.title.color).not.toBe(DEFAULT_THEME.chrome.title.color);
162
162
  });
163
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
+ });
@@ -106,6 +106,7 @@ function _luminanceFromHex(color: string): number {
106
106
  * adjusts gridline and axis colors for the dark background.
107
107
  */
108
108
  export function adaptTheme(theme: ResolvedTheme): ResolvedTheme {
109
+ const pairs = theme._tokenPairs;
109
110
  const inputBg = theme.colors.background;
110
111
  // "transparent" preserves the background token but must still adopt dark
111
112
  // text/gridline/axis colors — the container is dark, not the chart canvas.
@@ -115,23 +116,33 @@ export function adaptTheme(theme: ResolvedTheme): ResolvedTheme {
115
116
  // Preserve user-supplied background (including transparent) but always
116
117
  // apply dark-mode text/axis/gridline colors so labels are readable on the
117
118
  // dark host surface.
118
- const darkBg = alreadyDark ? inputBg : DARK_BG;
119
+ const darkBg = pairs?.['colors.background']
120
+ ? pairs['colors.background'].dark
121
+ : alreadyDark
122
+ ? inputBg
123
+ : DARK_BG;
119
124
  const darkMuted = ACHROMATIC_RAMP.fgMuted;
120
125
 
121
126
  // Per the "sensible defaults, full override" convention (see spec-grammar),
122
127
  // dark-mode adaptation only swaps in dark defaults for colors the spec
123
128
  // left at their light-mode default. An explicit override is preserved.
124
- // This mirrors resolveTheme's adaptChromeForDarkBg.
129
+ // When a TokenValue pair has an explicit dark value, use it directly.
125
130
  const light = DEFAULT_THEME.colors;
126
131
  const lightChrome = DEFAULT_THEME.chrome;
127
132
  const overridden = <T>(current: T, lightDefault: T, darkDefault: T): T =>
128
133
  current === lightDefault ? darkDefault : current;
129
134
 
130
- const darkText = overridden(theme.colors.text, light.text, DARK_TEXT);
131
- const darkGridline = overridden(theme.colors.gridline, light.gridline, 'rgba(255,255,255,0.05)');
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)');
132
141
  // axis is also tick-label fill — needs WCAG AA contrast on dark bg.
133
142
  // Zinc-400 (`#a1a1aa`) hits ~6:1 against #09090b.
134
- const darkAxis = overridden(theme.colors.axis, light.axis, '#a1a1aa');
143
+ const darkAxis = pairs?.['colors.axis']
144
+ ? pairs['colors.axis'].dark
145
+ : overridden(theme.colors.axis, light.axis, '#a1a1aa');
135
146
 
136
147
  // Categorical palette is pinned to design-system tokens. The same vibrant
137
148
  // hex values render in both light and dark modes — adapting them via
@@ -148,44 +159,63 @@ export function adaptTheme(theme: ResolvedTheme): ResolvedTheme {
148
159
  text: darkText,
149
160
  gridline: darkGridline,
150
161
  axis: darkAxis,
151
- annotationFill: overridden(
152
- theme.colors.annotationFill,
153
- light.annotationFill,
154
- 'rgba(255,255,255,0.06)',
155
- ),
156
- annotationText: overridden(theme.colors.annotationText, light.annotationText, darkMuted),
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),
157
168
  categorical,
158
- // Sparkline trend colors tuned for dark surfaces: teal-leaning green
159
- // and coral red read better than the saturated light-mode tokens.
160
- // Any non-default value is treated as a user override and preserved.
161
- positive: theme.colors.positive !== '#16a34a' ? theme.colors.positive : '#34d399',
162
- negative: theme.colors.negative !== '#dc2626' ? theme.colors.negative : '#f87171',
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',
163
179
  },
164
180
  chrome: {
165
- // Eyebrow keeps its accent tint (cyan in both modes); the other
166
- // chrome elements desaturate to a muted gray on the dark canvas.
167
- // Each color only adapts if the spec left it at the light default,
168
- // so explicit chrome color overrides survive dark-mode adaptation.
169
- eyebrow: theme.chrome.eyebrow,
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,
170
190
  title: {
171
191
  ...theme.chrome.title,
172
- color: overridden(theme.chrome.title.color, lightChrome.title.color, darkText),
192
+ color: pairs?.['chrome.title.color']
193
+ ? pairs['chrome.title.color'].dark
194
+ : overridden(theme.chrome.title.color, lightChrome.title.color, darkText),
173
195
  },
174
196
  subtitle: {
175
197
  ...theme.chrome.subtitle,
176
- color: overridden(theme.chrome.subtitle.color, lightChrome.subtitle.color, darkMuted),
198
+ color: pairs?.['chrome.subtitle.color']
199
+ ? pairs['chrome.subtitle.color'].dark
200
+ : overridden(theme.chrome.subtitle.color, lightChrome.subtitle.color, darkMuted),
177
201
  },
178
202
  source: {
179
203
  ...theme.chrome.source,
180
- color: overridden(theme.chrome.source.color, lightChrome.source.color, darkMuted),
204
+ color: pairs?.['chrome.source.color']
205
+ ? pairs['chrome.source.color'].dark
206
+ : overridden(theme.chrome.source.color, lightChrome.source.color, darkMuted),
181
207
  },
182
208
  byline: {
183
209
  ...theme.chrome.byline,
184
- color: overridden(theme.chrome.byline.color, lightChrome.byline.color, darkMuted),
210
+ color: pairs?.['chrome.byline.color']
211
+ ? pairs['chrome.byline.color'].dark
212
+ : overridden(theme.chrome.byline.color, lightChrome.byline.color, darkMuted),
185
213
  },
186
214
  footer: {
187
215
  ...theme.chrome.footer,
188
- color: overridden(theme.chrome.footer.color, lightChrome.footer.color, darkMuted),
216
+ color: pairs?.['chrome.footer.color']
217
+ ? pairs['chrome.footer.color'].dark
218
+ : overridden(theme.chrome.footer.color, lightChrome.footer.color, darkMuted),
189
219
  },
190
220
  },
191
221
  };
@@ -2,7 +2,7 @@
2
2
  * Default theme definition.
3
3
  *
4
4
  * Editorial design system with cyan-led categorical palette, Inter
5
- * Variable typography (weights 400/510/590), and zinc-based achromatic
5
+ * Variable typography (weights 450/550/590/700), and zinc-based achromatic
6
6
  * surfaces. Light is the default surface; dark adaptation lives in
7
7
  * dark-mode.ts.
8
8
  *
@@ -106,4 +106,5 @@ export const DEFAULT_THEME: Theme = {
106
106
  lineHeight: 1.4,
107
107
  },
108
108
  },
109
+ seriesStrategy: 'palette',
109
110
  };
@@ -4,4 +4,5 @@
4
4
 
5
5
  export { adaptColorForDarkMode, adaptForLightLineStroke, adaptTheme } from './dark-mode';
6
6
  export { DEFAULT_THEME } from './defaults';
7
+ export { editorial, essay, wire } from './presets';
7
8
  export { resolveTheme } from './resolve';
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Named theme presets built on the design token system.
3
+ *
4
+ * Each preset is a ThemeConfig object that can be passed to createChart()
5
+ * or the React/Svelte/Vue Chart component. The engine deep-merges them
6
+ * onto DEFAULT_THEME, so only the deltas are specified here.
7
+ */
8
+
9
+ import type { ThemeConfig } from '../types/spec';
10
+
11
+ /** Editorial preset: the current default look. Exported for explicit selection. */
12
+ export const editorial: ThemeConfig = {};
13
+
14
+ /** Essay preset: serif display titles, warmer background, generous spacing. */
15
+ export const essay: ThemeConfig = {
16
+ colors: {
17
+ background: { light: '#faf8f5', dark: '#1a1816' },
18
+ text: { light: '#1c1917', dark: '#e7e5e4' },
19
+ gridline: { light: 'rgba(0,0,0,0.08)', dark: 'rgba(255,255,255,0.06)' },
20
+ axis: { light: '#78716c', dark: '#a8a29e' },
21
+ },
22
+ fonts: {
23
+ family: '"Source Serif 4", "Source Serif Pro", Georgia, "Times New Roman", serif',
24
+ sizes: { title: 28, subtitle: 15, body: 14, small: 12 },
25
+ weights: { normal: 400, medium: 500, semibold: 600, bold: 700 },
26
+ },
27
+ spacing: {
28
+ padding: 24,
29
+ chromeGap: 6,
30
+ chromeToChart: 12,
31
+ chartToFooter: 12,
32
+ },
33
+ chrome: {
34
+ title: { fontWeight: 600, lineHeight: 1.2 },
35
+ subtitle: { fontWeight: 400, lineHeight: 1.5 },
36
+ },
37
+ borderRadius: 0,
38
+ };
39
+
40
+ /** Wire preset: dense, mono numerals, tighter chrome. Dashboard/agency feel. */
41
+ export const wire: ThemeConfig = {
42
+ colors: {
43
+ background: { light: '#ffffff', dark: '#0a0a0a' },
44
+ text: { light: '#171717', dark: '#ededed' },
45
+ gridline: { light: 'rgba(0,0,0,0.06)', dark: 'rgba(255,255,255,0.06)' },
46
+ axis: { light: '#737373', dark: '#a3a3a3' },
47
+ },
48
+ fonts: {
49
+ family: '"JetBrains Mono", "Fira Code", "Cascadia Code", monospace',
50
+ sizes: { title: 16, subtitle: 12, body: 11, small: 10, axisTick: 10 },
51
+ weights: { normal: 400, medium: 500, semibold: 600, bold: 700 },
52
+ },
53
+ spacing: {
54
+ padding: 12,
55
+ chromeGap: 2,
56
+ chromeToChart: 4,
57
+ chartToFooter: 4,
58
+ },
59
+ chrome: {
60
+ title: { fontWeight: 600, lineHeight: 1.25 },
61
+ subtitle: { fontWeight: 400, lineHeight: 1.3 },
62
+ },
63
+ borderRadius: 2,
64
+ };