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; 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}