@opendata-ai/openchart-core 8.0.0-rc.8 → 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 +461 -56
  2. package/dist/index.js +62 -24
  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 -172
  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 -208
  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 -154
  77. package/src/types/index.ts +0 -289
  78. package/src/types/layout.ts +0 -2001
  79. package/src/types/schema-roots.ts +0 -32
  80. package/src/types/spec.ts +0 -2906
  81. package/src/types/table.ts +0 -108
  82. package/src/types/theme.ts +0 -209
@@ -1,110 +0,0 @@
1
- /**
2
- * Default theme definition.
3
- *
4
- * Editorial design system with cyan-led categorical palette, Inter
5
- * Variable typography (weights 450/550/590/700), and zinc-based achromatic
6
- * surfaces. Light is the default surface; dark adaptation lives in
7
- * dark-mode.ts.
8
- *
9
- * resolveTheme() deep-merges user overrides onto this base.
10
- */
11
-
12
- import { CATEGORICAL_PALETTE, DIVERGING_PALETTES, SEQUENTIAL_PALETTES } from '../colors/palettes';
13
- import { X_AXIS_BAND_HEIGHT } from '../responsive/metrics';
14
- import type { Theme } from '../types/theme';
15
-
16
- /**
17
- * The default theme. All fields are required and fully specified.
18
- */
19
- export const DEFAULT_THEME: Theme = {
20
- colors: {
21
- categorical: [...CATEGORICAL_PALETTE],
22
- sequential: SEQUENTIAL_PALETTES,
23
- diverging: DIVERGING_PALETTES,
24
- background: 'transparent',
25
- text: '#09090b',
26
- gridline: 'rgba(0,0,0,0.1)',
27
- // Used for axis lines/ticks AND axis tick label fill. Must clear WCAG AA
28
- // contrast (4.5:1) on white because tick labels are rendered with this
29
- // color. Zinc-500 hits ~5.7:1.
30
- axis: '#71717a',
31
- annotationFill: 'rgba(0,0,0,0.04)',
32
- annotationText: '#71717a',
33
- positive: '#16a34a',
34
- negative: '#dc2626',
35
- },
36
- fonts: {
37
- family:
38
- '"Inter Variable", Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
39
- mono: '"JetBrains Mono", "Fira Code", "Cascadia Code", monospace',
40
- sizes: {
41
- title: 26,
42
- subtitle: 14,
43
- body: 13,
44
- small: 11,
45
- axisTick: 11,
46
- metricLabel: 10,
47
- metricValue: 22,
48
- },
49
- weights: {
50
- normal: 450,
51
- medium: 550,
52
- semibold: 590,
53
- bold: 700,
54
- },
55
- },
56
- spacing: {
57
- padding: 20,
58
- chromeGap: 4,
59
- chromeToChart: 8,
60
- chartToFooter: 8,
61
- axisMargin: 6,
62
- xAxisHeight: X_AXIS_BAND_HEIGHT,
63
- // Gap (px) between the x-axis line and the TOP of the tick labels. The
64
- // renderer treats this as a literal top gap and then shifts the label down
65
- // by textAscent() to land it on the alphabetic baseline (rather than
66
- // dominant-baseline:hanging, which WebKit positions from different metrics),
67
- // so the gap holds regardless of font size.
68
- xAxisLabelPadding: 4,
69
- },
70
- borderRadius: 2,
71
- chrome: {
72
- eyebrow: {
73
- fontSize: 11,
74
- fontWeight: 510,
75
- color: '#06b6d4',
76
- lineHeight: 1.4,
77
- },
78
- title: {
79
- fontSize: 26,
80
- fontWeight: 590,
81
- color: '#09090b',
82
- lineHeight: 1.15,
83
- },
84
- subtitle: {
85
- fontSize: 14,
86
- fontWeight: 400,
87
- color: '#71717a',
88
- lineHeight: 1.45,
89
- },
90
- source: {
91
- fontSize: 11,
92
- fontWeight: 400,
93
- color: '#71717a',
94
- lineHeight: 1.4,
95
- },
96
- byline: {
97
- fontSize: 11,
98
- fontWeight: 400,
99
- color: '#71717a',
100
- lineHeight: 1.4,
101
- },
102
- footer: {
103
- fontSize: 11,
104
- fontWeight: 400,
105
- color: '#71717a',
106
- lineHeight: 1.4,
107
- },
108
- },
109
- seriesStrategy: 'palette',
110
- };
@@ -1,8 +0,0 @@
1
- /**
2
- * Theme module barrel export.
3
- */
4
-
5
- export { adaptColorForDarkMode, adaptForLightLineStroke, adaptTheme } from './dark-mode';
6
- export { DEFAULT_THEME } from './defaults';
7
- export { editorial, essay, wire } from './presets';
8
- export { resolveTheme } from './resolve';
@@ -1,64 +0,0 @@
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
- };
@@ -1,312 +0,0 @@
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 { ChromeThemeOverride, ThemeConfig } from '../types/spec';
14
- import type { ChromeDefaults, ResolvedTheme, Theme, TokenValue } 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
- /** Extract the light-mode string from a TokenValue. */
48
- function resolveTokenLight(v: TokenValue): string {
49
- return typeof v === 'string' ? v : v.light;
50
- }
51
-
52
- /**
53
- * Convert a user-facing ThemeConfig (partial) into the full Theme shape
54
- * that deepMerge can work with.
55
- */
56
- function themeConfigToPartial(config: ThemeConfig): Partial<Theme> {
57
- const partial: Partial<Theme> = {};
58
-
59
- if (config.colors) {
60
- const colors: Partial<Theme['colors']> = {};
61
- // Shorthand: flat string[] is treated as categorical palette
62
- if (Array.isArray(config.colors)) {
63
- colors.categorical = config.colors;
64
- } else {
65
- if (config.colors.categorical) colors.categorical = config.colors.categorical;
66
- if (config.colors.sequential) colors.sequential = config.colors.sequential;
67
- if (config.colors.diverging) colors.diverging = config.colors.diverging;
68
- if (config.colors.background) colors.background = resolveTokenLight(config.colors.background);
69
- if (config.colors.text) colors.text = resolveTokenLight(config.colors.text);
70
- if (config.colors.gridline) colors.gridline = resolveTokenLight(config.colors.gridline);
71
- if (config.colors.axis) colors.axis = resolveTokenLight(config.colors.axis);
72
- if (config.colors.annotationFill)
73
- colors.annotationFill = resolveTokenLight(config.colors.annotationFill);
74
- if (config.colors.annotationText)
75
- colors.annotationText = resolveTokenLight(config.colors.annotationText);
76
- if (config.colors.positive) colors.positive = resolveTokenLight(config.colors.positive);
77
- if (config.colors.negative) colors.negative = resolveTokenLight(config.colors.negative);
78
- }
79
- partial.colors = colors as Theme['colors'];
80
- }
81
-
82
- if (config.fonts) {
83
- const fonts: Partial<Theme['fonts']> = {};
84
- if (config.fonts.family) fonts.family = config.fonts.family;
85
- if (config.fonts.mono) fonts.mono = config.fonts.mono;
86
- if (config.fonts.sizes) fonts.sizes = config.fonts.sizes as Theme['fonts']['sizes'];
87
- if (config.fonts.weights) fonts.weights = config.fonts.weights as Theme['fonts']['weights'];
88
- partial.fonts = fonts as Theme['fonts'];
89
- }
90
-
91
- if (config.spacing) {
92
- const spacing: Partial<Theme['spacing']> = {};
93
- if (config.spacing.padding !== undefined) spacing.padding = config.spacing.padding;
94
- if (config.spacing.chromeGap !== undefined) spacing.chromeGap = config.spacing.chromeGap;
95
- if (config.spacing.chromeToChart !== undefined)
96
- spacing.chromeToChart = config.spacing.chromeToChart;
97
- if (config.spacing.chartToFooter !== undefined)
98
- spacing.chartToFooter = config.spacing.chartToFooter;
99
- if (config.spacing.axisMargin !== undefined) spacing.axisMargin = config.spacing.axisMargin;
100
- if (config.spacing.xAxisHeight !== undefined) spacing.xAxisHeight = config.spacing.xAxisHeight;
101
- if (config.spacing.xAxisLabelPadding !== undefined)
102
- spacing.xAxisLabelPadding = config.spacing.xAxisLabelPadding;
103
- partial.spacing = spacing as Theme['spacing'];
104
- }
105
-
106
- if (config.borderRadius !== undefined) {
107
- partial.borderRadius = config.borderRadius;
108
- }
109
-
110
- if (config.chrome) {
111
- const chrome: Record<string, Partial<ChromeDefaults>> = {};
112
- for (const [element, value] of Object.entries(config.chrome)) {
113
- if (value === undefined) continue;
114
- if (typeof value === 'string') {
115
- chrome[element] = { color: value };
116
- } else {
117
- const override: Partial<ChromeDefaults> = {};
118
- const obj = value as ChromeThemeOverride;
119
- if (obj.color !== undefined) override.color = resolveTokenLight(obj.color);
120
- if (obj.fontSize !== undefined) override.fontSize = obj.fontSize;
121
- if (obj.fontWeight !== undefined) override.fontWeight = obj.fontWeight;
122
- if (obj.lineHeight !== undefined) override.lineHeight = obj.lineHeight;
123
- chrome[element] = override;
124
- }
125
- }
126
- partial.chrome = chrome as unknown as Theme['chrome'];
127
- }
128
-
129
- if (config.seriesStrategy) {
130
- partial.seriesStrategy = config.seriesStrategy;
131
- }
132
-
133
- return partial;
134
- }
135
-
136
- /**
137
- * Collect TokenValue pairs from the user's ThemeConfig so adaptTheme()
138
- * can use explicit dark values instead of algorithmic adaptation.
139
- */
140
- function collectTokenPairs(
141
- config: ThemeConfig,
142
- ): Record<string, { light: string; dark: string }> | undefined {
143
- const pairs: Record<string, { light: string; dark: string }> = {};
144
- if (config.colors && !Array.isArray(config.colors)) {
145
- const colorFields = [
146
- 'background',
147
- 'text',
148
- 'gridline',
149
- 'axis',
150
- 'annotationFill',
151
- 'annotationText',
152
- 'positive',
153
- 'negative',
154
- ] as const;
155
- for (const field of colorFields) {
156
- const v = config.colors[field];
157
- if (v && typeof v === 'object' && 'light' in v) {
158
- pairs[`colors.${field}`] = v;
159
- }
160
- }
161
- }
162
- if (config.chrome) {
163
- for (const [element, value] of Object.entries(config.chrome)) {
164
- if (
165
- value &&
166
- typeof value === 'object' &&
167
- 'color' in value &&
168
- value.color &&
169
- typeof value.color === 'object' &&
170
- 'light' in value.color
171
- ) {
172
- pairs[`chrome.${element}.color`] = value.color;
173
- }
174
- }
175
- }
176
- return Object.keys(pairs).length > 0 ? pairs : undefined;
177
- }
178
-
179
- /**
180
- * Parse a hex color to sRGB relative luminance (WCAG 2.1).
181
- * Returns 0 for invalid/unparseable colors.
182
- */
183
- function relativeLuminance(hex: string): number {
184
- const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim());
185
- if (!m) return 0;
186
- const r = parseInt(m[1].slice(0, 2), 16) / 255;
187
- const g = parseInt(m[1].slice(2, 4), 16) / 255;
188
- const b = parseInt(m[1].slice(4, 6), 16) / 255;
189
- const toLinear = (c: number) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
190
- return 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b);
191
- }
192
-
193
- /** Returns true if the hex color is perceptually dark (luminance < 0.2). */
194
- function isDarkBackground(hex: string): boolean {
195
- // Transparent has no intrinsic luminance — don't treat it as dark so
196
- // light-mode chrome text defaults don't get inverted.
197
- if (hex === 'transparent') return false;
198
- return relativeLuminance(hex) < 0.2;
199
- }
200
-
201
- /**
202
- * Adapt chrome text colors for a dark background.
203
- * Only overrides values that still match the light-mode defaults,
204
- * so explicit user overrides are preserved.
205
- */
206
- function adaptChromeForDarkBg(theme: Theme, textColor: string): Theme {
207
- const light = DEFAULT_THEME.chrome;
208
- return {
209
- ...theme,
210
- chrome: {
211
- // Eyebrow keeps its accent tint regardless of surface mode.
212
- eyebrow: theme.chrome.eyebrow,
213
- title: {
214
- ...theme.chrome.title,
215
- color:
216
- theme.chrome.title.color === light.title.color ? textColor : theme.chrome.title.color,
217
- },
218
- subtitle: {
219
- ...theme.chrome.subtitle,
220
- color:
221
- theme.chrome.subtitle.color === light.subtitle.color
222
- ? adjustOpacity(textColor, 0.7)
223
- : theme.chrome.subtitle.color,
224
- },
225
- source: {
226
- ...theme.chrome.source,
227
- color:
228
- theme.chrome.source.color === light.source.color
229
- ? adjustOpacity(textColor, 0.5)
230
- : theme.chrome.source.color,
231
- },
232
- byline: {
233
- ...theme.chrome.byline,
234
- color:
235
- theme.chrome.byline.color === light.byline.color
236
- ? adjustOpacity(textColor, 0.5)
237
- : theme.chrome.byline.color,
238
- },
239
- footer: {
240
- ...theme.chrome.footer,
241
- color:
242
- theme.chrome.footer.color === light.footer.color
243
- ? adjustOpacity(textColor, 0.5)
244
- : theme.chrome.footer.color,
245
- },
246
- },
247
- };
248
- }
249
-
250
- /** Blend a hex color toward white/black by a factor to simulate opacity. */
251
- function adjustOpacity(hex: string, opacity: number): string {
252
- const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim());
253
- if (!m) return hex;
254
- const r = parseInt(m[1].slice(0, 2), 16);
255
- const g = parseInt(m[1].slice(2, 4), 16);
256
- const b = parseInt(m[1].slice(4, 6), 16);
257
- // Blend toward mid-gray for muted secondary text
258
- const mix = (c: number) => Math.round(c * opacity + 128 * (1 - opacity));
259
- const toHex = (n: number) => n.toString(16).padStart(2, '0');
260
- return `#${toHex(mix(r))}${toHex(mix(g))}${toHex(mix(b))}`;
261
- }
262
-
263
- /**
264
- * Resolve a theme by deep-merging user overrides onto a base theme.
265
- *
266
- * Auto-detects dark backgrounds: if the resolved background color is
267
- * perceptually dark and chrome text colors are still the light-mode
268
- * defaults, they're adapted for readability.
269
- *
270
- * @param userTheme - Optional partial theme overrides from the spec.
271
- * @param base - Base theme to merge onto. Defaults to DEFAULT_THEME.
272
- * @returns A ResolvedTheme with all properties guaranteed.
273
- */
274
- export function resolveTheme(userTheme?: ThemeConfig, base: Theme = DEFAULT_THEME): ResolvedTheme {
275
- let merged: Theme = userTheme ? deepMerge(base, themeConfigToPartial(userTheme)) : { ...base };
276
-
277
- // Propagate fonts.sizes overrides onto the chrome element fontSizes.
278
- // The chrome defaults hard-code fontSize independently from fonts.sizes,
279
- // so we sync them here after merging so a single sizes override applies everywhere.
280
- if (userTheme?.fonts?.sizes) {
281
- const s = userTheme.fonts.sizes;
282
- merged = {
283
- ...merged,
284
- chrome: {
285
- ...merged.chrome,
286
- ...(s.title !== undefined && {
287
- title: { ...merged.chrome.title, fontSize: s.title },
288
- }),
289
- ...(s.subtitle !== undefined && {
290
- subtitle: { ...merged.chrome.subtitle, fontSize: s.subtitle },
291
- }),
292
- ...(s.small !== undefined && {
293
- source: { ...merged.chrome.source, fontSize: s.small },
294
- byline: { ...merged.chrome.byline, fontSize: s.small },
295
- footer: { ...merged.chrome.footer, fontSize: s.small },
296
- }),
297
- },
298
- };
299
- }
300
-
301
- // Auto-adapt chrome for dark backgrounds
302
- const dark = isDarkBackground(merged.colors.background);
303
- if (dark) {
304
- merged = adaptChromeForDarkBg(merged, merged.colors.text);
305
- }
306
-
307
- return {
308
- ...merged,
309
- isDark: dark,
310
- _tokenPairs: userTheme ? collectTokenPairs(userTheme) : undefined,
311
- } as ResolvedTheme;
312
- }