1use std::collections::{BTreeMap, BTreeSet};
14use std::fmt::Write;
15
16use crate::config::ThemeColor;
17
18pub const LIKEC4_DEFAULT_FILL: &str = "#3b82f6";
20
21const GROUP_MIX: [(&str, &str); 2] = [("7%", "20%"), ("22%", "40%")];
24
25#[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
51fn mix(from: Rgb, to: Rgb, t: f64) -> Rgb {
53 [0, 1, 2].map(|i| from[i] + (to[i] - from[i]) * t)
54}
55
56fn 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
69fn wants_dark_text(luminance: f64) -> bool {
73 luminance > 0.45
74}
75
76pub 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
104fn 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
128pub 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}