Skip to main content

contributor_graphs/
theme.rs

1//! Visual themes for both renderers.
2//!
3//! A [`Theme`] is a complete look: page colours, fonts, corner radius, and a
4//! `flat` flag that switches the chart to solid "band member" bars. Three are
5//! built in ([`builtins`]); more can be supplied at generation time via a JSON
6//! file (see [`load_config`]) and offered in the interactive page's theme menu.
7
8use anyhow::{bail, Context, Result};
9use serde::Deserialize;
10use std::collections::HashMap;
11use std::path::Path;
12
13const SANS: &str = "-apple-system, 'Segoe UI', Inter, 'Helvetica Neue', Arial, sans-serif";
14
15/// A fully-resolved theme. String colours are emitted verbatim into CSS/SVG.
16#[derive(Clone, Debug)]
17pub struct Theme {
18    pub id: String,
19    pub label: String,
20    /// Dark background: drives `color-scheme`, shadow choice, avatar lightness.
21    pub dark: bool,
22    /// Solid per-row band bars + a sans-serif chart font (the Wikipedia look).
23    pub flat: bool,
24    /// Corner radius for cards/controls, in pixels.
25    pub radius: u32,
26    pub font_sans: String,
27    pub font_display: String,
28    pub bg: String,
29    pub card: String,
30    pub border: String,
31    pub border_strong: String,
32    pub text: String,
33    pub muted: String,
34    pub faint: String,
35    pub accent: String,
36    /// Optional explicit accent-soft; derived from `accent` when absent.
37    pub accent_soft: Option<String>,
38    pub grid_year: String,
39    pub grid_month: String,
40    pub track: String,
41    pub ctx_area: String,
42    pub ctx_line: String,
43}
44
45impl Theme {
46    /// HSL lightness (%) for initials-fallback avatar discs.
47    pub fn avatar_l(&self) -> u32 {
48        if self.dark {
49            48
50        } else {
51            62
52        }
53    }
54
55    fn accent_soft(&self) -> String {
56        self.accent_soft
57            .clone()
58            .unwrap_or_else(|| rgba(&self.accent, 0.12))
59    }
60
61    fn shadow(&self) -> &'static str {
62        if self.flat {
63            "none"
64        } else if self.dark {
65            "0 1px 2px rgba(0,0,0,.4)"
66        } else {
67            "0 1px 2px rgba(16,24,40,.04), 0 1px 6px rgba(16,24,40,.05)"
68        }
69    }
70
71    fn shadow_lg(&self) -> &'static str {
72        if self.dark {
73            "0 10px 32px rgba(0,0,0,.55)"
74        } else {
75            "0 8px 28px rgba(16,24,40,.14)"
76        }
77    }
78
79    fn dim(&self) -> &'static str {
80        if self.dark {
81            "rgba(120,132,148,.14)"
82        } else {
83            "rgba(120,132,148,.18)"
84        }
85    }
86
87    /// CSS custom properties (plus `color-scheme`) for a `[data-theme]` block.
88    pub fn css_vars(&self) -> Vec<(String, String)> {
89        vec![
90            ("--bg".into(), self.bg.clone()),
91            ("--card".into(), self.card.clone()),
92            ("--border".into(), self.border.clone()),
93            ("--border-strong".into(), self.border_strong.clone()),
94            ("--text".into(), self.text.clone()),
95            ("--muted".into(), self.muted.clone()),
96            ("--faint".into(), self.faint.clone()),
97            ("--accent".into(), self.accent.clone()),
98            ("--accent-soft".into(), self.accent_soft()),
99            ("--shadow".into(), self.shadow().into()),
100            ("--shadow-lg".into(), self.shadow_lg().into()),
101            ("--grid-year".into(), self.grid_year.clone()),
102            ("--grid-month".into(), self.grid_month.clone()),
103            ("--track".into(), self.track.clone()),
104            ("--radius".into(), format!("{}px", self.radius)),
105            ("--font-sans".into(), self.font_sans.clone()),
106            ("--font-display".into(), self.font_display.clone()),
107            (
108                "color-scheme".into(),
109                if self.dark { "dark" } else { "light" }.into(),
110            ),
111        ]
112    }
113
114    /// The fields the chart's JS needs (mirrors the CSS where they overlap).
115    pub fn chart_json(&self) -> serde_json::Value {
116        serde_json::json!({
117            "label": self.label,
118            "text": self.text,
119            "muted": self.muted,
120            "faint": self.faint,
121            "gridYear": self.grid_year,
122            "gridMonth": self.grid_month,
123            "card": self.card,
124            "ctxArea": self.ctx_area,
125            "ctxLine": self.ctx_line,
126            "dim": self.dim(),
127            "font": self.font_sans,
128            "flat": self.flat,
129        })
130    }
131
132    /// Full serialisation for the interactive page: CSS block + chart fields.
133    pub fn to_json(&self) -> serde_json::Value {
134        let css: serde_json::Map<String, serde_json::Value> = self
135            .css_vars()
136            .into_iter()
137            .map(|(k, v)| (k, serde_json::Value::String(v)))
138            .collect();
139        serde_json::json!({
140            "id": self.id,
141            "label": self.label,
142            "css": css,
143            "chart": self.chart_json(),
144        })
145    }
146}
147
148/// Convert `#rrggbb` into `rgba(r,g,b,a)`; falls back to a neutral tint.
149fn rgba(hex: &str, alpha: f64) -> String {
150    let h = hex.trim_start_matches('#');
151    if h.len() == 6 {
152        if let (Ok(r), Ok(g), Ok(b)) = (
153            u8::from_str_radix(&h[0..2], 16),
154            u8::from_str_radix(&h[2..4], 16),
155            u8::from_str_radix(&h[4..6], 16),
156        ) {
157            return format!("rgba({r},{g},{b},{alpha})");
158        }
159    }
160    format!("rgba(120,132,148,{alpha})")
161}
162
163/// The three built-in themes, in menu order: Light, Dark, Wikipedia.
164pub fn builtins() -> Vec<Theme> {
165    vec![
166        Theme {
167            id: "light".into(),
168            label: "Light".into(),
169            dark: false,
170            flat: false,
171            radius: 12,
172            font_sans: SANS.into(),
173            font_display: SANS.into(),
174            bg: "#f6f7f9".into(),
175            card: "#ffffff".into(),
176            border: "#e4e7ec".into(),
177            border_strong: "#d4d9e0".into(),
178            text: "#1c2530".into(),
179            muted: "#5d6b7c".into(),
180            faint: "#98a3b1".into(),
181            accent: "#2f6feb".into(),
182            accent_soft: Some("rgba(47,111,235,.1)".into()),
183            grid_year: "#e2e6ec".into(),
184            grid_month: "#eef1f5".into(),
185            track: "#e8ebf0".into(),
186            ctx_area: "#c9d7f5".into(),
187            ctx_line: "#7d9ce8".into(),
188        },
189        Theme {
190            id: "dark".into(),
191            label: "Dark".into(),
192            dark: true,
193            flat: false,
194            radius: 12,
195            font_sans: SANS.into(),
196            font_display: SANS.into(),
197            bg: "#0d1117".into(),
198            card: "#151b23".into(),
199            border: "#262d37".into(),
200            border_strong: "#333c48".into(),
201            text: "#e6ebf2".into(),
202            muted: "#9aa7b6".into(),
203            faint: "#5f6c7b".into(),
204            accent: "#2f6feb".into(),
205            accent_soft: Some("rgba(83,140,255,.13)".into()),
206            grid_year: "#232b35".into(),
207            grid_month: "#1a212a".into(),
208            track: "#2a323d".into(),
209            ctx_area: "#23344f".into(),
210            ctx_line: "#4a6da8".into(),
211        },
212        Theme {
213            id: "wikipedia".into(),
214            label: "Wikipedia".into(),
215            dark: false,
216            flat: true,
217            radius: 2,
218            font_sans: "sans-serif".into(),
219            font_display: "'Linux Libertine', Georgia, 'Times New Roman', serif".into(),
220            bg: "#ffffff".into(),
221            card: "#ffffff".into(),
222            border: "#c8ccd1".into(),
223            border_strong: "#a2a9b1".into(),
224            text: "#202122".into(),
225            muted: "#54595d".into(),
226            faint: "#72777d".into(),
227            accent: "#3366cc".into(),
228            accent_soft: Some("rgba(51,102,204,.1)".into()),
229            grid_year: "#c8ccd1".into(),
230            grid_month: "#eaecf0".into(),
231            track: "#eaecf0".into(),
232            ctx_area: "#cdd9f2".into(),
233            ctx_line: "#5b81d4".into(),
234        },
235    ]
236}
237
238/// A custom theme as written in the JSON config. Every colour is optional;
239/// anything omitted is inherited from `extends` (default: `light`).
240#[derive(Deserialize, Default)]
241#[serde(deny_unknown_fields)]
242struct RawTheme {
243    label: Option<String>,
244    extends: Option<String>,
245    dark: Option<bool>,
246    flat: Option<bool>,
247    radius: Option<u32>,
248    font_sans: Option<String>,
249    font_display: Option<String>,
250    bg: Option<String>,
251    card: Option<String>,
252    border: Option<String>,
253    border_strong: Option<String>,
254    text: Option<String>,
255    muted: Option<String>,
256    faint: Option<String>,
257    accent: Option<String>,
258    accent_soft: Option<String>,
259    grid_year: Option<String>,
260    grid_month: Option<String>,
261    track: Option<String>,
262    ctx_area: Option<String>,
263    ctx_line: Option<String>,
264}
265
266#[derive(Deserialize, Default)]
267#[serde(deny_unknown_fields)]
268struct RawConfig {
269    /// Initial theme id for the page (overridden by `--theme`).
270    default: Option<String>,
271    /// Theme ids to offer in the menu, in order. Defaults to every theme.
272    available: Option<Vec<String>>,
273    /// Hide the menu and pin the page to the default theme.
274    lock: Option<bool>,
275    themes: Option<HashMap<String, RawTheme>>,
276}
277
278/// Themes plus the page's menu configuration.
279pub struct ThemeSet {
280    /// Every theme (built-ins first, then custom), used to resolve `--theme`.
281    pub all: Vec<Theme>,
282    /// Just the custom themes, serialised into the page.
283    pub custom: Vec<Theme>,
284    /// Theme ids to show in the menu, in order.
285    pub order: Vec<String>,
286    /// Initial theme from the config (`--theme` still wins).
287    pub default: Option<String>,
288    /// Hide the switcher and pin to a single theme.
289    pub lock: bool,
290}
291
292impl Default for ThemeSet {
293    fn default() -> Self {
294        let all = builtins();
295        let order = all.iter().map(|t| t.id.clone()).collect();
296        ThemeSet {
297            all,
298            custom: Vec::new(),
299            order,
300            default: None,
301            lock: false,
302        }
303    }
304}
305
306impl ThemeSet {
307    pub fn get(&self, id: &str) -> Option<&Theme> {
308        self.all.iter().find(|t| t.id == id)
309    }
310}
311
312fn resolve(id: &str, raw: &RawTheme, base: &Theme) -> Theme {
313    Theme {
314        id: id.to_string(),
315        label: raw.label.clone().unwrap_or_else(|| id.to_string()),
316        dark: raw.dark.unwrap_or(base.dark),
317        flat: raw.flat.unwrap_or(base.flat),
318        radius: raw.radius.unwrap_or(base.radius),
319        font_sans: raw
320            .font_sans
321            .clone()
322            .unwrap_or_else(|| base.font_sans.clone()),
323        font_display: raw
324            .font_display
325            .clone()
326            .unwrap_or_else(|| base.font_display.clone()),
327        bg: raw.bg.clone().unwrap_or_else(|| base.bg.clone()),
328        card: raw.card.clone().unwrap_or_else(|| base.card.clone()),
329        border: raw.border.clone().unwrap_or_else(|| base.border.clone()),
330        border_strong: raw
331            .border_strong
332            .clone()
333            .unwrap_or_else(|| base.border_strong.clone()),
334        text: raw.text.clone().unwrap_or_else(|| base.text.clone()),
335        muted: raw.muted.clone().unwrap_or_else(|| base.muted.clone()),
336        faint: raw.faint.clone().unwrap_or_else(|| base.faint.clone()),
337        accent: raw.accent.clone().unwrap_or_else(|| base.accent.clone()),
338        // accent_soft is only inherited when the accent itself is inherited;
339        // a new accent should derive a fresh tint.
340        accent_soft: raw.accent_soft.clone().or_else(|| {
341            if raw.accent.is_none() {
342                base.accent_soft.clone()
343            } else {
344                None
345            }
346        }),
347        grid_year: raw
348            .grid_year
349            .clone()
350            .unwrap_or_else(|| base.grid_year.clone()),
351        grid_month: raw
352            .grid_month
353            .clone()
354            .unwrap_or_else(|| base.grid_month.clone()),
355        track: raw.track.clone().unwrap_or_else(|| base.track.clone()),
356        ctx_area: raw
357            .ctx_area
358            .clone()
359            .unwrap_or_else(|| base.ctx_area.clone()),
360        ctx_line: raw
361            .ctx_line
362            .clone()
363            .unwrap_or_else(|| base.ctx_line.clone()),
364    }
365}
366
367/// Load a theme config JSON file and combine it with the built-ins.
368pub fn load_config(path: &Path) -> Result<ThemeSet> {
369    let text =
370        std::fs::read_to_string(path).with_context(|| format!("cannot read {}", path.display()))?;
371    let raw: RawConfig = serde_json::from_str(&text)
372        .with_context(|| format!("invalid theme config {}", path.display()))?;
373
374    let builtin = builtins();
375    let mut all = builtin.clone();
376    let mut custom = Vec::new();
377
378    if let Some(themes) = &raw.themes {
379        // Sort by id for deterministic output (HashMap order is random).
380        let mut ids: Vec<&String> = themes.keys().collect();
381        ids.sort();
382        for id in ids {
383            let rt = &themes[id];
384            if builtin.iter().any(|t| &t.id == id) {
385                bail!("theme id '{id}' shadows a built-in theme; pick another id");
386            }
387            let base = match &rt.extends {
388                Some(e) => all
389                    .iter()
390                    .find(|t| &t.id == e)
391                    .cloned()
392                    .with_context(|| format!("theme '{id}' extends unknown theme '{e}'"))?,
393                None => builtin[0].clone(), // light
394            };
395            let theme = resolve(id, rt, &base);
396            all.push(theme.clone());
397            custom.push(theme);
398        }
399    }
400
401    let order = match &raw.available {
402        Some(list) => {
403            for id in list {
404                if !all.iter().any(|t| &t.id == id) {
405                    bail!("'available' lists unknown theme '{id}'");
406                }
407            }
408            list.clone()
409        }
410        None => all.iter().map(|t| t.id.clone()).collect(),
411    };
412
413    if let Some(d) = &raw.default {
414        if !all.iter().any(|t| &t.id == d) {
415            bail!("'default' theme '{d}' is not defined");
416        }
417    }
418
419    Ok(ThemeSet {
420        all,
421        custom,
422        order,
423        default: raw.default.clone(),
424        lock: raw.lock.unwrap_or(false),
425    })
426}