@opendata-ai/openchart-core 2.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 (51) hide show
  1. package/README.md +130 -0
  2. package/dist/index.d.ts +2030 -0
  3. package/dist/index.js +1176 -0
  4. package/dist/index.js.map +1 -0
  5. package/dist/styles.css +757 -0
  6. package/package.json +61 -0
  7. package/src/accessibility/__tests__/alt-text.test.ts +110 -0
  8. package/src/accessibility/__tests__/aria.test.ts +125 -0
  9. package/src/accessibility/alt-text.ts +120 -0
  10. package/src/accessibility/aria.ts +73 -0
  11. package/src/accessibility/index.ts +6 -0
  12. package/src/colors/__tests__/colorblind.test.ts +63 -0
  13. package/src/colors/__tests__/contrast.test.ts +71 -0
  14. package/src/colors/__tests__/palettes.test.ts +54 -0
  15. package/src/colors/colorblind.ts +122 -0
  16. package/src/colors/contrast.ts +94 -0
  17. package/src/colors/index.ts +27 -0
  18. package/src/colors/palettes.ts +118 -0
  19. package/src/helpers/__tests__/spec-builders.test.ts +336 -0
  20. package/src/helpers/spec-builders.ts +410 -0
  21. package/src/index.ts +129 -0
  22. package/src/labels/__tests__/collision.test.ts +197 -0
  23. package/src/labels/collision.ts +154 -0
  24. package/src/labels/index.ts +6 -0
  25. package/src/layout/__tests__/chrome.test.ts +114 -0
  26. package/src/layout/__tests__/text-measure.test.ts +49 -0
  27. package/src/layout/chrome.ts +223 -0
  28. package/src/layout/index.ts +6 -0
  29. package/src/layout/text-measure.ts +54 -0
  30. package/src/locale/__tests__/format.test.ts +90 -0
  31. package/src/locale/format.ts +132 -0
  32. package/src/locale/index.ts +6 -0
  33. package/src/responsive/__tests__/breakpoints.test.ts +58 -0
  34. package/src/responsive/breakpoints.ts +92 -0
  35. package/src/responsive/index.ts +18 -0
  36. package/src/styles/viz.css +757 -0
  37. package/src/theme/__tests__/dark-mode.test.ts +68 -0
  38. package/src/theme/__tests__/defaults.test.ts +47 -0
  39. package/src/theme/__tests__/resolve.test.ts +61 -0
  40. package/src/theme/dark-mode.ts +123 -0
  41. package/src/theme/defaults.ts +85 -0
  42. package/src/theme/index.ts +7 -0
  43. package/src/theme/resolve.ts +190 -0
  44. package/src/types/__tests__/spec.test.ts +387 -0
  45. package/src/types/encoding.ts +144 -0
  46. package/src/types/events.ts +96 -0
  47. package/src/types/index.ts +141 -0
  48. package/src/types/layout.ts +794 -0
  49. package/src/types/spec.ts +563 -0
  50. package/src/types/table.ts +105 -0
  51. package/src/types/theme.ts +159 -0
