@opendata-ai/openchart-core 8.0.0-rc.2 → 8.0.0-rc.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +490 -25
- package/dist/index.js +57 -19
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/dist/styles.css.map +1 -1
- package/package.json +1 -2
- package/schema/chart.schema.json +485 -35
- package/schema/table.schema.json +35 -1
- package/schema/vizspec.schema.json +1305 -120
- package/src/accessibility/__tests__/alt-text.test.ts +0 -110
- package/src/accessibility/__tests__/aria.test.ts +0 -125
- package/src/accessibility/alt-text.ts +0 -155
- package/src/accessibility/aria.ts +0 -75
- package/src/accessibility/index.ts +0 -6
- package/src/colors/__tests__/colorblind.test.ts +0 -75
- package/src/colors/__tests__/contrast.test.ts +0 -119
- package/src/colors/__tests__/palettes.test.ts +0 -74
- package/src/colors/colorblind.ts +0 -131
- package/src/colors/contrast.ts +0 -143
- package/src/colors/index.ts +0 -36
- package/src/colors/palettes.ts +0 -206
- package/src/helpers/__tests__/deep-merge.test.ts +0 -54
- package/src/helpers/__tests__/spec-builders.test.ts +0 -338
- package/src/helpers/deep-merge.ts +0 -29
- package/src/helpers/spec-builders.ts +0 -479
- package/src/index.ts +0 -215
- package/src/labels/__tests__/collision.test.ts +0 -310
- package/src/labels/collision.ts +0 -249
- package/src/labels/index.ts +0 -13
- package/src/layout/__tests__/chrome.test.ts +0 -208
- package/src/layout/__tests__/text-measure.test.ts +0 -57
- package/src/layout/chrome.ts +0 -470
- package/src/layout/index.ts +0 -18
- package/src/layout/text-measure.ts +0 -404
- package/src/layout/text-wrap.ts +0 -93
- package/src/locale/__tests__/format.test.ts +0 -606
- package/src/locale/format.ts +0 -459
- package/src/locale/index.ts +0 -21
- package/src/responsive/__tests__/breakpoints.test.ts +0 -146
- package/src/responsive/__tests__/metrics.test.ts +0 -52
- package/src/responsive/breakpoints.ts +0 -172
- package/src/responsive/index.ts +0 -53
- package/src/responsive/metrics.ts +0 -299
- package/src/schema/__tests__/generated-artifacts.test.ts +0 -42
- package/src/schema/__tests__/schema.test.ts +0 -179
- package/src/styles/__tests__/reduced-motion-selectors.test.ts +0 -28
- package/src/styles/animation.css +0 -199
- package/src/styles/base.css +0 -102
- package/src/styles/chrome.css +0 -174
- package/src/styles/dark.css +0 -44
- package/src/styles/graph.css +0 -135
- package/src/styles/index.css +0 -28
- package/src/styles/keyframes.css +0 -135
- package/src/styles/legend.css +0 -18
- package/src/styles/reduced-motion.css +0 -67
- package/src/styles/series-search.css +0 -153
- package/src/styles/sparkline.css +0 -16
- package/src/styles/table-animation.css +0 -93
- package/src/styles/table.css +0 -398
- package/src/styles/token-definitions.ts +0 -199
- package/src/styles/tokens.css +0 -156
- package/src/styles/tooltip.css +0 -126
- package/src/styles/you-draw-it.css +0 -126
- package/src/theme/__tests__/dark-mode.test.ts +0 -232
- package/src/theme/__tests__/defaults.test.ts +0 -73
- package/src/theme/__tests__/resolve.test.ts +0 -277
- package/src/theme/dark-mode.ts +0 -258
- package/src/theme/defaults.ts +0 -110
- package/src/theme/index.ts +0 -8
- package/src/theme/presets.ts +0 -64
- package/src/theme/resolve.ts +0 -312
- package/src/types/__tests__/encoding.test.ts +0 -368
- package/src/types/__tests__/gradient.test.ts +0 -95
- package/src/types/__tests__/spec.test.ts +0 -499
- package/src/types/encoding.ts +0 -280
- package/src/types/events.ts +0 -154
- package/src/types/index.ts +0 -287
- package/src/types/layout.ts +0 -1966
- package/src/types/schema-roots.ts +0 -32
- package/src/types/spec.ts +0 -2867
- package/src/types/table.ts +0 -108
- package/src/types/theme.ts +0 -209
package/src/theme/defaults.ts
DELETED
|
@@ -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
|
-
};
|
package/src/theme/index.ts
DELETED
|
@@ -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';
|
package/src/theme/presets.ts
DELETED
|
@@ -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
|
-
};
|
package/src/theme/resolve.ts
DELETED
|
@@ -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
|
-
}
|