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 /// CSS font-weight for header cells (e.g. `"600"`, `"500"`).
258 pub table_header_font_weight: String,
259 /// CSS letter-spacing for header cells (e.g. `"normal"`, `"0.08em"`).
260 pub table_header_letter_spacing: String,
261 /// CSS text-transform for header cells (e.g. `"none"`, `"uppercase"`).
262 pub table_header_text_transform: String,
263 /// Border color for header cell bottom edge. Empty string falls back
264 /// to `table_border`.
265 pub table_header_border: String,
266 /// Background color for a regular (non-striped) body row.
267 pub table_row_bg: String,
268 /// Background color for the alternating zebra-striped body row.
269 pub table_row_bg_alt: String,
270 /// Border color between cells and between header/body.
271 pub table_border: String,
272 /// Text color for body cells.
273 pub table_text: String,
274 /// CSS shorthand padding for body cells (e.g. `"8px 12px"`).
275 pub table_cell_padding: String,
276 /// CSS font size for table text (e.g. `"13px"`).
277 pub table_font_size: String,
278 /// CSS border-radius for the table root container (e.g. `"4px"`, `"0"`).
279 pub table_border_radius: String,
280}
281
282impl Default for Theme {
283 /// Light mode theme — matches the `chartml.css` light-mode custom properties
284 /// and all currently hardcoded renderer defaults.
285 fn default() -> Self {
286 Self {
287 // Chrome colors
288 text: "#374151".into(),
289 text_secondary: "#6b7280".into(),
290 text_strong: "#1f2937".into(),
291 axis_line: "#374151".into(),
292 tick: "#374151".into(),
293 grid: "#e0e0e0".into(),
294 bg: "#ffffff".into(),
295
296 // Title typography
297 title_font_family: "system-ui, sans-serif".into(),
298 title_font_size: 14.0,
299 title_font_weight: 700,
300 title_font_style: "normal".into(),
301
302 // Label typography
303 label_font_family: "system-ui, sans-serif".into(),
304 label_font_size: 12.0,
305 label_font_weight: 400,
306 label_letter_spacing: 0.0,
307 label_text_transform: TextTransform::None,
308
309 // Numeric typography
310 numeric_font_family: "system-ui, sans-serif".into(),
311 numeric_font_size: 12.0,
312
313 // Legend typography
314 legend_font_family: "system-ui, sans-serif".into(),
315 legend_font_size: 12.0,
316 legend_font_weight: 400,
317
318 // Shape / stroke — see struct-level audit
319 axis_line_weight: 1.0,
320 grid_line_weight: 1.0,
321 series_line_weight: 2.0,
322 annotation_line_weight: 1.0,
323 bar_corner_radius: BarCornerRadius::Uniform(0.0),
324 dot_radius: 5.0,
325 dot_halo_color: None,
326 dot_halo_width: 0.0,
327
328 // Grid + baseline
329 grid_style: GridStyle::Both,
330 zero_line: None,
331
332 // Table tokens — light mode defaults
333 table_header_bg: "#f9fafb".into(),
334 table_header_text: "#1f2937".into(),
335 table_header_font_weight: "600".into(),
336 table_header_letter_spacing: "normal".into(),
337 table_header_text_transform: "none".into(),
338 table_header_border: String::new(),
339 table_row_bg: "#ffffff".into(),
340 table_row_bg_alt: "#f9fafb".into(),
341 table_border: "#e5e7eb".into(),
342 table_text: "#374151".into(),
343 table_cell_padding: "8px 12px".into(),
344 table_font_size: "13px".into(),
345 table_border_radius: "4px".into(),
346 }
347 }
348}
349
350impl Theme {
351 /// Dark mode theme — matches the `chartml.css` dark-mode custom properties.
352 ///
353 /// Typography and shape defaults are identical to `Theme::default()`;
354 /// chrome color fields and table tokens are overridden to dark values.
355 pub fn dark() -> Self {
356 Self {
357 text: "#e5e7eb".into(),
358 text_secondary: "#9ca3af".into(),
359 text_strong: "#f3f4f6".into(),
360 axis_line: "#9ca3af".into(),
361 tick: "#9ca3af".into(),
362 grid: "#374151".into(),
363 bg: "#1f2937".into(),
364 table_header_bg: "#111827".into(),
365 table_header_text: "#f3f4f6".into(),
366 table_row_bg: "#1f2937".into(),
367 table_row_bg_alt: "#111827".into(),
368 table_border: "#374151".into(),
369 table_text: "#e5e7eb".into(),
370 ..Theme::default()
371 }
372 }
373}
374
375#[cfg(test)]
376mod tests {
377 #![allow(clippy::unwrap_used)]
378 use super::*;
379
380 #[test]
381 fn default_theme_has_no_css_var_values() {
382 let theme = Theme::default();
383 let all_values = [
384 &theme.text, &theme.text_secondary, &theme.text_strong,
385 &theme.axis_line, &theme.tick, &theme.grid, &theme.bg,
386 ];
387 for value in all_values {
388 assert!(
389 !value.contains("var("),
390 "Theme value should be plain hex, not CSS var(): {value}"
391 );
392 }
393 }
394
395 #[test]
396 fn dark_theme_has_no_css_var_values() {
397 let theme = Theme::dark();
398 let all_values = [
399 &theme.text, &theme.text_secondary, &theme.text_strong,
400 &theme.axis_line, &theme.tick, &theme.grid, &theme.bg,
401 ];
402 for value in all_values {
403 assert!(
404 !value.contains("var("),
405 "Dark theme value should be plain hex, not CSS var(): {value}"
406 );
407 }
408 }
409
410 #[test]
411 fn theme_fields_are_customizable() {
412 let custom = Theme {
413 axis_line: "#ff0000".into(),
414 ..Theme::dark()
415 };
416 assert_eq!(custom.axis_line, "#ff0000");
417 assert_eq!(custom.grid, "#374151"); // rest from dark
418 }
419
420 // ---- Phase 2: new field default tests ----
421
422 #[test]
423 fn default_title_typography() {
424 let t = Theme::default();
425 assert_eq!(t.title_font_family, "system-ui, sans-serif");
426 assert_eq!(t.title_font_size, 14.0);
427 assert_eq!(t.title_font_weight, 700);
428 assert_eq!(t.title_font_style, "normal");
429 }
430
431 #[test]
432 fn default_label_typography() {
433 let t = Theme::default();
434 assert_eq!(t.label_font_family, "system-ui, sans-serif");
435 assert_eq!(t.label_font_size, 12.0);
436 assert_eq!(t.label_font_weight, 400);
437 assert_eq!(t.label_letter_spacing, 0.0);
438 assert_eq!(t.label_text_transform, TextTransform::None);
439 }
440
441 #[test]
442 fn default_numeric_typography() {
443 let t = Theme::default();
444 assert_eq!(t.numeric_font_family, "system-ui, sans-serif");
445 assert_eq!(t.numeric_font_size, 12.0);
446 }
447
448 #[test]
449 fn default_legend_typography() {
450 let t = Theme::default();
451 assert_eq!(t.legend_font_family, "system-ui, sans-serif");
452 assert_eq!(t.legend_font_size, 12.0);
453 assert_eq!(t.legend_font_weight, 400);
454 }
455
456 #[test]
457 fn default_stroke_weights_match_audit() {
458 let t = Theme::default();
459 assert_eq!(t.axis_line_weight, 1.0);
460 assert_eq!(t.grid_line_weight, 1.0);
461 assert_eq!(t.series_line_weight, 2.0);
462 assert_eq!(t.annotation_line_weight, 1.0);
463 }
464
465 #[test]
466 fn default_shape_fields() {
467 let t = Theme::default();
468 assert_eq!(t.bar_corner_radius, BarCornerRadius::Uniform(0.0));
469 assert_eq!(t.dot_radius, 5.0);
470 assert!(t.dot_halo_color.is_none());
471 assert_eq!(t.dot_halo_width, 0.0);
472 }
473
474 #[test]
475 fn default_grid_style_is_both() {
476 assert_eq!(Theme::default().grid_style, GridStyle::Both);
477 }
478
479 #[test]
480 fn default_zero_line_is_none() {
481 assert!(Theme::default().zero_line.is_none());
482 }
483
484 #[test]
485 fn dark_theme_inherits_typography_and_shape_from_default() {
486 let d = Theme::default();
487 let k = Theme::dark();
488 // Typography
489 assert_eq!(d.title_font_size, k.title_font_size);
490 assert_eq!(d.label_font_weight, k.label_font_weight);
491 assert_eq!(d.numeric_font_family, k.numeric_font_family);
492 assert_eq!(d.legend_font_family, k.legend_font_family);
493 // Shape
494 assert_eq!(d.axis_line_weight, k.axis_line_weight);
495 assert_eq!(d.grid_line_weight, k.grid_line_weight);
496 assert_eq!(d.series_line_weight, k.series_line_weight);
497 assert_eq!(d.dot_radius, k.dot_radius);
498 assert_eq!(d.bar_corner_radius, k.bar_corner_radius);
499 // Grid + baseline
500 assert_eq!(d.grid_style, k.grid_style);
501 assert_eq!(d.zero_line, k.zero_line);
502 }
503
504 #[test]
505 fn custom_theme_can_override_new_fields_individually() {
506 let custom = Theme {
507 series_line_weight: 3.5,
508 bar_corner_radius: BarCornerRadius::Uniform(4.0),
509 dot_halo_color: Some("#ffffff".into()),
510 dot_halo_width: 2.0,
511 grid_style: GridStyle::HorizontalOnly,
512 zero_line: Some(ZeroLineSpec { color: "#000000".into(), width: 1.5 }),
513 label_text_transform: TextTransform::Uppercase,
514 ..Theme::default()
515 };
516 // Overridden
517 assert_eq!(custom.series_line_weight, 3.5);
518 assert_eq!(custom.bar_corner_radius, BarCornerRadius::Uniform(4.0));
519 assert_eq!(custom.dot_halo_color.as_deref(), Some("#ffffff"));
520 assert_eq!(custom.dot_halo_width, 2.0);
521 assert_eq!(custom.grid_style, GridStyle::HorizontalOnly);
522 assert_eq!(
523 custom.zero_line,
524 Some(ZeroLineSpec { color: "#000000".into(), width: 1.5 })
525 );
526 assert_eq!(custom.label_text_transform, TextTransform::Uppercase);
527 // Not overridden — should match Default
528 assert_eq!(custom.axis_line_weight, 1.0);
529 assert_eq!(custom.dot_radius, 5.0);
530 assert_eq!(custom.title_font_size, 14.0);
531 assert_eq!(custom.axis_line, "#374151");
532 }
533}