@opendata-ai/openchart-core 6.28.6 → 7.0.2
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 +673 -111
- package/dist/index.js +163 -66
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/package.json +1 -1
- package/src/colors/__tests__/contrast.test.ts +2 -2
- package/src/colors/__tests__/palettes.test.ts +22 -2
- package/src/colors/index.ts +1 -0
- package/src/colors/palettes.ts +52 -20
- package/src/helpers/spec-builders.ts +3 -1
- package/src/layout/chrome.ts +91 -10
- package/src/styles/base.css +11 -1
- package/src/styles/chrome.css +127 -2
- package/src/styles/dark.css +27 -10
- package/src/styles/tokens.css +57 -14
- package/src/styles/tooltip.css +66 -22
- package/src/theme/__tests__/dark-mode.test.ts +53 -8
- package/src/theme/__tests__/defaults.test.ts +43 -17
- package/src/theme/dark-mode.ts +76 -16
- package/src/theme/defaults.ts +44 -30
- package/src/theme/index.ts +1 -1
- package/src/theme/resolve.ts +2 -0
- package/src/types/__tests__/spec.test.ts +85 -18
- package/src/types/index.ts +16 -0
- package/src/types/layout.ts +151 -5
- package/src/types/spec.ts +519 -85
- package/src/types/theme.ts +5 -0
package/src/styles/dark.css
CHANGED
|
@@ -3,18 +3,35 @@
|
|
|
3
3
|
* --------------------------------------------------------------------------- */
|
|
4
4
|
|
|
5
5
|
.oc-dark {
|
|
6
|
-
|
|
7
|
-
--oc-
|
|
8
|
-
--oc-
|
|
9
|
-
--oc-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
--oc-
|
|
6
|
+
/* Surfaces (zinc-based achromatic ramp) */
|
|
7
|
+
--oc-bg: #09090b;
|
|
8
|
+
--oc-card: #111113;
|
|
9
|
+
--oc-secondary: #27272a;
|
|
10
|
+
|
|
11
|
+
/* Text levels — see tokens.css for the cross-mode naming rationale */
|
|
12
|
+
--oc-text: #f7f8f8;
|
|
13
|
+
--oc-text-secondary: #d0d6e0;
|
|
14
|
+
--oc-text-muted: #a1a1aa;
|
|
15
|
+
--oc-text-subtle: #71717a;
|
|
16
|
+
--oc-text-faint: #52525b;
|
|
17
|
+
|
|
18
|
+
/* Lines */
|
|
19
|
+
--oc-gridline: rgba(255, 255, 255, 0.05);
|
|
20
|
+
--oc-axis: rgba(255, 255, 255, 0.1);
|
|
21
|
+
--oc-border: rgba(255, 255, 255, 0.1);
|
|
22
|
+
|
|
23
|
+
/* Brand and semantic — accent stays cyan (no darkening on dark bg) */
|
|
24
|
+
--oc-accent: #06b6d4;
|
|
25
|
+
--oc-accent-strong: #06b6d4;
|
|
26
|
+
--oc-positive: #34d399;
|
|
27
|
+
--oc-negative: #fb7185;
|
|
13
28
|
--oc-focus: #60a5fa;
|
|
29
|
+
|
|
30
|
+
/* Interactive states */
|
|
14
31
|
--oc-hover-bg: rgba(255, 255, 255, 0.05);
|
|
15
|
-
--oc-tooltip-bg: rgba(
|
|
32
|
+
--oc-tooltip-bg: rgba(17, 17, 19, 0.92);
|
|
16
33
|
--oc-tooltip-border: rgba(255, 255, 255, 0.08);
|
|
17
34
|
--oc-tooltip-shadow: 0 2px 8px rgba(0, 0, 0, 0.3), 0 0 1px rgba(0, 0, 0, 0.4);
|
|
18
|
-
--oc-tooltip-text: #
|
|
19
|
-
--oc-legend-text: #
|
|
35
|
+
--oc-tooltip-text: #f7f8f8;
|
|
36
|
+
--oc-legend-text: #d0d6e0;
|
|
20
37
|
}
|
package/src/styles/tokens.css
CHANGED
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
.oc-sankey-root,
|
|
12
12
|
.oc-tilemap-root,
|
|
13
13
|
.oc-barlist-root {
|
|
14
|
-
--oc-font-family:
|
|
14
|
+
--oc-font-family:
|
|
15
|
+
"Inter Variable", Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
15
16
|
--oc-font-mono: "JetBrains Mono", "Fira Code", "Cascadia Code", monospace;
|
|
16
17
|
|
|
17
18
|
/* Animation easing presets via CSS linear() */
|
|
@@ -70,27 +71,69 @@
|
|
|
70
71
|
--oc-animation-duration: 500ms;
|
|
71
72
|
--oc-animation-stagger: 80ms;
|
|
72
73
|
--oc-annotation-delay: 200ms;
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
--oc-title-
|
|
74
|
+
|
|
75
|
+
/* Typography scale (editorial design system) */
|
|
76
|
+
--oc-title-size: 26px;
|
|
77
|
+
--oc-title-weight: 590;
|
|
78
|
+
--oc-title-tracking: -0.022em;
|
|
76
79
|
--oc-subtitle-size: 14px;
|
|
77
80
|
--oc-subtitle-weight: 400;
|
|
78
|
-
--oc-source-size:
|
|
81
|
+
--oc-source-size: 11px;
|
|
79
82
|
--oc-source-weight: 400;
|
|
80
83
|
--oc-body-size: 13px;
|
|
84
|
+
--oc-eyebrow-size: 11px;
|
|
85
|
+
--oc-eyebrow-weight: 510;
|
|
86
|
+
--oc-eyebrow-tracking: 0.08em;
|
|
87
|
+
|
|
88
|
+
/* Surfaces (light mode defaults) */
|
|
81
89
|
--oc-bg: #ffffff;
|
|
82
|
-
--oc-
|
|
83
|
-
--oc-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
90
|
+
--oc-card: #ffffff;
|
|
91
|
+
--oc-secondary: #f4f4f5; /* zinc-100, raised surface */
|
|
92
|
+
|
|
93
|
+
/*
|
|
94
|
+
* Text levels. Names invert across modes: in light mode "muted" sits at
|
|
95
|
+
* a lighter zinc step than "subtle" because both are picked relative to
|
|
96
|
+
* the active background, not from a fixed lightness ladder. The intent
|
|
97
|
+
* is "muted = first step away from primary text"; "subtle = next step
|
|
98
|
+
* down"; etc.
|
|
99
|
+
*
|
|
100
|
+
* Light mode: text=zinc-950, secondary=zinc-700, muted=zinc-500,
|
|
101
|
+
* subtle=zinc-400, faint=zinc-300
|
|
102
|
+
* Dark mode (dark.css): inverts the surface tokens but keeps the same
|
|
103
|
+
* muted -> subtle -> faint progression away from primary.
|
|
104
|
+
*/
|
|
105
|
+
--oc-text: #09090b;
|
|
106
|
+
--oc-text-secondary: #3f3f46;
|
|
107
|
+
--oc-text-muted: #71717a;
|
|
108
|
+
--oc-text-subtle: #a1a1aa;
|
|
109
|
+
--oc-text-faint: #d4d4d8;
|
|
110
|
+
|
|
111
|
+
/* Lines */
|
|
112
|
+
--oc-gridline: rgba(0, 0, 0, 0.06);
|
|
113
|
+
--oc-axis: rgba(0, 0, 0, 0.1);
|
|
114
|
+
--oc-border: rgba(0, 0, 0, 0.08);
|
|
115
|
+
--oc-border-radius: 2px;
|
|
116
|
+
|
|
117
|
+
/* Brand and semantic */
|
|
118
|
+
--oc-accent: #06b6d4;
|
|
119
|
+
--oc-accent-strong: #0891b2; /* darker cyan for line strokes on light bg */
|
|
120
|
+
--oc-positive: #10b981;
|
|
121
|
+
--oc-negative: #e11d48;
|
|
89
122
|
--oc-focus: #3b82f6;
|
|
123
|
+
|
|
124
|
+
/* Spacing scale (4px base) */
|
|
125
|
+
--oc-space-1: 4px;
|
|
126
|
+
--oc-space-2: 8px;
|
|
127
|
+
--oc-space-3: 12px;
|
|
128
|
+
--oc-space-4: 16px;
|
|
129
|
+
--oc-space-6: 24px;
|
|
130
|
+
--oc-space-8: 32px;
|
|
131
|
+
|
|
132
|
+
/* Interactive states */
|
|
90
133
|
--oc-hover-bg: rgba(0, 0, 0, 0.025);
|
|
91
134
|
--oc-tooltip-bg: rgba(255, 255, 255, 0.88);
|
|
92
135
|
--oc-tooltip-border: rgba(0, 0, 0, 0.08);
|
|
93
136
|
--oc-tooltip-shadow: 0 2px 8px rgba(0, 0, 0, 0.08), 0 0 1px rgba(0, 0, 0, 0.12);
|
|
94
|
-
--oc-tooltip-text: #
|
|
95
|
-
--oc-legend-text: #
|
|
137
|
+
--oc-tooltip-text: #09090b;
|
|
138
|
+
--oc-legend-text: #3f3f46;
|
|
96
139
|
}
|
package/src/styles/tooltip.css
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
/* ---------------------------------------------------------------------------
|
|
2
2
|
* Tooltip
|
|
3
|
+
* Editorial card matching the new design system: zinc surface, cyan accent,
|
|
4
|
+
* Inter Variable, mono numerics. Sized for a slice tooltip that lists every
|
|
5
|
+
* series at the snapped x.
|
|
3
6
|
* --------------------------------------------------------------------------- */
|
|
4
7
|
|
|
5
8
|
.oc-tooltip {
|
|
@@ -8,23 +11,26 @@
|
|
|
8
11
|
pointer-events: none;
|
|
9
12
|
z-index: 1000;
|
|
10
13
|
background: var(--oc-tooltip-bg);
|
|
11
|
-
backdrop-filter: blur(
|
|
14
|
+
backdrop-filter: blur(14px);
|
|
12
15
|
border: 1px solid var(--oc-tooltip-border);
|
|
13
|
-
border-radius:
|
|
16
|
+
border-radius: var(--oc-border-radius, 4px);
|
|
14
17
|
box-shadow: var(--oc-tooltip-shadow);
|
|
15
18
|
color: var(--oc-tooltip-text);
|
|
16
19
|
font-family: var(--oc-font-family);
|
|
17
20
|
font-size: 12px;
|
|
18
21
|
padding: 0;
|
|
19
|
-
max-width:
|
|
20
|
-
min-width:
|
|
22
|
+
max-width: 280px;
|
|
23
|
+
min-width: 160px;
|
|
21
24
|
line-height: 1.4;
|
|
22
25
|
animation: oc-tooltip-in 120ms ease-out;
|
|
26
|
+
transition:
|
|
27
|
+
left 100ms ease-in-out,
|
|
28
|
+
top 100ms ease-in-out;
|
|
23
29
|
|
|
24
30
|
& .oc-tooltip-header {
|
|
25
31
|
display: flex;
|
|
26
32
|
align-items: center;
|
|
27
|
-
gap:
|
|
33
|
+
gap: 8px;
|
|
28
34
|
padding: 8px 12px 6px;
|
|
29
35
|
}
|
|
30
36
|
|
|
@@ -36,53 +42,91 @@
|
|
|
36
42
|
}
|
|
37
43
|
|
|
38
44
|
& .oc-tooltip-title {
|
|
39
|
-
font-weight:
|
|
40
|
-
font-size:
|
|
41
|
-
letter-spacing:
|
|
42
|
-
|
|
45
|
+
font-weight: 590;
|
|
46
|
+
font-size: 11px;
|
|
47
|
+
letter-spacing: 0.04em;
|
|
48
|
+
text-transform: uppercase;
|
|
49
|
+
color: var(--oc-text-muted);
|
|
43
50
|
white-space: nowrap;
|
|
44
51
|
overflow: hidden;
|
|
45
52
|
text-overflow: ellipsis;
|
|
46
53
|
}
|
|
47
54
|
|
|
48
55
|
& .oc-tooltip-body {
|
|
49
|
-
padding:
|
|
56
|
+
padding: 6px 12px 10px;
|
|
50
57
|
border-top: 1px solid var(--oc-tooltip-border);
|
|
51
58
|
}
|
|
52
59
|
|
|
53
|
-
/* Only add separator when header is present */
|
|
54
|
-
& .oc-tooltip-header + .oc-tooltip-body {
|
|
55
|
-
padding-top: 6px;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
60
|
/* No separator when body is the only child */
|
|
59
61
|
& .oc-tooltip-body:first-child {
|
|
60
62
|
border-top: none;
|
|
61
|
-
padding-top:
|
|
63
|
+
padding-top: 10px;
|
|
62
64
|
}
|
|
63
65
|
|
|
64
66
|
& .oc-tooltip-row {
|
|
65
67
|
display: flex;
|
|
66
68
|
align-items: baseline;
|
|
67
69
|
justify-content: space-between;
|
|
68
|
-
gap:
|
|
69
|
-
padding:
|
|
70
|
+
gap: 16px;
|
|
71
|
+
padding: 2px 0;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
& .oc-tooltip-row-swatch {
|
|
75
|
+
display: inline-block;
|
|
76
|
+
width: 8px;
|
|
77
|
+
height: 8px;
|
|
78
|
+
border-radius: 50%;
|
|
79
|
+
margin-right: 6px;
|
|
80
|
+
flex-shrink: 0;
|
|
81
|
+
transform: translateY(-1px);
|
|
70
82
|
}
|
|
71
83
|
|
|
72
84
|
& .oc-tooltip-label {
|
|
73
|
-
color: var(--oc-text-
|
|
74
|
-
font-size:
|
|
85
|
+
color: var(--oc-text-secondary);
|
|
86
|
+
font-size: 12px;
|
|
87
|
+
font-weight: 400;
|
|
75
88
|
white-space: nowrap;
|
|
76
89
|
flex-shrink: 0;
|
|
90
|
+
display: inline-flex;
|
|
91
|
+
align-items: center;
|
|
77
92
|
}
|
|
78
93
|
|
|
79
94
|
& .oc-tooltip-value {
|
|
80
|
-
font-weight:
|
|
81
|
-
font-size:
|
|
95
|
+
font-weight: 510;
|
|
96
|
+
font-size: 12px;
|
|
82
97
|
font-variant-numeric: tabular-nums;
|
|
98
|
+
color: var(--oc-tooltip-text);
|
|
83
99
|
text-align: right;
|
|
84
100
|
overflow: hidden;
|
|
85
101
|
text-overflow: ellipsis;
|
|
86
102
|
white-space: nowrap;
|
|
87
103
|
}
|
|
88
104
|
}
|
|
105
|
+
|
|
106
|
+
/* ---------------------------------------------------------------------------
|
|
107
|
+
* Crosshair guideline + per-series snap dots (line/area charts)
|
|
108
|
+
* Animate the snap between adjacent points so the indicator slides instead
|
|
109
|
+
* of jumping. ~50ms ease-in-out keeps the motion crisp without lagging.
|
|
110
|
+
* --------------------------------------------------------------------------- */
|
|
111
|
+
|
|
112
|
+
.oc-crosshair {
|
|
113
|
+
pointer-events: none;
|
|
114
|
+
transition:
|
|
115
|
+
x1 50ms ease-in-out,
|
|
116
|
+
x2 50ms ease-in-out;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
.oc-snap-dots circle {
|
|
120
|
+
pointer-events: none;
|
|
121
|
+
transition:
|
|
122
|
+
cx 50ms ease-in-out,
|
|
123
|
+
cy 50ms ease-in-out;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
@media (prefers-reduced-motion: reduce) {
|
|
127
|
+
.oc-tooltip,
|
|
128
|
+
.oc-crosshair,
|
|
129
|
+
.oc-snap-dots circle {
|
|
130
|
+
transition: none;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
@@ -1,14 +1,15 @@
|
|
|
1
|
+
import { hsl } from 'd3-color';
|
|
1
2
|
import { describe, expect, it } from 'vitest';
|
|
2
3
|
import { contrastRatio } from '../../colors/contrast';
|
|
3
|
-
import { adaptColorForDarkMode, adaptTheme } from '../dark-mode';
|
|
4
|
+
import { adaptColorForDarkMode, adaptForLightLineStroke, adaptTheme } from '../dark-mode';
|
|
4
5
|
import { resolveTheme } from '../resolve';
|
|
5
6
|
|
|
6
7
|
describe('adaptColorForDarkMode', () => {
|
|
7
8
|
const lightBg = '#ffffff';
|
|
8
|
-
const darkBg = '#
|
|
9
|
+
const darkBg = '#09090b';
|
|
9
10
|
|
|
10
11
|
it('adapted color has similar contrast on dark bg as original on light bg', () => {
|
|
11
|
-
const original = '#
|
|
12
|
+
const original = '#06b6d4'; // cyan, primary accent
|
|
12
13
|
const adapted = adaptColorForDarkMode(original, lightBg, darkBg);
|
|
13
14
|
|
|
14
15
|
const originalRatio = contrastRatio(original, lightBg);
|
|
@@ -19,6 +20,11 @@ describe('adaptColorForDarkMode', () => {
|
|
|
19
20
|
expect(Math.abs(adaptedRatio - originalRatio)).toBeLessThan(tolerance);
|
|
20
21
|
});
|
|
21
22
|
|
|
23
|
+
it('returns unchanged input for unparseable colors (e.g. raw oklch)', () => {
|
|
24
|
+
const result = adaptColorForDarkMode('oklch(70% 0.15 200)', lightBg, darkBg);
|
|
25
|
+
expect(result).toBe('oklch(70% 0.15 200)');
|
|
26
|
+
});
|
|
27
|
+
|
|
22
28
|
it('returns a valid hex color', () => {
|
|
23
29
|
const result = adaptColorForDarkMode('#e15759', lightBg, darkBg);
|
|
24
30
|
expect(result).toMatch(/^#[0-9a-f]{6}$/i);
|
|
@@ -30,6 +36,44 @@ describe('adaptColorForDarkMode', () => {
|
|
|
30
36
|
});
|
|
31
37
|
});
|
|
32
38
|
|
|
39
|
+
describe('adaptForLightLineStroke', () => {
|
|
40
|
+
it('darkens a saturated cyan by ~12% lightness', () => {
|
|
41
|
+
const original = '#06b6d4';
|
|
42
|
+
const darkened = adaptForLightLineStroke(original);
|
|
43
|
+
expect(darkened).not.toBe(original);
|
|
44
|
+
const c = hsl(darkened);
|
|
45
|
+
const o = hsl(original);
|
|
46
|
+
expect(c).not.toBeNull();
|
|
47
|
+
expect(c!.l).toBeCloseTo(o.l - 0.12, 2);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('passes saturated red and blue through with reduced lightness', () => {
|
|
51
|
+
for (const color of ['#ef4444', '#3b82f6']) {
|
|
52
|
+
const out = adaptForLightLineStroke(color);
|
|
53
|
+
const before = hsl(color);
|
|
54
|
+
const after = hsl(out);
|
|
55
|
+
expect(after!.l).toBeLessThan(before.l);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('passes pure gray through unchanged (saturation below threshold)', () => {
|
|
60
|
+
// zinc-400 has near-zero saturation; reducing lightness on a gray would
|
|
61
|
+
// shift it toward black, which isn't desired for achromatic palettes.
|
|
62
|
+
expect(adaptForLightLineStroke('#a1a1aa')).toBe('#a1a1aa');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('passes already-dark colors through unchanged (l <= 0.4)', () => {
|
|
66
|
+
// Indigo-900 sits at l ≈ 0.30 — already meets contrast on white.
|
|
67
|
+
const dark = '#312e81';
|
|
68
|
+
expect(adaptForLightLineStroke(dark)).toBe(dark);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('passes invalid input through unchanged', () => {
|
|
72
|
+
expect(adaptForLightLineStroke('not-a-color')).toBe('not-a-color');
|
|
73
|
+
expect(adaptForLightLineStroke('')).toBe('');
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
33
77
|
describe('adaptTheme', () => {
|
|
34
78
|
it('sets isDark to true', () => {
|
|
35
79
|
const light = resolveTheme();
|
|
@@ -40,7 +84,7 @@ describe('adaptTheme', () => {
|
|
|
40
84
|
it('swaps to dark background', () => {
|
|
41
85
|
const light = resolveTheme();
|
|
42
86
|
const dark = adaptTheme(light);
|
|
43
|
-
expect(dark.colors.background).toBe('#
|
|
87
|
+
expect(dark.colors.background).toBe('#09090b');
|
|
44
88
|
});
|
|
45
89
|
|
|
46
90
|
it('updates text color for dark mode', () => {
|
|
@@ -52,12 +96,13 @@ describe('adaptTheme', () => {
|
|
|
52
96
|
expect(ratio).toBeGreaterThan(4);
|
|
53
97
|
});
|
|
54
98
|
|
|
55
|
-
it('
|
|
99
|
+
it('preserves categorical palette across modes', () => {
|
|
56
100
|
const light = resolveTheme();
|
|
57
101
|
const dark = adaptTheme(light);
|
|
58
|
-
//
|
|
59
|
-
|
|
60
|
-
|
|
102
|
+
// Design-system tokens are mode-agnostic: the same vibrant cyan-led
|
|
103
|
+
// palette renders in both modes. Contrast-equivalence adaptation
|
|
104
|
+
// dulls cyan into teal, which is not what the spec calls for.
|
|
105
|
+
expect(dark.colors.categorical).toEqual(light.colors.categorical);
|
|
61
106
|
});
|
|
62
107
|
|
|
63
108
|
it('updates chrome text colors', () => {
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
2
|
import { contrastRatio } from '../../colors/contrast';
|
|
3
|
+
import { ACHROMATIC_RAMP } from '../../colors/palettes';
|
|
4
|
+
import { adaptColorForDarkMode } from '../dark-mode';
|
|
3
5
|
import { DEFAULT_THEME } from '../defaults';
|
|
4
6
|
|
|
5
7
|
describe('DEFAULT_THEME', () => {
|
|
@@ -11,33 +13,57 @@ describe('DEFAULT_THEME', () => {
|
|
|
11
13
|
expect(DEFAULT_THEME.chrome).toBeDefined();
|
|
12
14
|
});
|
|
13
15
|
|
|
14
|
-
it('uses Inter as primary font', () => {
|
|
15
|
-
expect(DEFAULT_THEME.fonts.family).toContain('Inter');
|
|
16
|
+
it('uses Inter Variable as primary font', () => {
|
|
17
|
+
expect(DEFAULT_THEME.fonts.family).toContain('Inter Variable');
|
|
16
18
|
});
|
|
17
19
|
|
|
18
|
-
it('title is
|
|
19
|
-
expect(DEFAULT_THEME.chrome.title.fontSize).toBe(
|
|
20
|
-
expect(DEFAULT_THEME.chrome.title.fontWeight).toBe(
|
|
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);
|
|
21
23
|
});
|
|
22
24
|
|
|
23
|
-
it('subtitle is
|
|
24
|
-
expect(DEFAULT_THEME.chrome.subtitle.fontSize).toBe(
|
|
25
|
+
it('subtitle is 14px normal weight', () => {
|
|
26
|
+
expect(DEFAULT_THEME.chrome.subtitle.fontSize).toBe(14);
|
|
25
27
|
expect(DEFAULT_THEME.chrome.subtitle.fontWeight).toBe(400);
|
|
26
28
|
});
|
|
27
29
|
|
|
28
|
-
it('source is
|
|
29
|
-
expect(DEFAULT_THEME.chrome.source.fontSize).toBe(
|
|
30
|
+
it('source is 11px normal weight', () => {
|
|
31
|
+
expect(DEFAULT_THEME.chrome.source.fontSize).toBe(11);
|
|
30
32
|
expect(DEFAULT_THEME.chrome.source.fontWeight).toBe(400);
|
|
31
33
|
});
|
|
32
34
|
|
|
33
|
-
it('
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
35
|
+
it('borderRadius is 2px (square aesthetic)', () => {
|
|
36
|
+
expect(DEFAULT_THEME.borderRadius).toBe(2);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('font weights include 510 (medium) and 590 (demi)', () => {
|
|
40
|
+
expect(DEFAULT_THEME.fonts.weights.medium).toBe(510);
|
|
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);
|
|
41
67
|
});
|
|
42
68
|
|
|
43
69
|
it('has sequential and diverging palette entries', () => {
|
package/src/theme/dark-mode.ts
CHANGED
|
@@ -7,16 +7,17 @@
|
|
|
7
7
|
|
|
8
8
|
import { hsl, rgb } from 'd3-color';
|
|
9
9
|
import { contrastRatio } from '../colors/contrast';
|
|
10
|
+
import { ACHROMATIC_RAMP } from '../colors/palettes';
|
|
10
11
|
import type { ResolvedTheme } from '../types/theme';
|
|
11
12
|
|
|
12
13
|
// ---------------------------------------------------------------------------
|
|
13
14
|
// Dark mode background
|
|
14
15
|
// ---------------------------------------------------------------------------
|
|
15
16
|
|
|
16
|
-
/** Default dark mode background color. */
|
|
17
|
-
const DARK_BG =
|
|
17
|
+
/** Default dark mode background color (zinc-based canvas). */
|
|
18
|
+
const DARK_BG = ACHROMATIC_RAMP.bg;
|
|
18
19
|
/** Default dark mode text color. */
|
|
19
|
-
const DARK_TEXT =
|
|
20
|
+
const DARK_TEXT = ACHROMATIC_RAMP.fg;
|
|
20
21
|
|
|
21
22
|
// ---------------------------------------------------------------------------
|
|
22
23
|
// Color adaptation
|
|
@@ -30,6 +31,17 @@ const DARK_TEXT = '#e0e0e0';
|
|
|
30
31
|
* had against lightBg.
|
|
31
32
|
*/
|
|
32
33
|
export function adaptColorForDarkMode(color: string, lightBg: string, darkBg: string): string {
|
|
34
|
+
// Adapter only handles hex/rgb-style inputs. Raw oklch() and other
|
|
35
|
+
// CSS Color 4 strings parse unreliably through d3-color in happy-dom,
|
|
36
|
+
// so guard early instead of silently falling back to a default.
|
|
37
|
+
if (rgb(color) == null) {
|
|
38
|
+
if (typeof console !== 'undefined' && console.warn) {
|
|
39
|
+
console.warn(
|
|
40
|
+
`[openchart] adaptColorForDarkMode: unparseable color "${color}", returning unchanged. Use precomputed sRGB hex.`,
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
return color;
|
|
44
|
+
}
|
|
33
45
|
const originalRatio = contrastRatio(color, lightBg);
|
|
34
46
|
const c = hsl(color);
|
|
35
47
|
if (c == null || Number.isNaN(c.h)) {
|
|
@@ -101,13 +113,17 @@ export function adaptTheme(theme: ResolvedTheme): ResolvedTheme {
|
|
|
101
113
|
// instead of overwriting with library defaults.
|
|
102
114
|
const darkBg = alreadyDark ? inputBg : DARK_BG;
|
|
103
115
|
const darkText = alreadyDark ? theme.colors.text : DARK_TEXT;
|
|
104
|
-
const darkGridline = alreadyDark ? theme.colors.gridline : '
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
|
|
116
|
+
const darkGridline = alreadyDark ? theme.colors.gridline : 'rgba(255,255,255,0.05)';
|
|
117
|
+
// axis is also tick-label fill — needs WCAG AA contrast on dark bg.
|
|
118
|
+
// Zinc-400 (`#a1a1aa`) hits ~6:1 against #09090b.
|
|
119
|
+
const darkAxis = alreadyDark ? theme.colors.axis : '#a1a1aa';
|
|
120
|
+
const darkMuted = ACHROMATIC_RAMP.fgMuted;
|
|
121
|
+
|
|
122
|
+
// Categorical palette is pinned to design-system tokens. The same vibrant
|
|
123
|
+
// hex values render in both light and dark modes — adapting them via
|
|
124
|
+
// contrast-equivalence dulls them on dark backgrounds (cyan -> teal),
|
|
125
|
+
// which is the opposite of what the design system calls for.
|
|
126
|
+
const categorical = theme.colors.categorical;
|
|
111
127
|
|
|
112
128
|
return {
|
|
113
129
|
...theme,
|
|
@@ -118,16 +134,60 @@ export function adaptTheme(theme: ResolvedTheme): ResolvedTheme {
|
|
|
118
134
|
text: darkText,
|
|
119
135
|
gridline: darkGridline,
|
|
120
136
|
axis: darkAxis,
|
|
121
|
-
annotationFill: 'rgba(255,255,255,0.
|
|
122
|
-
annotationText:
|
|
137
|
+
annotationFill: 'rgba(255,255,255,0.06)',
|
|
138
|
+
annotationText: darkMuted,
|
|
123
139
|
categorical,
|
|
140
|
+
// Sparkline trend colors tuned for dark surfaces: teal-leaning green
|
|
141
|
+
// and coral red read better than the saturated light-mode tokens.
|
|
142
|
+
// Any non-default value is treated as a user override and preserved.
|
|
143
|
+
positive: theme.colors.positive !== '#16a34a' ? theme.colors.positive : '#34d399',
|
|
144
|
+
negative: theme.colors.negative !== '#dc2626' ? theme.colors.negative : '#f87171',
|
|
124
145
|
},
|
|
125
146
|
chrome: {
|
|
147
|
+
// Eyebrow keeps its accent tint (cyan in both modes); the other
|
|
148
|
+
// chrome elements desaturate to a muted gray on the dark canvas.
|
|
149
|
+
eyebrow: theme.chrome.eyebrow,
|
|
126
150
|
title: { ...theme.chrome.title, color: darkText },
|
|
127
|
-
subtitle: { ...theme.chrome.subtitle, color:
|
|
128
|
-
source: { ...theme.chrome.source, color:
|
|
129
|
-
byline: { ...theme.chrome.byline, color:
|
|
130
|
-
footer: { ...theme.chrome.footer, color:
|
|
151
|
+
subtitle: { ...theme.chrome.subtitle, color: darkMuted },
|
|
152
|
+
source: { ...theme.chrome.source, color: darkMuted },
|
|
153
|
+
byline: { ...theme.chrome.byline, color: darkMuted },
|
|
154
|
+
footer: { ...theme.chrome.footer, color: darkMuted },
|
|
131
155
|
},
|
|
132
156
|
};
|
|
133
157
|
}
|
|
158
|
+
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
// Light mode line stroke darkening
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Returns a darker variant of a color for use as a foreground stroke on
|
|
165
|
+
* light backgrounds, where mid-lightness palette colors (e.g. cyan-500
|
|
166
|
+
* `#06b6d4`) lack contrast against white. Drops HSL lightness by ~12%
|
|
167
|
+
* absolute (clamped to >= 0) while preserving hue and saturation, which
|
|
168
|
+
* reproduces the cyan-500 → cyan-600 step the cyan accent originally
|
|
169
|
+
* needed and works for any other palette accent the user picks.
|
|
170
|
+
*
|
|
171
|
+
* Returns the input unchanged when:
|
|
172
|
+
* - the color is already dark enough (L <= ~0.40) — further darkening
|
|
173
|
+
* just muddies the stroke without adding contrast
|
|
174
|
+
* - the color isn't parseable
|
|
175
|
+
* - the color is achromatic (NaN hue) — HSL lightness on grays drifts
|
|
176
|
+
* toward black instead of staying neutral
|
|
177
|
+
*
|
|
178
|
+
* Exposed via the `--oc-accent-strong` CSS token.
|
|
179
|
+
*/
|
|
180
|
+
export function adaptForLightLineStroke(color: string): string {
|
|
181
|
+
if (rgb(color) == null) return color;
|
|
182
|
+
const c = hsl(color);
|
|
183
|
+
if (c == null) return color;
|
|
184
|
+
// Achromatic check: NaN hue OR low saturation. d3-color reports a valid
|
|
185
|
+
// hue for grays whose RGB channels aren't perfectly equal (e.g. zinc
|
|
186
|
+
// `#a1a1aa` has s≈0.05), so saturation is the more reliable signal.
|
|
187
|
+
// Threshold 0.10 catches near-grays without tripping on real palette
|
|
188
|
+
// colors (palette saturation is ≥ ~0.5).
|
|
189
|
+
if (Number.isNaN(c.h) || c.s < 0.1) return color;
|
|
190
|
+
if (c.l <= 0.4) return color;
|
|
191
|
+
c.l = Math.max(0, c.l - 0.12);
|
|
192
|
+
return c.formatHex();
|
|
193
|
+
}
|