Skip to main content

asbuilt_core/
theme.rs

1//! Diagram colors for the documentation pages. Per element kind, a light
2//! and a dark fill come from `[theme]` (LikeC4's default blue for a kind
3//! with none), and each fill's text, secondary text and outline are
4//! derived from it. `theme.css` sets them as `--c4-<kind>-*` variables for
5//! the system's light and dark schemes and for a visitor's explicit
6//! choice (`data-theme` on the root), then applies them to the nodes of an
7//! inlined view. Group boxes, edges and edge labels take their colors
8//! from the page tokens (`--bg`, `--fg`, `--muted`), so a host stylesheet
9//! that restates those restyles the diagrams with the page.
10//!
11//! Pure: colors in, CSS out.
12
13use std::collections::{BTreeMap, BTreeSet};
14use std::fmt::Write;
15
16use crate::config::ThemeColor;
17
18/// The fill `likec4 gen dot` gives an element kind with no style.
19pub const LIKEC4_DEFAULT_FILL: &str = "#3b82f6";
20
21/// How far a group box's fill and outline move from the page background
22/// toward its kind's color, light scheme then dark.
23const GROUP_MIX: [(&str, &str); 2] = [("7%", "20%"), ("22%", "40%")];
24
25/// A node's colors for one scheme, each `#rrggbb`.
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct NodeColors {
28    pub fill: String,
29    pub stroke: String,
30    pub text: String,
31    pub muted: String,
32}
33
34type Rgb = [f64; 3];
35
36fn parse(hex: &str) -> Option<Rgb> {
37    let digits = hex.strip_prefix('#').filter(|d| d.len() == 6)?;
38    let channel = |i: usize| {
39        u8::from_str_radix(&digits[i..i + 2], 16)
40            .ok()
41            .map(f64::from)
42    };
43    Some([channel(0)?, channel(2)?, channel(4)?])
44}
45
46fn to_hex(color: Rgb) -> String {
47    let [r, g, b] = color.map(|c| c.round().clamp(0.0, 255.0) as u8);
48    format!("#{r:02x}{g:02x}{b:02x}")
49}
50
51/// `to` mixed into `from` by `t`, channel by channel.
52fn mix(from: Rgb, to: Rgb, t: f64) -> Rgb {
53    [0, 1, 2].map(|i| from[i] + (to[i] - from[i]) * t)
54}
55
56/// Relative luminance, as WCAG defines it.
57fn luminance(color: Rgb) -> f64 {
58    let linear = |c: f64| {
59        let c = c / 255.0;
60        if c <= 0.04045 {
61            c / 12.92
62        } else {
63            ((c + 0.055) / 1.055).powf(2.4)
64        }
65    };
66    0.2126 * linear(color[0]) + 0.7152 * linear(color[1]) + 0.0722 * linear(color[2])
67}
68
69/// Whether a fill of this luminance takes near-black text. The threshold
70/// sits above the midpoint so saturated mid-tones (LikeC4's blue, a rust
71/// red) keep white text; a fill exactly at it keeps light text too.
72fn wants_dark_text(luminance: f64) -> bool {
73    luminance > 0.45
74}
75
76/// A fill's colors: near-black text on a light fill and near-white on a
77/// dark or saturated one (the threshold keeps white text on LikeC4's blue
78/// and on a rust red), secondary text most of the way from the fill to the
79/// text, and a hairline outline a step from the fill toward the text. A
80/// fill that is not `#rrggbb` is LikeC4's default blue.
81///
82/// ```
83/// let white = asbuilt_core::theme::node_colors("#ffffff");
84/// assert_eq!(white.text, "#242424");
85/// ```
86pub fn node_colors(fill: &str) -> NodeColors {
87    let default = parse(LIKEC4_DEFAULT_FILL).unwrap_or([59.0, 130.0, 246.0]);
88    let base = parse(fill).unwrap_or(default);
89    let light_fill = wants_dark_text(luminance(base));
90    let (ink, strength) = if light_fill {
91        ([0.0; 3], 0.86)
92    } else {
93        ([255.0; 3], 0.94)
94    };
95    let text = mix(base, ink, strength);
96    NodeColors {
97        fill: to_hex(base),
98        stroke: to_hex(mix(base, text, 0.18)),
99        text: to_hex(text),
100        muted: to_hex(mix(base, text, 0.8)),
101    }
102}
103
104/// A kind usable in a class name and a custom property name.
105fn is_css_ident(kind: &str) -> bool {
106    !kind.is_empty()
107        && kind
108            .chars()
109            .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
110}
111
112fn kind_variables(out: &mut String, indent: &str, kind: &str, colors: &NodeColors) {
113    let _ = writeln!(out, "{indent}--c4-{kind}-fill: {};", colors.fill);
114    let _ = writeln!(out, "{indent}--c4-{kind}-stroke: {};", colors.stroke);
115    let _ = writeln!(out, "{indent}--c4-{kind}-text: {};", colors.text);
116    let _ = writeln!(out, "{indent}--c4-{kind}-muted: {};", colors.muted);
117}
118
119fn dark_variables(out: &mut String, indent: &str, kinds: &[(&String, NodeColors, NodeColors)]) {
120    let (fill, stroke) = GROUP_MIX[1];
121    let _ = writeln!(out, "{indent}--c4-group-fill-mix: {fill};");
122    let _ = writeln!(out, "{indent}--c4-group-stroke-mix: {stroke};");
123    for (kind, _, dark) in kinds {
124        kind_variables(out, indent, kind, dark);
125    }
126}
127
128/// `theme.css` for the element kinds a model has, colored from `[theme]`.
129pub fn stylesheet(kinds: &BTreeSet<String>, theme: &BTreeMap<String, ThemeColor>) -> String {
130    let colored: Vec<(&String, NodeColors, NodeColors)> = kinds
131        .iter()
132        .filter(|kind| is_css_ident(kind))
133        .map(|kind| {
134            let light = theme
135                .get(kind)
136                .map_or(LIKEC4_DEFAULT_FILL, |c| c.light.as_str());
137            let dark = theme
138                .get(kind)
139                .and_then(|c| c.dark.as_deref())
140                .unwrap_or(light);
141            (kind, node_colors(light), node_colors(dark))
142        })
143        .collect();
144
145    let mut out = String::from(
146        "/* Diagram colors for the asbuilt docs pages, generated from [theme].\n   Group boxes, edges and labels follow the page tokens; a host\n   stylesheet linked after this one can restate any variable. */\n",
147    );
148    out.push_str(":root {\n");
149    out.push_str("  --c4-edge: color-mix(in srgb, var(--fg) 45%, var(--bg));\n");
150    out.push_str("  --c4-edge-text: var(--muted);\n");
151    out.push_str("  --c4-label-bg: color-mix(in srgb, var(--bg) 88%, transparent);\n");
152    let (fill, stroke) = GROUP_MIX[0];
153    let _ = writeln!(out, "  --c4-group-fill-mix: {fill};");
154    let _ = writeln!(out, "  --c4-group-stroke-mix: {stroke};");
155    for (kind, light, _) in &colored {
156        kind_variables(&mut out, "  ", kind, light);
157    }
158    out.push_str(
159        "}\n@media (prefers-color-scheme: dark) {\n  :root:not([data-theme=\"light\"]) {\n",
160    );
161    dark_variables(&mut out, "    ", &colored);
162    out.push_str("  }\n}\n:root[data-theme=\"dark\"] {\n");
163    dark_variables(&mut out, "  ", &colored);
164    out.push_str("}\n");
165    out.push_str(
166        "svg.c4 { display: block; width: 100%; height: auto; }\n\
167         svg.c4 .cluster text { fill: var(--muted); fill-opacity: 1; }\n\
168         svg.c4 .edge path { stroke: var(--c4-edge); }\n\
169         svg.c4 .edge polygon:not(.c4-label-bg) { fill: var(--c4-edge); stroke: var(--c4-edge); }\n\
170         svg.c4 .edge .c4-label-bg { fill: var(--c4-label-bg); fill-opacity: 1; }\n\
171         svg.c4 .edge text { fill: var(--c4-edge-text); }\n",
172    );
173    for (kind, _, _) in &colored {
174        let _ = writeln!(
175            out,
176            "svg.c4 .node.c4-k-{kind} > :is(polygon, path, ellipse, polyline) {{ fill: var(--c4-{kind}-fill); stroke: var(--c4-{kind}-stroke); stroke-width: 1px; vector-effect: non-scaling-stroke; }}"
177        );
178        let _ = writeln!(
179            out,
180            "svg.c4 .node.c4-k-{kind} text {{ fill: var(--c4-{kind}-text); }}"
181        );
182        let _ = writeln!(
183            out,
184            "svg.c4 .node.c4-k-{kind} text.c4-muted {{ fill: var(--c4-{kind}-muted); }}"
185        );
186        let _ = writeln!(
187            out,
188            "svg.c4 .cluster.c4-k-{kind} > :is(polygon, path) {{ fill: color-mix(in srgb, var(--c4-{kind}-fill) var(--c4-group-fill-mix), var(--bg)); stroke: color-mix(in srgb, var(--c4-{kind}-fill) var(--c4-group-stroke-mix), var(--bg)); }}"
189        );
190    }
191    out
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197
198    fn colors(fill: &str, stroke: &str, text: &str, muted: &str) -> NodeColors {
199        NodeColors {
200            fill: fill.into(),
201            stroke: stroke.into(),
202            text: text.into(),
203            muted: muted.into(),
204        }
205    }
206
207    #[test]
208    fn a_light_fill_gets_near_black_text_and_a_grey_hairline() {
209        assert_eq!(
210            node_colors("#ffffff"),
211            colors("#ffffff", "#d8d8d8", "#242424", "#505050")
212        );
213    }
214
215    #[test]
216    fn a_saturated_fill_keeps_near_white_text() {
217        assert_eq!(
218            node_colors("#ce422b"),
219            colors("#ce422b", "#d6624f", "#fcf4f2", "#f3d0ca")
220        );
221    }
222
223    #[test]
224    fn a_dark_fill_gets_near_white_text() {
225        assert_eq!(
226            node_colors("#3d2c22"),
227            colors("#3d2c22", "#5e5047", "#f3f2f2", "#cfcbc8")
228        );
229    }
230
231    fn close(actual: f64, expected: f64) {
232        assert!((actual - expected).abs() < 1e-9, "{actual} vs {expected}");
233    }
234
235    #[test]
236    fn luminance_weights_red_green_and_blue_as_wcag_does() {
237        close(luminance([255.0, 0.0, 0.0]), 0.2126);
238        close(luminance([0.0, 255.0, 0.0]), 0.7152);
239        close(luminance([0.0, 0.0, 255.0]), 0.0722);
240    }
241
242    #[test]
243    fn luminance_follows_the_srgb_curve_above_and_below_its_linear_segment() {
244        close(luminance([128.0, 128.0, 128.0]), 0.21586050011389923);
245        close(luminance([5.0, 5.0, 5.0]), 0.0015176349177441874);
246    }
247
248    #[test]
249    fn a_fill_exactly_at_the_threshold_keeps_light_text() {
250        assert!(!wants_dark_text(0.45));
251        assert!(wants_dark_text(0.4501));
252    }
253
254    #[test]
255    fn a_fill_that_is_not_hex_is_likec4_s_blue() {
256        assert_eq!(node_colors("blue"), node_colors(LIKEC4_DEFAULT_FILL));
257    }
258
259    fn kinds(names: &[&str]) -> BTreeSet<String> {
260        names.iter().map(|s| s.to_string()).collect()
261    }
262
263    fn block<'a>(css: &'a str, opener: &str) -> &'a str {
264        let start = css
265            .find(opener)
266            .unwrap_or_else(|| panic!("no {opener} in {css}"));
267        let rest = &css[start..];
268        &rest[..rest.find('}').unwrap()]
269    }
270
271    #[test]
272    fn a_themed_kind_gets_its_light_and_dark_fills_in_every_scheme_block() {
273        let theme = BTreeMap::from([(
274            "container".to_string(),
275            ThemeColor {
276                light: "#ce422b".into(),
277                dark: Some("#8f2d19".into()),
278            },
279        )]);
280        let css = stylesheet(&kinds(&["container"]), &theme);
281        assert!(
282            block(&css, ":root {").contains("--c4-container-fill: #ce422b;"),
283            "{css}"
284        );
285        assert!(
286            block(&css, ":root:not([data-theme=\"light\"])")
287                .contains("--c4-container-fill: #8f2d19;"),
288            "{css}"
289        );
290        assert!(
291            block(&css, ":root[data-theme=\"dark\"]").contains("--c4-container-fill: #8f2d19;"),
292            "{css}"
293        );
294    }
295
296    #[test]
297    fn a_kind_without_a_dark_color_keeps_its_light_one_in_the_dark() {
298        let theme = BTreeMap::from([(
299            "process".to_string(),
300            ThemeColor {
301                light: "#e9d4c8".into(),
302                dark: None,
303            },
304        )]);
305        let css = stylesheet(&kinds(&["process"]), &theme);
306        assert!(
307            block(&css, ":root[data-theme=\"dark\"]").contains("--c4-process-fill: #e9d4c8;"),
308            "{css}"
309        );
310    }
311
312    #[test]
313    fn an_unthemed_kind_is_likec4_s_blue_in_both_schemes() {
314        let css = stylesheet(&kinds(&["component"]), &BTreeMap::new());
315        assert!(
316            block(&css, ":root {").contains("--c4-component-fill: #3b82f6;"),
317            "{css}"
318        );
319        assert!(
320            block(&css, ":root[data-theme=\"dark\"]").contains("--c4-component-fill: #3b82f6;"),
321            "{css}"
322        );
323    }
324
325    #[test]
326    fn every_kind_the_model_has_gets_node_and_group_box_rules_and_no_other_kind_does() {
327        let theme = BTreeMap::from([(
328            "browser".to_string(),
329            ThemeColor {
330                light: "#000000".into(),
331                dark: None,
332            },
333        )]);
334        let css = stylesheet(&kinds(&["component", "container"]), &theme);
335        for kind in ["component", "container"] {
336            assert!(
337                css.contains(&format!("svg.c4 .node.c4-k-{kind} > ")),
338                "{css}"
339            );
340            assert!(
341                css.contains(&format!("svg.c4 .node.c4-k-{kind} text.c4-muted")),
342                "{css}"
343            );
344            assert!(
345                css.contains(&format!("svg.c4 .cluster.c4-k-{kind} > ")),
346                "{css}"
347            );
348        }
349        assert!(!css.contains("browser"), "{css}");
350    }
351
352    #[test]
353    fn a_kind_that_is_not_a_css_identifier_is_skipped() {
354        let css = stylesheet(
355            &kinds(&["a b", "ok", "web-app", "db_store"]),
356            &BTreeMap::new(),
357        );
358        assert!(!css.contains("a b"), "{css}");
359        for kind in ["ok", "web-app", "db_store"] {
360            assert!(css.contains(&format!("--c4-{kind}-fill")), "{css}");
361        }
362    }
363
364    #[test]
365    fn group_boxes_mix_toward_the_page_more_in_the_dark() {
366        let css = stylesheet(&kinds(&["container"]), &BTreeMap::new());
367        assert!(
368            block(&css, ":root {").contains("--c4-group-fill-mix: 7%;"),
369            "{css}"
370        );
371        assert!(
372            block(&css, ":root[data-theme=\"dark\"]").contains("--c4-group-fill-mix: 22%;"),
373            "{css}"
374        );
375        assert!(
376            css.contains("fill: color-mix(in srgb, var(--c4-container-fill) var(--c4-group-fill-mix), var(--bg));"),
377            "{css}"
378        );
379    }
380
381    #[test]
382    fn edges_and_labels_follow_the_page_tokens() {
383        let css = stylesheet(&kinds(&["container"]), &BTreeMap::new());
384        let root = block(&css, ":root {");
385        assert!(
386            root.contains("--c4-edge: color-mix(in srgb, var(--fg) 45%, var(--bg));"),
387            "{css}"
388        );
389        assert!(root.contains("--c4-edge-text: var(--muted);"), "{css}");
390        assert!(
391            css.contains(
392                "svg.c4 .edge .c4-label-bg { fill: var(--c4-label-bg); fill-opacity: 1; }"
393            ),
394            "{css}"
395        );
396    }
397}