@@ -0,0 +1,68 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { contrastRatio } from '../../colors/contrast';
3
+ import { adaptColorForDarkMode, adaptTheme } from '../dark-mode';
4
+ import { resolveTheme } from '../resolve';
5
+
6
+ describe('adaptColorForDarkMode', () => {
7
+ const lightBg = '#ffffff';
8
+ const darkBg = '#1a1a2e';
9
+
10
+ it('adapted color has similar contrast on dark bg as original on light bg', () => {
11
+ const original = '#1b7fa3'; // teal from palette
12
+ const adapted = adaptColorForDarkMode(original, lightBg, darkBg);
13
+
14
+ const originalRatio = contrastRatio(original, lightBg);
15
+ const adaptedRatio = contrastRatio(adapted, darkBg);
16
+
17
+ // Should be within 30% of the original ratio
18
+ const tolerance = originalRatio * 0.3;
19
+ expect(Math.abs(adaptedRatio - originalRatio)).toBeLessThan(tolerance);
20
+ });
21
+
22
+ it('returns a valid hex color', () => {
23
+ const result = adaptColorForDarkMode('#e15759', lightBg, darkBg);
24
+ expect(result).toMatch(/^#[0-9a-f]{6}$/i);
25
+ });
26
+
27
+ it('handles pure black gracefully', () => {
28
+ const result = adaptColorForDarkMode('#000000', lightBg, darkBg);
29
+ expect(result).toMatch(/^#[0-9a-f]{6}$/i);
30
+ });
31
+ });
32
+
33
+ describe('adaptTheme', () => {
34
+ it('sets isDark to true', () => {
35
+ const light = resolveTheme();
36
+ const dark = adaptTheme(light);
37
+ expect(dark.isDark).toBe(true);
38
+ });
39
+
40
+ it('swaps to dark background', () => {
41
+ const light = resolveTheme();
42
+ const dark = adaptTheme(light);
43
+ expect(dark.colors.background).toBe('#1a1a2e');
44
+ });
45
+
46
+ it('updates text color for dark mode', () => {
47
+ const light = resolveTheme();
48
+ const dark = adaptTheme(light);
49
+ expect(dark.colors.text).not.toBe(light.colors.text);
50
+ // Dark text should be light
51
+ const ratio = contrastRatio(dark.colors.text, dark.colors.background);
52
+ expect(ratio).toBeGreaterThan(4);
53
+ });
54
+
55
+ it('adapts categorical palette colors', () => {
56
+ const light = resolveTheme();
57
+ const dark = adaptTheme(light);
58
+ // Colors should be different (adjusted for dark bg)
59
+ expect(dark.colors.categorical).not.toEqual(light.colors.categorical);
60
+ expect(dark.colors.categorical).toHaveLength(light.colors.categorical.length);
61
+ });
62
+
63
+ it('updates chrome text colors', () => {
64
+ const light = resolveTheme();
65
+ const dark = adaptTheme(light);
66
+ expect(dark.chrome.title.color).not.toBe(light.chrome.title.color);
67
+ });
68
+ });
@@ -0,0 +1,47 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { contrastRatio } from '../../colors/contrast';
3
+ import { DEFAULT_THEME } from '../defaults';
4
+
5
+ describe('DEFAULT_THEME', () => {
6
+ it('has all required top-level fields', () => {
7
+ expect(DEFAULT_THEME.colors).toBeDefined();
8
+ expect(DEFAULT_THEME.fonts).toBeDefined();
9
+ expect(DEFAULT_THEME.spacing).toBeDefined();
10
+ expect(DEFAULT_THEME.borderRadius).toBeDefined();
11
+ expect(DEFAULT_THEME.chrome).toBeDefined();
12
+ });
13
+
14
+ it('uses Inter as primary font', () => {
15
+ expect(DEFAULT_THEME.fonts.family).toContain('Inter');
16
+ });
17
+
18
+ it('title is 22px bold', () => {
19
+ expect(DEFAULT_THEME.chrome.title.fontSize).toBe(22);
20
+ expect(DEFAULT_THEME.chrome.title.fontWeight).toBe(700);
21
+ });
22
+
23
+ it('subtitle is 15px normal weight', () => {
24
+ expect(DEFAULT_THEME.chrome.subtitle.fontSize).toBe(15);
25
+ expect(DEFAULT_THEME.chrome.subtitle.fontWeight).toBe(400);
26
+ });
27
+
28
+ it('source is 12px normal weight', () => {
29
+ expect(DEFAULT_THEME.chrome.source.fontSize).toBe(12);
30
+ expect(DEFAULT_THEME.chrome.source.fontWeight).toBe(400);
31
+ });
32
+
33
+ it('categorical palette has sufficient contrast on white background', () => {
34
+ const bg = DEFAULT_THEME.colors.background;
35
+ for (const color of DEFAULT_THEME.colors.categorical) {
36
+ const ratio = contrastRatio(color, bg);
37
+ // AA for large text is 3:1. Some editorial palette colors may not
38
+ // hit 4.5:1 on pure white, but they should all clear 3:1.
39
+ expect(ratio).toBeGreaterThanOrEqual(3);
40
+ }
41
+ });
42
+
43
+ it('has sequential and diverging palette entries', () => {
44
+ expect(Object.keys(DEFAULT_THEME.colors.sequential).length).toBeGreaterThan(0);
45
+ expect(Object.keys(DEFAULT_THEME.colors.diverging).length).toBeGreaterThan(0);
46
+ });
47
+ });
@@ -0,0 +1,61 @@
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 categorical palette completely', () => {
46
+ const custom = ['#ff0000', '#00ff00', '#0000ff'];
47
+ const resolved = resolveTheme({
48
+ colors: { categorical: custom },
49
+ });
50
+ expect(resolved.colors.categorical).toEqual(custom);
51
+ });
52
+
53
+ it('accepts a custom base theme', () => {
54
+ const customBase = {
55
+ ...DEFAULT_THEME,
56
+ borderRadius: 12,
57
+ };
58
+ const resolved = resolveTheme(undefined, customBase);
59
+ expect(resolved.borderRadius).toBe(12);
60
+ });
61
+ });
@@ -0,0 +1,123 @@
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 type { ResolvedTheme } from '../types/theme';
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // Dark mode background
14
+ // ---------------------------------------------------------------------------
15
+
16
+ /** Default dark mode background color. */
17
+ const DARK_BG = '#1a1a2e';
18
+ /** Default dark mode text color. */
19
+ const DARK_TEXT = '#e0e0e0';
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // Color adaptation
23
+ // ---------------------------------------------------------------------------
24
+
25
+ /**
26
+ * Adapt a single color for dark mode.
27
+ *
28
+ * Preserves the hue and adjusts lightness/saturation so the adapted
29
+ * color has the same contrast ratio against darkBg as the original
30
+ * had against lightBg.
31
+ */
32
+ export function adaptColorForDarkMode(color: string, lightBg: string, darkBg: string): string {
33
+ const originalRatio = contrastRatio(color, lightBg);
34
+ const c = hsl(color);
35
+ if (c == null || Number.isNaN(c.h)) {
36
+ // Achromatic or invalid color: just adjust lightness
37
+ const r = rgb(color);
38
+ if (r == null) return color;
39
+ const darkBgLum = _luminanceFromHex(darkBg);
40
+ const isLight = darkBgLum < 0.5;
41
+ if (isLight) return color;
42
+ // Invert the lightness
43
+ const inverted = hsl(color);
44
+ if (inverted == null) return color;
45
+ inverted.l = 1 - inverted.l;
46
+ return inverted.formatHex();
47
+ }
48
+
49
+ // Binary search for lightness that gives equivalent contrast on dark bg
50
+ let lo = 0.0;
51
+ let hi = 1.0;
52
+ let bestColor = color;
53
+ let bestDiff = Infinity;
54
+
55
+ for (let i = 0; i < 20; i++) {
56
+ const mid = (lo + hi) / 2;
57
+ const candidate = hsl(c.h, c.s, mid);
58
+ const hex = candidate.formatHex();
59
+ const ratio = contrastRatio(hex, darkBg);
60
+ const diff = Math.abs(ratio - originalRatio);
61
+
62
+ if (diff < bestDiff) {
63
+ bestDiff = diff;
64
+ bestColor = hex;
65
+ }
66
+
67
+ if (ratio < originalRatio) {
68
+ // Need more contrast = more lightness on dark bg
69
+ lo = mid;
70
+ } else {
71
+ hi = mid;
72
+ }
73
+ }
74
+
75
+ return bestColor;
76
+ }
77
+
78
+ /** Quick luminance estimation from a hex color. */
79
+ function _luminanceFromHex(color: string): number {
80
+ const c = rgb(color);
81
+ if (c == null) return 0;
82
+ return (0.2126 * c.r + 0.7152 * c.g + 0.0722 * c.b) / 255;
83
+ }
84
+
85
+ // ---------------------------------------------------------------------------
86
+ // Full theme adaptation
87
+ // ---------------------------------------------------------------------------
88
+
89
+ /**
90
+ * Adapt an entire resolved theme for dark mode.
91
+ *
92
+ * Swaps background/text, adapts categorical and annotation colors,
93
+ * adjusts gridline and axis colors for the dark background.
94
+ */
95
+ export function adaptTheme(theme: ResolvedTheme): ResolvedTheme {
96
+ const lightBg = theme.colors.background;
97
+ const darkBg = DARK_BG;
98
+
99
+ return {
100
+ ...theme,
101
+ isDark: true,
102
+ colors: {
103
+ ...theme.colors,
104
+ background: darkBg,
105
+ text: DARK_TEXT,
106
+ gridline: '#333344',
107
+ axis: '#888899',
108
+ annotationFill: 'rgba(255,255,255,0.08)',
109
+ annotationText: '#bbbbcc',
110
+ categorical: theme.colors.categorical.map((c) => adaptColorForDarkMode(c, lightBg, darkBg)),
111
+ // Sequential and diverging palettes are kept as-is since they're
112
+ // typically used for fills where the lightness range still works.
113
+ // If a specific use case needs adaptation, it can be done per-color.
114
+ },
115
+ chrome: {
116
+ title: { ...theme.chrome.title, color: DARK_TEXT },
117
+ subtitle: { ...theme.chrome.subtitle, color: '#aaaaaa' },
118
+ source: { ...theme.chrome.source, color: '#888888' },
119
+ byline: { ...theme.chrome.byline, color: '#888888' },
120
+ footer: { ...theme.chrome.footer, color: '#888888' },
121
+ },
122
+ };
123
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Default theme definition.
3
+ *
4
+ * Tuned to match Infrographic's visual weight and editorial style.
5
+ * Inter font family, typography hierarchy for chrome, and color
6
+ * palettes from the colors module.
7
+ */
8
+
9
+ import { CATEGORICAL_PALETTE, DIVERGING_PALETTES, SEQUENTIAL_PALETTES } from '../colors/palettes';
10
+ import type { Theme } from '../types/theme';
11
+
12
+ /**
13
+ * The default theme. All fields are required and fully specified.
14
+ * resolveTheme() deep-merges user overrides onto this base.
15
+ */
16
+ export const DEFAULT_THEME: Theme = {
17
+ colors: {
18
+ categorical: [...CATEGORICAL_PALETTE],
19
+ sequential: SEQUENTIAL_PALETTES,
20
+ diverging: DIVERGING_PALETTES,
21
+ background: '#ffffff',
22
+ text: '#1d1d1d',
23
+ gridline: '#e8e8e8',
24
+ axis: '#888888',
25
+ annotationFill: 'rgba(0,0,0,0.04)',
26
+ annotationText: '#555555',
27
+ },
28
+ fonts: {
29
+ family: 'Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
30
+ mono: '"JetBrains Mono", "Fira Code", "Cascadia Code", monospace',
31
+ sizes: {
32
+ title: 22,
33
+ subtitle: 15,
34
+ body: 13,
35
+ small: 11,
36
+ axisTick: 11,
37
+ },
38
+ weights: {
39
+ normal: 400,
40
+ medium: 500,
41
+ semibold: 600,
42
+ bold: 700,
43
+ },
44
+ },
45
+ spacing: {
46
+ padding: 12,
47
+ chromeGap: 4,
48
+ chromeToChart: 8,
49
+ chartToFooter: 8,
50
+ axisMargin: 6,
51
+ },
52
+ borderRadius: 4,
53
+ chrome: {
54
+ title: {
55
+ fontSize: 22,
56
+ fontWeight: 700,
57
+ color: '#333333',
58
+ lineHeight: 1.3,
59
+ },
60
+ subtitle: {
61
+ fontSize: 15,
62
+ fontWeight: 400,
63
+ color: '#666666',
64
+ lineHeight: 1.4,
65
+ },
66
+ source: {
67
+ fontSize: 12,
68
+ fontWeight: 400,
69
+ color: '#999999',
70
+ lineHeight: 1.3,
71
+ },
72
+ byline: {
73
+ fontSize: 12,
74
+ fontWeight: 400,
75
+ color: '#999999',
76
+ lineHeight: 1.3,
77
+ },
78
+ footer: {
79
+ fontSize: 12,
80
+ fontWeight: 400,
81
+ color: '#999999',
82
+ lineHeight: 1.3,
83
+ },
84
+ },
85
+ };
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Theme module barrel export.
3
+ */
4
+
5
+ export { adaptColorForDarkMode, adaptTheme } from './dark-mode';
6
+ export { DEFAULT_THEME } from './defaults';
7
+ export { resolveTheme } from './resolve';
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Theme resolver: deep-merges user overrides onto default theme.
3
+ *
4
+ * Produces a ResolvedTheme where every property is guaranteed to have
5
+ * a value. The engine uses ResolvedTheme internally so it never needs
6
+ * null checks on theme properties.
7
+ *
8
+ * Auto-detects dark backgrounds and adapts chrome text colors so
9
+ * custom themes with dark canvases are readable without explicitly
10
+ * enabling darkMode.
11
+ */
12
+
13
+ import type { ThemeConfig } from '../types/spec';
14
+ import type { ResolvedTheme, Theme } from '../types/theme';
15
+ import { DEFAULT_THEME } from './defaults';
16
+
17
+ /**
18
+ * Deep merge source into target, creating a new object.
19
+ * Only merges plain objects; arrays and primitives replace directly.
20
+ */
21
+ // biome-ignore lint/suspicious/noExplicitAny: recursive object merge requires dynamic typing
22
+ function deepMerge(target: any, source: any): any {
23
+ const result = { ...target };
24
+
25
+ for (const key of Object.keys(source)) {
26
+ const sourceVal = source[key];
27
+ const targetVal = target[key];
28
+
29
+ if (
30
+ sourceVal !== undefined &&
31
+ sourceVal !== null &&
32
+ typeof sourceVal === 'object' &&
33
+ !Array.isArray(sourceVal) &&
34
+ typeof targetVal === 'object' &&
35
+ targetVal !== null &&
36
+ !Array.isArray(targetVal)
37
+ ) {
38
+ result[key] = deepMerge(targetVal, sourceVal);
39
+ } else if (sourceVal !== undefined) {
40
+ result[key] = sourceVal;
41
+ }
42
+ }
43
+
44
+ return result;
45
+ }
46
+
47
+ /**
48
+ * Convert a user-facing ThemeConfig (partial) into the full Theme shape
49
+ * that deepMerge can work with.
50
+ */
51
+ function themeConfigToPartial(config: ThemeConfig): Partial<Theme> {
52
+ const partial: Partial<Theme> = {};
53
+
54
+ if (config.colors) {
55
+ const colors: Partial<Theme['colors']> = {};
56
+ if (config.colors.categorical) colors.categorical = config.colors.categorical;
57
+ if (config.colors.sequential) colors.sequential = config.colors.sequential;
58
+ if (config.colors.diverging) colors.diverging = config.colors.diverging;
59
+ if (config.colors.background) colors.background = config.colors.background;
60
+ if (config.colors.text) colors.text = config.colors.text;
61
+ if (config.colors.gridline) colors.gridline = config.colors.gridline;
62
+ if (config.colors.axis) colors.axis = config.colors.axis;
63
+ partial.colors = colors as Theme['colors'];
64
+ }
65
+
66
+ if (config.fonts) {
67
+ const fonts: Partial<Theme['fonts']> = {};
68
+ if (config.fonts.family) fonts.family = config.fonts.family;
69
+ if (config.fonts.mono) fonts.mono = config.fonts.mono;
70
+ partial.fonts = fonts as Theme['fonts'];
71
+ }
72
+
73
+ if (config.spacing) {
74
+ const spacing: Partial<Theme['spacing']> = {};
75
+ if (config.spacing.padding !== undefined) spacing.padding = config.spacing.padding;
76
+ if (config.spacing.chromeGap !== undefined) spacing.chromeGap = config.spacing.chromeGap;
77
+ partial.spacing = spacing as Theme['spacing'];
78
+ }
79
+
80
+ if (config.borderRadius !== undefined) {
81
+ partial.borderRadius = config.borderRadius;
82
+ }
83
+
84
+ return partial;
85
+ }
86
+
87
+ /**
88
+ * Parse a hex color to sRGB relative luminance (WCAG 2.1).
89
+ * Returns 0 for invalid/unparseable colors.
90
+ */
91
+ function relativeLuminance(hex: string): number {
92
+ const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim());
93
+ if (!m) return 0;
94
+ const r = parseInt(m[1].slice(0, 2), 16) / 255;
95
+ const g = parseInt(m[1].slice(2, 4), 16) / 255;
96
+ const b = parseInt(m[1].slice(4, 6), 16) / 255;
97
+ const toLinear = (c: number) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
98
+ return 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b);
99
+ }
100
+
101
+ /** Returns true if the hex color is perceptually dark (luminance < 0.2). */
102
+ function isDarkBackground(hex: string): boolean {
103
+ return relativeLuminance(hex) < 0.2;
104
+ }
105
+
106
+ /**
107
+ * Adapt chrome text colors for a dark background.
108
+ * Only overrides values that still match the light-mode defaults,
109
+ * so explicit user overrides are preserved.
110
+ */
111
+ function adaptChromeForDarkBg(theme: Theme, textColor: string): Theme {
112
+ const light = DEFAULT_THEME.chrome;
113
+ return {
114
+ ...theme,
115
+ chrome: {
116
+ title: {
117
+ ...theme.chrome.title,
118
+ color:
119
+ theme.chrome.title.color === light.title.color ? textColor : theme.chrome.title.color,
120
+ },
121
+ subtitle: {
122
+ ...theme.chrome.subtitle,
123
+ color:
124
+ theme.chrome.subtitle.color === light.subtitle.color
125
+ ? adjustOpacity(textColor, 0.7)
126
+ : theme.chrome.subtitle.color,
127
+ },
128
+ source: {
129
+ ...theme.chrome.source,
130
+ color:
131
+ theme.chrome.source.color === light.source.color
132
+ ? adjustOpacity(textColor, 0.5)
133
+ : theme.chrome.source.color,
134
+ },
135
+ byline: {
136
+ ...theme.chrome.byline,
137
+ color:
138
+ theme.chrome.byline.color === light.byline.color
139
+ ? adjustOpacity(textColor, 0.5)
140
+ : theme.chrome.byline.color,
141
+ },
142
+ footer: {
143
+ ...theme.chrome.footer,
144
+ color:
145
+ theme.chrome.footer.color === light.footer.color
146
+ ? adjustOpacity(textColor, 0.5)
147
+ : theme.chrome.footer.color,
148
+ },
149
+ },
150
+ };
151
+ }
152
+
153
+ /** Blend a hex color toward white/black by a factor to simulate opacity. */
154
+ function adjustOpacity(hex: string, opacity: number): string {
155
+ const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim());
156
+ if (!m) return hex;
157
+ const r = parseInt(m[1].slice(0, 2), 16);
158
+ const g = parseInt(m[1].slice(2, 4), 16);
159
+ const b = parseInt(m[1].slice(4, 6), 16);
160
+ // Blend toward mid-gray for muted secondary text
161
+ const mix = (c: number) => Math.round(c * opacity + 128 * (1 - opacity));
162
+ const toHex = (n: number) => n.toString(16).padStart(2, '0');
163
+ return `#${toHex(mix(r))}${toHex(mix(g))}${toHex(mix(b))}`;
164
+ }
165
+
166
+ /**
167
+ * Resolve a theme by deep-merging user overrides onto a base theme.
168
+ *
169
+ * Auto-detects dark backgrounds: if the resolved background color is
170
+ * perceptually dark and chrome text colors are still the light-mode
171
+ * defaults, they're adapted for readability.
172
+ *
173
+ * @param userTheme - Optional partial theme overrides from the spec.
174
+ * @param base - Base theme to merge onto. Defaults to DEFAULT_THEME.
175
+ * @returns A ResolvedTheme with all properties guaranteed.
176
+ */
177
+ export function resolveTheme(userTheme?: ThemeConfig, base: Theme = DEFAULT_THEME): ResolvedTheme {
178
+ let merged: Theme = userTheme ? deepMerge(base, themeConfigToPartial(userTheme)) : { ...base };
179
+
180
+ // Auto-adapt chrome for dark backgrounds
181
+ const dark = isDarkBackground(merged.colors.background);
182
+ if (dark) {
183
+ merged = adaptChromeForDarkBg(merged, merged.colors.text);
184
+ }
185
+
186
+ return {
187
+ ...merged,
188
+ isDark: dark,
189
+ } as ResolvedTheme;
190
+ }