chartml_core/theme.rs
1//! Chart theme — colors, typography, and shape defaults for chart chrome.
2//!
3//! The `Theme` struct provides all chrome properties used by chart renderers:
4//! colors (axes, grid, text, background), typography (fonts and sizes for
5//! titles, labels, and numeric tick labels), and shape defaults (stroke
6//! weights, dot radii, corner radii, grid styling).
7//!
8//! Plain values are written directly into SVG attributes, ensuring
9//! compatibility with every SVG renderer (browsers, resvg, Inkscape, etc.).
10//!
11//! ## Browser theming
12//!
13//! When charts are rendered in a browser, a `<style>` block inside the SVG
14//! maps element classes to CSS custom properties:
15//!
16//! ```css
17//! .axis-line { stroke: var(--chartml-axis-line) }
18//! .grid-line { stroke: var(--chartml-grid) }
19//! ```
20//!
21//! CSS specificity means these override the inline attribute defaults, so
22//! consuming apps can set `--chartml-axis-line: #9ca3af` on a parent element
23//! and charts respond instantly — no re-render needed.
24//!
25//! ## Server-side rendering
26//!
27//! For server-side rendering (e.g. `render_to_png()`), pass a `Theme` that
28//! matches your application's current appearance. The same `Theme` used
29//! server-side should match the CSS custom properties set browser-side,
30//! ensuring visual parity between both rendering paths.
31//!
32//! ## Example
33//!
34//! ```rust
35//! use chartml_core::theme::Theme;
36//!
37//! // Light mode (default)
38//! let light = Theme::default();
39//!
40//! // Dark mode
41//! let dark = Theme::dark();
42//!
43//! // Custom theme — `Theme` is `#[non_exhaustive]`, so consumers must
44//! // start from `Theme::default()` or `Theme::dark()` and mutate fields.
45//! // This makes adding new theme fields non-breaking forever.
46//! let mut custom = Theme::dark();
47//! custom.axis_line = "#9ca3af".into();
48//! custom.grid = "#374151".into();
49//! ```
50
51/// Grid line style — controls which gridlines are drawn.
52#[derive(Debug, Clone, PartialEq)]
53pub enum GridStyle {
54 /// Draw both horizontal and vertical gridlines (current default behavior).
55 Both,
56 /// Draw only horizontal gridlines.
57 HorizontalOnly,
58 /// Draw only vertical gridlines.
59 VerticalOnly,
60 /// Do not draw gridlines.
61 None,
62}
63
64/// Text transform applied to label text (tick labels, axis labels, legend).
65#[derive(Debug, Clone, PartialEq)]
66pub enum TextTransform {
67 /// No transform — render text as-is.
68 None,
69 /// Transform to uppercase.
70 Uppercase,
71 /// Transform to lowercase.
72 Lowercase,
73}
74
75/// Specification for the zero-line (baseline) overlay on value axes.
76#[derive(Debug, Clone, PartialEq)]
77pub struct ZeroLineSpec {
78 /// Stroke color for the zero line.
79 pub color: String,
80 /// Stroke width in pixels.
81 pub width: f32,
82}
83
84/// Which corners of a bar rect are rounded.
85///
86/// Default is `Uniform(0.0)` — no rounding — preserving the legacy square-bar
87/// behavior. `Uniform(r)` rounds all four corners equally; `Top(r)` rounds
88/// only the two corners at the maximum-value end of the bar (the top of a
89/// vertical bar, the right end of a horizontal bar, and — for bars with
90/// negative values — the end that points away from the zero baseline).
91#[derive(Debug, Clone, PartialEq)]
92pub enum BarCornerRadius {
93 /// Round all four corners uniformly with the given radius.
94 Uniform(f32),
95 /// Round only the "top" corners — the two corners at the maximum-value
96 /// end of the bar. For a vertical bar this is the top of the rect; for a
97 /// horizontal bar this is the right side (the end pointing away from the
98 /// category axis). For bars with negative values, the rounded corners
99 /// flip to the opposite end (the side pointing away from zero).
100 Top(f32),
101}
102
103impl Default for BarCornerRadius {
104 fn default() -> Self {
105 Self::Uniform(0.0)
106 }
107}
108
109/// Chart theme — colors, typography, and shape defaults.
110///
111/// Color fields are CSS color strings (typically hex like `"#374151"`) that
112/// are written directly into SVG `stroke` and `fill` attributes.
113///
114/// ## Line weight audit (Phase 2)
115///
116/// Defaults for the stroke-weight fields were chosen by auditing every
117/// hardcoded `stroke_width: Some(X.0)` across the renderer crates and
118/// categorizing each by role. The audit found:
119///
120/// - **axis_line_weight = 1.0** — universal across
121/// `chartml-chart-cartesian/src/helpers.rs` (lines 419, 684, 756, 866, 957)
122/// and `chartml-chart-scatter/src/lib.rs` (lines 237, 242). All axis lines
123/// currently use 1.0. (Tick marks also use 1.0 today and reuse this field.)
124/// - **grid_line_weight = 1.0** — universal across
125/// `chartml-chart-cartesian/src/helpers.rs` (lines 434, 780, 982) and
126/// `chartml-chart-scatter/src/lib.rs` (lines 168, 211).
127/// - **series_line_weight = 2.0** — majority value in
128/// `chartml-chart-cartesian/src/{line.rs:454, 563, 635}`, `area.rs:{212, 343, 481}`,
129/// and `bar.rs:1258` (combo line).
130/// Outlier: legend line symbols use 2.5 in `bar.rs:1352` and
131/// `chartml-core/src/layout/legend.rs:220` — this is a legend-specific glyph
132/// weight, not a series weight, and is intentionally not folded in.
133/// NOT included: `chartml-chart-pie/src/lib.rs:75` (pie slice border). The
134/// pie slice border uses `theme.bg` as its color — it is a background-colored
135/// separator gap between slices, not a series mark, and must NOT be wired to
136/// `series_line_weight` in later phases.
137/// - **annotation_line_weight = 1.0** — annotations currently read their
138/// stroke width from the spec (`ann.stroke_width` in `helpers.rs:1348, 1382`);
139/// no hardcoded default exists. 1.0 is chosen as the natural fallback for
140/// a future "annotation default" path (reference lines, brackets, etc.).
141/// - **bar_corner_radius = BarCornerRadius::Uniform(0.0)** — default is no
142/// rounding (byte-identical to pre-3.1 behavior). `Uniform(r)` rounds all
143/// four corners; `Top(r)` rounds only the two corners at the max-value end
144/// of the bar (see `BarCornerRadius` docs).
145/// - **dot_radius = 5.0** — matches `chartml-chart-scatter/src/lib.rs:106`
146/// (default when no size field) and line endpoint markers in
147/// `chartml-chart-cartesian/src/line.rs:{466, 577, 649}` and
148/// `bar.rs:1268` (combo line dots).
149///
150/// ## Construction contract
151///
152/// `Theme` is `#[non_exhaustive]`: external crates cannot construct it with
153/// a struct literal *or* the functional-update spread syntax. They must start
154/// from `Theme::default()` (or `Theme::dark()`) and mutate fields:
155///
156/// ```ignore
157/// let mut theme = Theme::default();
158/// theme.axis_line = "#9ca3af".into();
159/// theme.grid = "#374151".into();
160/// ```
161///
162/// This makes adding new fields genuinely non-breaking forever — every
163/// downstream consumer that follows the mutate-after-default pattern keeps
164/// compiling regardless of how many fields land in `Theme` later.
165#[derive(Debug, Clone, PartialEq)]
166#[non_exhaustive]
167pub struct Theme {
168 // ----- Chrome colors -----
169 /// Primary text color (metric values, param controls).
170 pub text: String,
171 /// Secondary text color (tick labels, axis labels, legend labels).
172 pub text_secondary: String,
173 /// Strong/emphasized text color (chart titles).
174 pub text_strong: String,
175 /// Axis line strokes (the main horizontal/vertical axis lines).
176 pub axis_line: String,
177 /// Tick mark strokes (small marks at each tick position).
178 pub tick: String,
179 /// Grid line strokes.
180 pub grid: String,
181 /// Background-aware stroke for element separators
182 /// (pie slice borders, dot outlines). Should match the chart background.
183 pub bg: String,
184
185 // ----- Typography: title -----
186 /// Font family for chart titles.
187 pub title_font_family: String,
188 /// Font size (px) for chart titles.
189 pub title_font_size: f32,
190 /// Font weight for chart titles.
191 pub title_font_weight: u16,
192 /// Font style (`"normal"` / `"italic"`) for chart titles.
193 pub title_font_style: String,
194
195 // ----- Typography: labels (tick labels, axis labels, data labels) -----
196 /// Font family for tick and axis labels.
197 pub label_font_family: String,
198 /// Font size (px) for tick and axis labels.
199 pub label_font_size: f32,
200 /// Font weight for tick and axis labels.
201 pub label_font_weight: u16,
202 /// Extra letter spacing (px) applied to labels.
203 pub label_letter_spacing: f32,
204 /// Text transform applied to labels.
205 pub label_text_transform: TextTransform,
206
207 // ----- Typography: numeric tick labels -----
208 /// Font family for numeric tick labels (e.g. a tabular/monospaced face).
209 pub numeric_font_family: String,
210 /// Font size (px) for numeric tick labels.
211 pub numeric_font_size: f32,
212
213 // ----- Typography: legend -----
214 /// Font family for legend labels.
215 pub legend_font_family: String,
216 /// Font size (px) for legend labels.
217 pub legend_font_size: f32,
218 /// Font weight for legend labels.
219 pub legend_font_weight: u16,
220
221 // ----- Shape / stroke -----
222 /// Stroke width for axis lines. See audit in struct-level doc.
223 pub axis_line_weight: f32,
224 /// Stroke width for grid lines. See audit in struct-level doc.
225 pub grid_line_weight: f32,
226 /// Stroke width for series marks (line paths, area outlines, combo line).
227 /// Pie slice borders are NOT series marks — they use `theme.bg` as a
228 /// background-colored separator. See audit in struct-level doc.
229 pub series_line_weight: f32,
230 /// Stroke width for annotation lines (reference lines, brackets).
231 /// See audit in struct-level doc.
232 pub annotation_line_weight: f32,
233 /// Corner radius for bar rects. Supports uniform rounding (all four
234 /// corners) and top-only rounding (the two corners at the max-value end
235 /// of the bar). When the enclosed radius is `0.0`, renderers MUST NOT
236 /// emit any `rx`/`ry` attribute and MUST emit a plain `<rect>` (to
237 /// preserve byte-identical output for un-themed charts).
238 pub bar_corner_radius: BarCornerRadius,
239 /// Default radius (px) for scatter points and line endpoint markers.
240 pub dot_radius: f32,
241 /// Optional halo/outline color for dots. When `None`, no halo is drawn.
242 pub dot_halo_color: Option<String>,
243 /// Halo stroke width (px). Only used when `dot_halo_color` is `Some`.
244 pub dot_halo_width: f32,
245
246 // ----- Grid + baseline -----
247 /// Which gridlines to draw (both, horizontal-only, vertical-only, none).
248 pub grid_style: GridStyle,
249 /// Optional emphasized zero line on the value axis. `None` = no zero line.
250 pub zero_line: Option<ZeroLineSpec>,
251
252 // ----- Table chart tokens -----
253 /// Background color for the table header row.
254 pub table_header_bg: String,
255 /// Text color for the table header row.
256 pub table_header_text: String,
257 /// Background color for a regular (non-striped) body row.
258 pub table_row_bg: String,
259 /// Background color for the alternating zebra-striped body row.
260 pub table_row_bg_alt: String,
261 /// Border color between cells and between header/body.
262 pub table_border: String,
263 /// Text color for body cells.
264 pub table_text: String,
265 /// CSS shorthand padding for body cells (e.g. `"8px 12px"`).
266 pub table_cell_padding: String,
267 /// CSS font size for table text (e.g. `"13px"`).
268 pub table_font_size: String,
269}
270
271impl Default for Theme {
272 /// Light mode theme — matches the `chartml.css` light-mode custom properties
273 /// and all currently hardcoded renderer defaults.
274 fn default() -> Self {
275 Self {
276 // Chrome colors
277 text: "#374151".into(),
278 text_secondary: "#6b7280".into(),
279 text_strong: "#1f2937".into(),
280 axis_line: "#374151".into(),
281 tick: "#374151".into(),
282 grid: "#e0e0e0".into(),
283 bg: "#ffffff".into(),
284
285 // Title typography
286 title_font_family: "system-ui, sans-serif".into(),
287 title_font_size: 14.0,
288 title_font_weight: 700,
289 title_font_style: "normal".into(),
290
291 // Label typography
292 label_font_family: "system-ui, sans-serif".into(),
293 label_font_size: 12.0,
294 label_font_weight: 400,
295 label_letter_spacing: 0.0,
296 label_text_transform: TextTransform::None,
297
298 // Numeric typography
299 numeric_font_family: "system-ui, sans-serif".into(),
300 numeric_font_size: 12.0,
301
302 // Legend typography
303 legend_font_family: "system-ui, sans-serif".into(),
304 legend_font_size: 12.0,
305 legend_font_weight: 400,
306
307 // Shape / stroke — see struct-level audit
308 axis_line_weight: 1.0,
309 grid_line_weight: 1.0,
310 series_line_weight: 2.0,
311 annotation_line_weight: 1.0,
312 bar_corner_radius: BarCornerRadius::Uniform(0.0),
313 dot_radius: 5.0,
314 dot_halo_color: None,
315 dot_halo_width: 0.0,
316
317 // Grid + baseline
318 grid_style: GridStyle::Both,
319 zero_line: None,
320
321 // Table tokens — light mode defaults
322 table_header_bg: "#f9fafb".into(),
323 table_header_text: "#1f2937".into(),
324 table_row_bg: "#ffffff".into(),
325 table_row_bg_alt: "#f9fafb".into(),
326 table_border: "#e5e7eb".into(),
327 table_text: "#374151".into(),
328 table_cell_padding: "8px 12px".into(),
329 table_font_size: "13px".into(),
330 }
331 }
332}
333
334impl Theme {
335 /// Dark mode theme — matches the `chartml.css` dark-mode custom properties.
336 ///
337 /// Typography and shape defaults are identical to `Theme::default()`;
338 /// chrome color fields and table tokens are overridden to dark values.
339 pub fn dark() -> Self {
340 Self {
341 text: "#e5e7eb".into(),
342 text_secondary: "#9ca3af".into(),
343 text_strong: "#f3f4f6".into(),
344 axis_line: "#9ca3af".into(),
345 tick: "#9ca3af".into(),
346 grid: "#374151".into(),
347 bg: "#1f2937".into(),
348 table_header_bg: "#111827".into(),
349 table_header_text: "#f3f4f6".into(),
350 table_row_bg: "#1f2937".into(),
351 table_row_bg_alt: "#111827".into(),
352 table_border: "#374151".into(),
353 table_text: "#e5e7eb".into(),
354 ..Theme::default()
355 }
356 }
357}
358
359#[cfg(test)]
360mod tests {
361 use super::*;
362
363 #[test]
364 fn default_theme_has_no_css_var_values() {
365 let theme = Theme::default();
366 let all_values = [
367 &theme.text, &theme.text_secondary, &theme.text_strong,
368 &theme.axis_line, &theme.tick, &theme.grid, &theme.bg,
369 ];
370 for value in all_values {
371 assert!(
372 !value.contains("var("),
373 "Theme value should be plain hex, not CSS var(): {value}"
374 );
375 }
376 }
377
378 #[test]
379 fn dark_theme_has_no_css_var_values() {
380 let theme = Theme::dark();
381 let all_values = [
382 &theme.text, &theme.text_secondary, &theme.text_strong,
383 &theme.axis_line, &theme.tick, &theme.grid, &theme.bg,
384 ];
385 for value in all_values {
386 assert!(
387 !value.contains("var("),
388 "Dark theme value should be plain hex, not CSS var(): {value}"
389 );
390 }
391 }
392
393 #[test]
394 fn theme_fields_are_customizable() {
395 let custom = Theme {
396 axis_line: "#ff0000".into(),
397 ..Theme::dark()
398 };
399 assert_eq!(custom.axis_line, "#ff0000");
400 assert_eq!(custom.grid, "#374151"); // rest from dark
401 }
402
403 // ---- Phase 2: new field default tests ----
404
405 #[test]
406 fn default_title_typography() {
407 let t = Theme::default();
408 assert_eq!(t.title_font_family, "system-ui, sans-serif");
409 assert_eq!(t.title_font_size, 14.0);
410 assert_eq!(t.title_font_weight, 700);
411 assert_eq!(t.title_font_style, "normal");
412 }
413
414 #[test]
415 fn default_label_typography() {
416 let t = Theme::default();
417 assert_eq!(t.label_font_family, "system-ui, sans-serif");
418 assert_eq!(t.label_font_size, 12.0);
419 assert_eq!(t.label_font_weight, 400);
420 assert_eq!(t.label_letter_spacing, 0.0);
421 assert_eq!(t.label_text_transform, TextTransform::None);
422 }
423
424 #[test]
425 fn default_numeric_typography() {
426 let t = Theme::default();
427 assert_eq!(t.numeric_font_family, "system-ui, sans-serif");
428 assert_eq!(t.numeric_font_size, 12.0);
429 }
430
431 #[test]
432 fn default_legend_typography() {
433 let t = Theme::default();
434 assert_eq!(t.legend_font_family, "system-ui, sans-serif");
435 assert_eq!(t.legend_font_size, 12.0);
436 assert_eq!(t.legend_font_weight, 400);
437 }
438
439 #[test]
440 fn default_stroke_weights_match_audit() {
441 let t = Theme::default();
442 assert_eq!(t.axis_line_weight, 1.0);
443 assert_eq!(t.grid_line_weight, 1.0);
444 assert_eq!(t.series_line_weight, 2.0);
445 assert_eq!(t.annotation_line_weight, 1.0);
446 }
447
448 #[test]
449 fn default_shape_fields() {
450 let t = Theme::default();
451 assert_eq!(t.bar_corner_radius, BarCornerRadius::Uniform(0.0));
452 assert_eq!(t.dot_radius, 5.0);
453 assert!(t.dot_halo_color.is_none());
454 assert_eq!(t.dot_halo_width, 0.0);
455 }
456
457 #[test]
458 fn default_grid_style_is_both() {
459 assert_eq!(Theme::default().grid_style, GridStyle::Both);
460 }
461
462 #[test]
463 fn default_zero_line_is_none() {
464 assert!(Theme::default().zero_line.is_none());
465 }
466
467 #[test]
468 fn dark_theme_inherits_typography_and_shape_from_default() {
469 let d = Theme::default();
470 let k = Theme::dark();
471 // Typography
472 assert_eq!(d.title_font_size, k.title_font_size);
473 assert_eq!(d.label_font_weight, k.label_font_weight);
474 assert_eq!(d.numeric_font_family, k.numeric_font_family);
475 assert_eq!(d.legend_font_family, k.legend_font_family);
476 // Shape
477 assert_eq!(d.axis_line_weight, k.axis_line_weight);
478 assert_eq!(d.grid_line_weight, k.grid_line_weight);
479 assert_eq!(d.series_line_weight, k.series_line_weight);
480 assert_eq!(d.dot_radius, k.dot_radius);
481 assert_eq!(d.bar_corner_radius, k.bar_corner_radius);
482 // Grid + baseline
483 assert_eq!(d.grid_style, k.grid_style);
484 assert_eq!(d.zero_line, k.zero_line);
485 }
486
487 #[test]
488 fn custom_theme_can_override_new_fields_individually() {
489 let custom = Theme {
490 series_line_weight: 3.5,
491 bar_corner_radius: BarCornerRadius::Uniform(4.0),
492 dot_halo_color: Some("#ffffff".into()),
493 dot_halo_width: 2.0,
494 grid_style: GridStyle::HorizontalOnly,
495 zero_line: Some(ZeroLineSpec { color: "#000000".into(), width: 1.5 }),
496 label_text_transform: TextTransform::Uppercase,
497 ..Theme::default()
498 };
499 // Overridden
500 assert_eq!(custom.series_line_weight, 3.5);
501 assert_eq!(custom.bar_corner_radius, BarCornerRadius::Uniform(4.0));
502 assert_eq!(custom.dot_halo_color.as_deref(), Some("#ffffff"));
503 assert_eq!(custom.dot_halo_width, 2.0);
504 assert_eq!(custom.grid_style, GridStyle::HorizontalOnly);
505 assert_eq!(
506 custom.zero_line,
507 Some(ZeroLineSpec { color: "#000000".into(), width: 1.5 })
508 );
509 assert_eq!(custom.label_text_transform, TextTransform::Uppercase);
510 // Not overridden — should match Default
511 assert_eq!(custom.axis_line_weight, 1.0);
512 assert_eq!(custom.dot_radius, 5.0);
513 assert_eq!(custom.title_font_size, 14.0);
514 assert_eq!(custom.axis_line, "#374151");
515 }
516}