@opendata-ai/openchart-core 6.28.5 → 7.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.
@@ -3,18 +3,35 @@
3
3
  * --------------------------------------------------------------------------- */
4
4
 
5
5
  .oc-dark {
6
- --oc-bg: #1a1a2e;
7
- --oc-text: #e0e0e0;
8
- --oc-text-secondary: #b0b0b0;
9
- --oc-text-muted: #808080;
10
- --oc-gridline: #333355;
11
- --oc-axis: #999999;
12
- --oc-border: #444466;
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(30, 30, 50, 0.85);
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: #e0e0e0;
19
- --oc-legend-text: #b0b0b0;
35
+ --oc-tooltip-text: #f7f8f8;
36
+ --oc-legend-text: #d0d6e0;
20
37
  }
@@ -11,7 +11,8 @@
11
11
  .oc-sankey-root,
12
12
  .oc-tilemap-root,
13
13
  .oc-barlist-root {
14
- --oc-font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
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
- --oc-title-size: 22px;
74
- --oc-title-weight: 700;
75
- --oc-title-tracking: -0.02em;
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: 12px;
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-text: #1d1d1d;
83
- --oc-text-secondary: #5c5c5c;
84
- --oc-text-muted: #999999;
85
- --oc-gridline: #e8e8e8;
86
- --oc-axis: #888888;
87
- --oc-border: #e2e2e2;
88
- --oc-border-radius: 4px;
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: #1d1d1d;
95
- --oc-legend-text: #555555;
137
+ --oc-tooltip-text: #09090b;
138
+ --oc-legend-text: #3f3f46;
96
139
  }
@@ -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(12px);
14
+ backdrop-filter: blur(14px);
12
15
  border: 1px solid var(--oc-tooltip-border);
13
- border-radius: 8px;
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: 260px;
20
- min-width: 140px;
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: 6px;
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: 600;
40
- font-size: 12px;
41
- letter-spacing: -0.01em;
42
- color: var(--oc-tooltip-text);
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: 4px 12px 8px;
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: 8px;
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: 12px;
69
- padding: 1px 0;
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-muted);
74
- font-size: 11px;
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: 500;
81
- font-size: 11px;
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 = '#1a1a2e';
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 = '#1b7fa3'; // teal from palette
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('#1a1a2e');
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('adapts categorical palette colors', () => {
99
+ it('preserves categorical palette across modes', () => {
56
100
  const light = resolveTheme();
57
101
  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);
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 22px bold', () => {
19
- expect(DEFAULT_THEME.chrome.title.fontSize).toBe(22);
20
- expect(DEFAULT_THEME.chrome.title.fontWeight).toBe(700);
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 15px normal weight', () => {
24
- expect(DEFAULT_THEME.chrome.subtitle.fontSize).toBe(15);
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 12px normal weight', () => {
29
- expect(DEFAULT_THEME.chrome.source.fontSize).toBe(12);
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('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
- }
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', () => {
@@ -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 = '#1a1a2e';
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 = '#e0e0e0';
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 : '#333344';
105
- const darkAxis = alreadyDark ? theme.colors.axis : '#888899';
106
-
107
- // Only adapt categorical colors when switching from light to dark
108
- const categorical = alreadyDark
109
- ? theme.colors.categorical
110
- : theme.colors.categorical.map((c) => adaptColorForDarkMode(c, inputBg, darkBg));
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.08)',
122
- annotationText: '#bbbbcc',
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: '#aaaaaa' },
128
- source: { ...theme.chrome.source, color: '#888888' },
129
- byline: { ...theme.chrome.byline, color: '#888888' },
130
- footer: { ...theme.chrome.footer, color: '#888888' },
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
+ }