@opendata-ai/openchart-core 8.0.0-rc.9 → 8.0.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.
Files changed (82) hide show
  1. package/dist/index.d.ts +363 -43
  2. package/dist/index.js +39 -18
  3. package/dist/index.js.map +1 -1
  4. package/dist/styles.css +1 -1
  5. package/dist/styles.css.map +1 -1
  6. package/package.json +1 -2
  7. package/schema/chart.schema.json +373 -67
  8. package/schema/table.schema.json +35 -1
  9. package/schema/vizspec.schema.json +950 -152
  10. package/src/accessibility/__tests__/alt-text.test.ts +0 -110
  11. package/src/accessibility/__tests__/aria.test.ts +0 -125
  12. package/src/accessibility/alt-text.ts +0 -155
  13. package/src/accessibility/aria.ts +0 -75
  14. package/src/accessibility/index.ts +0 -6
  15. package/src/colors/__tests__/colorblind.test.ts +0 -75
  16. package/src/colors/__tests__/contrast.test.ts +0 -119
  17. package/src/colors/__tests__/palettes.test.ts +0 -74
  18. package/src/colors/colorblind.ts +0 -131
  19. package/src/colors/contrast.ts +0 -143
  20. package/src/colors/index.ts +0 -36
  21. package/src/colors/palettes.ts +0 -206
  22. package/src/helpers/__tests__/deep-merge.test.ts +0 -54
  23. package/src/helpers/__tests__/spec-builders.test.ts +0 -338
  24. package/src/helpers/deep-merge.ts +0 -29
  25. package/src/helpers/spec-builders.ts +0 -479
  26. package/src/index.ts +0 -215
  27. package/src/labels/__tests__/collision.test.ts +0 -310
  28. package/src/labels/collision.ts +0 -249
  29. package/src/labels/index.ts +0 -13
  30. package/src/layout/__tests__/chrome.test.ts +0 -208
  31. package/src/layout/__tests__/text-measure.test.ts +0 -57
  32. package/src/layout/chrome.ts +0 -470
  33. package/src/layout/index.ts +0 -18
  34. package/src/layout/text-measure.ts +0 -404
  35. package/src/layout/text-wrap.ts +0 -93
  36. package/src/locale/__tests__/format.test.ts +0 -606
  37. package/src/locale/format.ts +0 -459
  38. package/src/locale/index.ts +0 -21
  39. package/src/responsive/__tests__/breakpoints.test.ts +0 -146
  40. package/src/responsive/__tests__/metrics.test.ts +0 -52
  41. package/src/responsive/breakpoints.ts +0 -177
  42. package/src/responsive/index.ts +0 -53
  43. package/src/responsive/metrics.ts +0 -299
  44. package/src/schema/__tests__/generated-artifacts.test.ts +0 -42
  45. package/src/schema/__tests__/schema.test.ts +0 -179
  46. package/src/styles/__tests__/reduced-motion-selectors.test.ts +0 -28
  47. package/src/styles/animation.css +0 -213
  48. package/src/styles/base.css +0 -102
  49. package/src/styles/chrome.css +0 -174
  50. package/src/styles/dark.css +0 -44
  51. package/src/styles/graph.css +0 -135
  52. package/src/styles/index.css +0 -28
  53. package/src/styles/keyframes.css +0 -151
  54. package/src/styles/legend.css +0 -18
  55. package/src/styles/reduced-motion.css +0 -67
  56. package/src/styles/series-search.css +0 -153
  57. package/src/styles/sparkline.css +0 -16
  58. package/src/styles/table-animation.css +0 -93
  59. package/src/styles/table.css +0 -398
  60. package/src/styles/token-definitions.ts +0 -199
  61. package/src/styles/tokens.css +0 -156
  62. package/src/styles/tooltip.css +0 -126
  63. package/src/styles/you-draw-it.css +0 -126
  64. package/src/theme/__tests__/dark-mode.test.ts +0 -232
  65. package/src/theme/__tests__/defaults.test.ts +0 -73
  66. package/src/theme/__tests__/resolve.test.ts +0 -277
  67. package/src/theme/dark-mode.ts +0 -258
  68. package/src/theme/defaults.ts +0 -110
  69. package/src/theme/index.ts +0 -8
  70. package/src/theme/presets.ts +0 -64
  71. package/src/theme/resolve.ts +0 -312
  72. package/src/types/__tests__/encoding.test.ts +0 -368
  73. package/src/types/__tests__/gradient.test.ts +0 -95
  74. package/src/types/__tests__/spec.test.ts +0 -499
  75. package/src/types/encoding.ts +0 -280
  76. package/src/types/events.ts +0 -236
  77. package/src/types/index.ts +0 -290
  78. package/src/types/layout.ts +0 -2024
  79. package/src/types/schema-roots.ts +0 -32
  80. package/src/types/spec.ts +0 -2925
  81. package/src/types/table.ts +0 -108
  82. 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
- });
@@ -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
- }