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, and so does\n   each diagram's legend, styled here so a figure embedded with this\n   file alone has it; a host stylesheet linked after this one can\n   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 :is(polygon, ellipse):not(.c4-label-bg):not([fill=\"none\"]) { fill: var(--c4-edge); stroke: var(--c4-edge); }\n\
170         svg.c4 .edge :is(polygon, ellipse)[fill=\"none\"] { stroke: var(--c4-edge); }\n\
171         svg.c4 .edge .c4-label-bg { fill: var(--c4-label-bg); fill-opacity: 1; }\n\
172         svg.c4 .edge text { fill: var(--c4-edge-text); }\n\
173         .legend ul { list-style: none; display: flex; flex-wrap: wrap; gap: 0.25rem 0.9rem; margin: 0.4rem 0 0; padding: 0; font-size: 0.8em; color: var(--muted); }\n\
174         .legend li { display: inline-flex; align-items: center; gap: 0.35em; }\n\
175         .legend-swatch { display: inline-block; width: 0.9em; height: 0.9em; border: 1px solid var(--rule); border-radius: 2px; }\n\
176         .legend-line { width: 2.25em; height: 0.75em; overflow: visible; }\n\
177         .legend-line line { stroke: var(--c4-edge); stroke-width: 1.5; stroke-linecap: round; }\n\
178         .legend-line :is(polygon, circle):not([fill=\"none\"]) { fill: var(--c4-edge); stroke: var(--c4-edge); }\n\
179         .legend-line :is(polygon, circle)[fill=\"none\"] { stroke: var(--c4-edge); stroke-width: 1.2; }\n",
180    );
181    for (kind, _, _) in &colored {
182        let _ = writeln!(
183            out,
184            "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; }}"
185        );
186        let _ = writeln!(
187            out,
188            "svg.c4 .node.c4-k-{kind} text {{ fill: var(--c4-{kind}-text); }}"
189        );
190        let _ = writeln!(
191            out,
192            "svg.c4 .node.c4-k-{kind} text.c4-muted {{ fill: var(--c4-{kind}-muted); }}"
193        );
194        let _ = writeln!(
195            out,
196            "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)); }}"
197        );
198        let _ = writeln!(
199            out,
200            ".legend-swatch.c4-k-{kind} {{ background: var(--c4-{kind}-fill); border-color: var(--c4-{kind}-stroke); }}"
201        );
202    }
203    out
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209
210    fn colors(fill: &str, stroke: &str, text: &str, muted: &str) -> NodeColors {
211        NodeColors {
212            fill: fill.into(),
213            stroke: stroke.into(),
214            text: text.into(),
215            muted: muted.into(),
216        }
217    }
218
219    #[test]
220    fn a_light_fill_gets_near_black_text_and_a_grey_hairline() {
221        assert_eq!(
222            node_colors("#ffffff"),
223            colors("#ffffff", "#d8d8d8", "#242424", "#505050")
224        );
225    }
226
227    #[test]
228    fn a_saturated_fill_keeps_near_white_text() {
229        assert_eq!(
230            node_colors("#ce422b"),
231            colors("#ce422b", "#d6624f", "#fcf4f2", "#f3d0ca")
232        );
233    }
234
235    #[test]
236    fn a_dark_fill_gets_near_white_text() {
237        assert_eq!(
238            node_colors("#3d2c22"),
239            colors("#3d2c22", "#5e5047", "#f3f2f2", "#cfcbc8")
240        );
241    }
242
243    fn close(actual: f64, expected: f64) {
244        assert!((actual - expected).abs() < 1e-9, "{actual} vs {expected}");
245    }
246
247    #[test]
248    fn luminance_weights_red_green_and_blue_as_wcag_does() {
249        close(luminance([255.0, 0.0, 0.0]), 0.2126);
250        close(luminance([0.0, 255.0, 0.0]), 0.7152);
251        close(luminance([0.0, 0.0, 255.0]), 0.0722);
252    }
253
254    #[test]
255    fn luminance_follows_the_srgb_curve_above_and_below_its_linear_segment() {
256        close(luminance([128.0, 128.0, 128.0]), 0.21586050011389923);
257        close(luminance([5.0, 5.0, 5.0]), 0.0015176349177441874);
258    }
259
260    #[test]
261    fn a_fill_exactly_at_the_threshold_keeps_light_text() {
262        assert!(!wants_dark_text(0.45));
263        assert!(wants_dark_text(0.4501));
264    }
265
266    #[test]
267    fn a_fill_that_is_not_hex_is_likec4_s_blue() {
268        assert_eq!(node_colors("blue"), node_colors(LIKEC4_DEFAULT_FILL));
269    }
270
271    fn kinds(names: &[&str]) -> BTreeSet<String> {
272        names.iter().map(|s| s.to_string()).collect()
273    }
274
275    fn block<'a>(css: &'a str, opener: &str) -> &'a str {
276        let start = css
277            .find(opener)
278            .unwrap_or_else(|| panic!("no {opener} in {css}"));
279        let rest = &css[start..];
280        &rest[..rest.find('}').unwrap()]
281    }
282
283    #[test]
284    fn a_themed_kind_gets_its_light_and_dark_fills_in_every_scheme_block() {
285        let theme = BTreeMap::from([(
286            "container".to_string(),
287            ThemeColor {
288                light: "#ce422b".into(),
289                dark: Some("#8f2d19".into()),
290            },
291        )]);
292        let css = stylesheet(&kinds(&["container"]), &theme);
293        assert!(
294            block(&css, ":root {").contains("--c4-container-fill: #ce422b;"),
295            "{css}"
296        );
297        assert!(
298            block(&css, ":root:not([data-theme=\"light\"])")
299                .contains("--c4-container-fill: #8f2d19;"),
300            "{css}"
301        );
302        assert!(
303            block(&css, ":root[data-theme=\"dark\"]").contains("--c4-container-fill: #8f2d19;"),
304            "{css}"
305        );
306    }
307
308    #[test]
309    fn a_kind_without_a_dark_color_keeps_its_light_one_in_the_dark() {
310        let theme = BTreeMap::from([(
311            "process".to_string(),
312            ThemeColor {
313                light: "#e9d4c8".into(),
314                dark: None,
315            },
316        )]);
317        let css = stylesheet(&kinds(&["process"]), &theme);
318        assert!(
319            block(&css, ":root[data-theme=\"dark\"]").contains("--c4-process-fill: #e9d4c8;"),
320            "{css}"
321        );
322    }
323
324    #[test]
325    fn an_unthemed_kind_is_likec4_s_blue_in_both_schemes() {
326        let css = stylesheet(&kinds(&["component"]), &BTreeMap::new());
327        assert!(
328            block(&css, ":root {").contains("--c4-component-fill: #3b82f6;"),
329            "{css}"
330        );
331        assert!(
332            block(&css, ":root[data-theme=\"dark\"]").contains("--c4-component-fill: #3b82f6;"),
333            "{css}"
334        );
335    }
336
337    #[test]
338    fn every_kind_the_model_has_gets_node_and_group_box_rules_and_no_other_kind_does() {
339        let theme = BTreeMap::from([(
340            "browser".to_string(),
341            ThemeColor {
342                light: "#000000".into(),
343                dark: None,
344            },
345        )]);
346        let css = stylesheet(&kinds(&["component", "container"]), &theme);
347        for kind in ["component", "container"] {
348            assert!(
349                css.contains(&format!("svg.c4 .node.c4-k-{kind} > ")),
350                "{css}"
351            );
352            assert!(
353                css.contains(&format!("svg.c4 .node.c4-k-{kind} text.c4-muted")),
354                "{css}"
355            );
356            assert!(
357                css.contains(&format!("svg.c4 .cluster.c4-k-{kind} > ")),
358                "{css}"
359            );
360        }
361        assert!(!css.contains("browser"), "{css}");
362    }
363
364    #[test]
365    fn a_kind_that_is_not_a_css_identifier_is_skipped() {
366        let css = stylesheet(
367            &kinds(&["a b", "ok", "web-app", "db_store"]),
368            &BTreeMap::new(),
369        );
370        assert!(!css.contains("a b"), "{css}");
371        for kind in ["ok", "web-app", "db_store"] {
372            assert!(css.contains(&format!("--c4-{kind}-fill")), "{css}");
373        }
374    }
375
376    #[test]
377    fn group_boxes_mix_toward_the_page_more_in_the_dark() {
378        let css = stylesheet(&kinds(&["container"]), &BTreeMap::new());
379        assert!(
380            block(&css, ":root {").contains("--c4-group-fill-mix: 7%;"),
381            "{css}"
382        );
383        assert!(
384            block(&css, ":root[data-theme=\"dark\"]").contains("--c4-group-fill-mix: 22%;"),
385            "{css}"
386        );
387        assert!(
388            css.contains("fill: color-mix(in srgb, var(--c4-container-fill) var(--c4-group-fill-mix), var(--bg));"),
389            "{css}"
390        );
391    }
392
393    #[test]
394    fn edges_and_labels_follow_the_page_tokens() {
395        let css = stylesheet(&kinds(&["container"]), &BTreeMap::new());
396        let root = block(&css, ":root {");
397        assert!(
398            root.contains("--c4-edge: color-mix(in srgb, var(--fg) 45%, var(--bg));"),
399            "{css}"
400        );
401        assert!(root.contains("--c4-edge-text: var(--muted);"), "{css}");
402        assert!(
403            css.contains(
404                "svg.c4 .edge .c4-label-bg { fill: var(--c4-label-bg); fill-opacity: 1; }"
405            ),
406            "{css}"
407        );
408    }
409
410    #[test]
411    fn a_filled_arrowhead_takes_the_edge_color() {
412        let css = stylesheet(&kinds(&["container"]), &BTreeMap::new());
413        assert!(
414            css.contains("svg.c4 .edge :is(polygon, ellipse):not(.c4-label-bg):not([fill=\"none\"]) { fill: var(--c4-edge); stroke: var(--c4-edge); }"),
415            "{css}"
416        );
417    }
418
419    #[test]
420    fn a_hollow_arrowhead_keeps_its_hollow_and_takes_the_edge_color_as_its_outline() {
421        let css = stylesheet(&kinds(&["container"]), &BTreeMap::new());
422        assert!(
423            css.contains(
424                "svg.c4 .edge :is(polygon, ellipse)[fill=\"none\"] { stroke: var(--c4-edge); }"
425            ),
426            "{css}"
427        );
428    }
429
430    #[test]
431    fn a_legend_swatch_takes_its_kind_s_colors() {
432        let css = stylesheet(&kinds(&["container"]), &BTreeMap::new());
433        assert!(
434            css.contains(".legend-swatch.c4-k-container { background: var(--c4-container-fill); border-color: var(--c4-container-stroke); }"),
435            "{css}"
436        );
437    }
438
439    #[test]
440    fn the_legend_is_styled_with_the_diagram_colors() {
441        let css = stylesheet(&kinds(&["container"]), &BTreeMap::new());
442        assert!(
443            css.contains(".legend-line line { stroke: var(--c4-edge); stroke-width: 1.5; stroke-linecap: round; }"),
444            "{css}"
445        );
446    }
447}