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