Add dark mode theme support for Mermaid diagrams Gitiles renders Mermaid diagrams server-side into SVG. Previously, all SVG shapes (nodes, subgraphs, edges, markers, text, and labels) used hardcoded light-mode hex values, causing them to display as glaring white rectangles with poor contrast in dark mode. This change: - Defines CSS variables for Mermaid diagram elements (--mermaid-node-bg, --mermaid-node-stroke, --mermaid-node-text, --mermaid-edge, --mermaid-subgraph-bg, etc.) in doc.css under :root, light mode, dark media query, and html[data-theme="dark"]. - Adds semantic CSS classes (.mermaid-node, .mermaid-subgraph, .mermaid-edge, .mermaid-arrow, .mermaid-shadow, etc.) to rendered SVG elements in SimpleMermaidRenderer while preserving presentation attribute fallbacks. - Selectively applies fill/stroke classes when custom style directives are absent, preserving user-defined colors without stylesheet overrides. - Supports `color:` in `style` directives and computes background luminance for custom-filled nodes (e.g. pastel database cylinders) to preserve dark, readable text without stylesheet theme inversion. - Adds comprehensive unit tests in SimpleMermaidRendererTest covering theme classes, custom styles, and text contrast on custom-filled shapes. Change-Id: Iac234a2dd627a06ecceb41c4fabbbbdd92233605
diff --git a/java/com/google/gitiles/doc/SimpleMermaidRenderer.java b/java/com/google/gitiles/doc/SimpleMermaidRenderer.java index 140fc44..1429e43 100644 --- a/java/com/google/gitiles/doc/SimpleMermaidRenderer.java +++ b/java/com/google/gitiles/doc/SimpleMermaidRenderer.java
@@ -19,6 +19,7 @@ import static com.google.common.primitives.Doubles.max; import com.google.common.base.Ascii; +import com.google.common.base.Splitter; import com.google.common.collect.Iterables; import java.util.ArrayDeque; import java.util.ArrayList; @@ -98,6 +99,7 @@ public boolean isVirtual = false; @Nullable public String customFill; @Nullable public String customStroke; + @Nullable public String customColor; public Node(String id) { this.id = id; @@ -127,6 +129,7 @@ public double height; @Nullable public String customFill; @Nullable public String customStroke; + @Nullable public String customColor; public Subgraph(String id, String title) { this.id = id; @@ -609,6 +612,7 @@ String fill = null; String stroke = null; + String color = null; int p = 0; while (p < rest.length()) { int nextSep = rest.length(); @@ -628,6 +632,8 @@ fill = val; } else if (key.equals("stroke")) { stroke = val; + } else if (key.equals("color")) { + color = val; } } p = nextSep + 1; @@ -639,6 +645,9 @@ if (stroke != null && !isValidCssColor(stroke)) { stroke = null; } + if (color != null && !isValidCssColor(color)) { + color = null; + } Subgraph sg = graph.lookupSubgraph(targetId); if (sg != null) { @@ -648,6 +657,9 @@ if (stroke != null) { sg.customStroke = stroke; } + if (color != null) { + sg.customColor = color; + } } Node n = graph.nodes.get(targetId); if (n != null) { @@ -657,6 +669,9 @@ if (stroke != null) { n.customStroke = stroke; } + if (color != null) { + n.customColor = color; + } } } @@ -683,6 +698,109 @@ return v.matches("^(rgb|hsl)a?\\([0-9%,. ]+\\)$"); } + private static boolean isLightColor(@Nullable String color) { + if (isNullOrEmpty(color)) { + return true; + } + String c = color.trim().toLowerCase(Locale.ROOT); + if (c.startsWith("#")) { + try { + String hex = c.substring(1); + int r; + int g; + int b; + if (hex.length() == 3 || hex.length() == 4) { + r = Integer.parseInt(hex.substring(0, 1) + hex.substring(0, 1), 16); + g = Integer.parseInt(hex.substring(1, 2) + hex.substring(1, 2), 16); + b = Integer.parseInt(hex.substring(2, 3) + hex.substring(2, 3), 16); + } else if (hex.length() >= 6) { + r = Integer.parseInt(hex.substring(0, 2), 16); + g = Integer.parseInt(hex.substring(2, 4), 16); + b = Integer.parseInt(hex.substring(4, 6), 16); + } else { + return true; + } + return (0.299 * r + 0.587 * g + 0.114 * b) >= 128; + } catch (NumberFormatException e) { + return true; + } + } + if (c.startsWith("rgb")) { + int start = c.indexOf('('); + int end = c.indexOf(')'); + if (start != -1 && end > start) { + List<String> parts = Splitter.on(',').splitToList(c.substring(start + 1, end)); + if (parts.size() >= 3) { + try { + double r = parseColorComponent(parts.get(0)); + double g = parseColorComponent(parts.get(1)); + double b = parseColorComponent(parts.get(2)); + return (0.299 * r + 0.587 * g + 0.114 * b) >= 128; + } catch (NumberFormatException e) { + return true; + } + } + } + } + if (c.startsWith("hsl")) { + int start = c.indexOf('('); + int end = c.indexOf(')'); + if (start != -1 && end > start) { + List<String> parts = Splitter.on(',').splitToList(c.substring(start + 1, end)); + if (parts.size() >= 3) { + try { + String lStr = parts.get(2).trim().replace("%", ""); + double l = Double.parseDouble(lStr); + return l >= 50.0; + } catch (NumberFormatException e) { + return true; + } + } + } + } + switch (c) { + case "black", + "navy", + "darkblue", + "mediumblue", + "blue", + "darkgreen", + "green", + "teal", + "darkcyan", + "darkred", + "maroon", + "purple", + "indigo", + "darkmagenta", + "darkviolet", + "darkslateblue", + "saddlebrown", + "sienna", + "brown", + "darkslategray", + "darkslategrey", + "midnightblue", + "gray", + "grey", + "dimgray", + "dimgrey" -> { + return false; + } + default -> { + return true; + } + } + } + + private static double parseColorComponent(String part) { + String p = part.trim(); + if (p.endsWith("%")) { + return Double.parseDouble(p.substring(0, p.length() - 1)) * 2.55; + } + return Double.parseDouble(p); + } + private static void parseStatement( CharScanner s, MermaidGraph graph, @Nullable Subgraph currentSubgraph) { List<RawNodeToken> prevGroup = scanNodeGroup(s); @@ -2188,13 +2306,14 @@ svg.append( " <marker id=\"mermaid-arrow\" viewBox=\"0 0 10 10\" refX=\"8\" refY=\"5\"" + " markerWidth=\"7\" markerHeight=\"7\" orient=\"auto-start-reverse\">\n"); - svg.append(" <path d=\"M 0 1.5 L 10 5 L 0 8.5 z\" fill=\"#64748b\" />\n"); + svg.append( + " <path class=\"mermaid-arrow\" d=\"M 0 1.5 L 10 5 L 0 8.5 z\" fill=\"#64748b\" />\n"); svg.append(" </marker>\n"); svg.append( " <filter id=\"node-shadow\" x=\"-5%\" y=\"-5%\" width=\"115%\" height=\"120%\">\n"); svg.append( - " <feDropShadow dx=\"0\" dy=\"1.5\" stdDeviation=\"2\" flood-color=\"#0f172a\"" - + " flood-opacity=\"0.06\" />\n"); + " <feDropShadow class=\"mermaid-shadow\" dx=\"0\" dy=\"1.5\" stdDeviation=\"2\"" + + " flood-color=\"#0f172a\" flood-opacity=\"0.06\" />\n"); svg.append(" </filter>\n"); svg.append(" </defs>\n"); @@ -2246,11 +2365,21 @@ int depth = getSubgraphDepth(sg); String fill = sg.customFill != null ? sg.customFill : (depth % 2 == 0 ? "#fafafa" : "#f8fafc"); String stroke = sg.customStroke != null ? sg.customStroke : "#cbd5e1"; + String sgClass = + sg.customFill == null && sg.customStroke == null + ? (depth % 2 == 0 ? "mermaid-subgraph" : "mermaid-subgraph mermaid-subgraph--alt") + : (sg.customFill == null + ? (depth % 2 == 0 + ? "mermaid-subgraph-fill" + : "mermaid-subgraph-fill mermaid-subgraph-fill--alt") + : (sg.customStroke == null ? "mermaid-subgraph-stroke" : "")); + String classAttr = sgClass.isEmpty() ? "" : String.format(" class=\"%s\"", sgClass); svg.append( String.format( Locale.ROOT, - " <rect x=\"%.1f\" y=\"%.1f\" width=\"%.1f\" height=\"%.1f\" rx=\"8\" fill=\"%s\"" + " <rect%s x=\"%.1f\" y=\"%.1f\" width=\"%.1f\" height=\"%.1f\" rx=\"8\" fill=\"%s\"" + " stroke=\"%s\" stroke-width=\"1.5\" stroke-dasharray=\"4,4\" />\n", + classAttr, sg.x, sg.y, sg.width, @@ -2258,13 +2387,28 @@ fill, stroke)); if (sg.title != null && !sg.title.isEmpty()) { + String titleColor; + String titleClassAttr; + if (sg.customColor != null) { + titleColor = sg.customColor; + titleClassAttr = ""; + } else if (sg.customFill != null) { + boolean light = isLightColor(sg.customFill); + titleColor = light ? "#334155" : "#bdc1c6"; + titleClassAttr = ""; + } else { + titleColor = "#334155"; + titleClassAttr = " class=\"mermaid-subgraph-title\""; + } svg.append( String.format( Locale.ROOT, - " <text x=\"%.1f\" y=\"%.1f\" font-size=\"12\" font-weight=\"600\"" - + " fill=\"#334155\">%s</text>\n", + " <text%s x=\"%.1f\" y=\"%.1f\" font-size=\"12\" font-weight=\"600\"" + + " fill=\"%s\">%s</text>\n", + titleClassAttr, sg.x + 14, sg.y + 18, + titleColor, escapeXml(sg.title))); } } @@ -2279,6 +2423,15 @@ String fill = n.customFill != null ? n.customFill : "#ffffff"; String stroke = n.customStroke != null ? n.customStroke : "#64748b"; + String nodeClass = + n.customFill == null && n.customStroke == null + ? "mermaid-node" + : (n.customFill == null + ? "mermaid-node-fill" + : (n.customStroke == null ? "mermaid-node-stroke" : "")); + String classAttr = nodeClass.isEmpty() ? "" : String.format(" class=\"%s\"", nodeClass); + String strokeClassAttr = + n.customStroke == null ? " class=\"mermaid-node-stroke\"" : ""; // Shape Geometry switch (n.shape) { @@ -2287,8 +2440,9 @@ svg.append( String.format( Locale.ROOT, - " <circle cx=\"%.1f\" cy=\"%.1f\" r=\"%.1f\" fill=\"%s\" stroke=\"%s\"" + " <circle%s cx=\"%.1f\" cy=\"%.1f\" r=\"%.1f\" fill=\"%s\" stroke=\"%s\"" + " stroke-width=\"1.5\" filter=\"url(#node-shadow)\" />\n", + classAttr, n.x + r, n.y + r, r, @@ -2301,8 +2455,9 @@ svg.append( String.format( Locale.ROOT, - " <polygon points=\"%.1f,%.1f %.1f,%.1f %.1f,%.1f %.1f,%.1f\" fill=\"%s\"" + " <polygon%s points=\"%.1f,%.1f %.1f,%.1f %.1f,%.1f %.1f,%.1f\" fill=\"%s\"" + " stroke=\"%s\" stroke-width=\"1.5\" filter=\"url(#node-shadow)\" />\n", + classAttr, cx, n.y, n.x + n.width, @@ -2320,9 +2475,10 @@ svg.append( String.format( Locale.ROOT, - " <polygon points=\"%.1f,%.1f %.1f,%.1f %.1f,%.1f %.1f,%.1f %.1f,%.1f %.1f,%.1f\"" + " <polygon%s points=\"%.1f,%.1f %.1f,%.1f %.1f,%.1f %.1f,%.1f %.1f,%.1f %.1f,%.1f\"" + " fill=\"%s\" stroke=\"%s\" stroke-width=\"1.5\" filter=\"url(#node-shadow)\"" + " />\n", + classAttr, n.x + indent, n.y, n.x + n.width - indent, @@ -2345,9 +2501,10 @@ svg.append( String.format( Locale.ROOT, - " <path d=\"M %.1f %.1f a %.1f,%.1f 0 1,0 %.1f,0 a %.1f,%.1f 0 1,0 -%.1f,0 l" + " <path%s d=\"M %.1f %.1f a %.1f,%.1f 0 1,0 %.1f,0 a %.1f,%.1f 0 1,0 -%.1f,0 l" + " 0,%.1f a %.1f,%.1f 0 0,0 %.1f,0 l 0,-%.1f Z\" fill=\"%s\" stroke=\"%s\"" + " stroke-width=\"1.5\" filter=\"url(#node-shadow)\" />\n", + classAttr, n.x, n.y + ry, rxCyl, @@ -2366,8 +2523,9 @@ svg.append( String.format( Locale.ROOT, - " <path d=\"M %.1f %.1f a %.1f,%.1f 0 0,0 %.1f,0\" fill=\"none\" stroke=\"%s\"" + " <path%s d=\"M %.1f %.1f a %.1f,%.1f 0 0,0 %.1f,0\" fill=\"none\" stroke=\"%s\"" + " stroke-width=\"1.5\" />\n", + strokeClassAttr, n.x, n.y + ry, rxCyl, @@ -2380,9 +2538,10 @@ svg.append( String.format( Locale.ROOT, - " <polygon points=\"%.1f,%.1f %.1f,%.1f %.1f,%.1f %.1f,%.1f %.1f,%.1f\"" + " <polygon%s points=\"%.1f,%.1f %.1f,%.1f %.1f,%.1f %.1f,%.1f %.1f,%.1f\"" + " fill=\"%s\" stroke=\"%s\" stroke-width=\"1.5\" filter=\"url(#node-shadow)\"" + " />\n", + classAttr, n.x, n.y, n.x + n.width, @@ -2400,8 +2559,9 @@ svg.append( String.format( Locale.ROOT, - " <rect x=\"%.1f\" y=\"%.1f\" width=\"%.1f\" height=\"%.1f\" rx=\"4\" fill=\"%s\"" + " <rect%s x=\"%.1f\" y=\"%.1f\" width=\"%.1f\" height=\"%.1f\" rx=\"4\" fill=\"%s\"" + " stroke=\"%s\" stroke-width=\"1.5\" filter=\"url(#node-shadow)\" />\n", + classAttr, n.x, n.y, n.width, @@ -2411,8 +2571,9 @@ svg.append( String.format( Locale.ROOT, - " <line x1=\"%.1f\" y1=\"%.1f\" x2=\"%.1f\" y2=\"%.1f\" stroke=\"%s\"" + " <line%s x1=\"%.1f\" y1=\"%.1f\" x2=\"%.1f\" y2=\"%.1f\" stroke=\"%s\"" + " stroke-width=\"1.5\" />\n", + strokeClassAttr, n.x + 10, n.y, n.x + 10, @@ -2421,8 +2582,9 @@ svg.append( String.format( Locale.ROOT, - " <line x1=\"%.1f\" y1=\"%.1f\" x2=\"%.1f\" y2=\"%.1f\" stroke=\"%s\"" + " <line%s x1=\"%.1f\" y1=\"%.1f\" x2=\"%.1f\" y2=\"%.1f\" stroke=\"%s\"" + " stroke-width=\"1.5\" />\n", + strokeClassAttr, n.x + n.width - 10, n.y, n.x + n.width - 10, @@ -2433,9 +2595,10 @@ svg.append( String.format( Locale.ROOT, - " <rect x=\"%.1f\" y=\"%.1f\" width=\"%.1f\" height=\"%.1f\" rx=\"%.1f\"" + " <rect%s x=\"%.1f\" y=\"%.1f\" width=\"%.1f\" height=\"%.1f\" rx=\"%.1f\"" + " fill=\"%s\" stroke=\"%s\" stroke-width=\"1.5\"" + " filter=\"url(#node-shadow)\" />\n", + classAttr, n.x, n.y, n.width, @@ -2445,6 +2608,33 @@ stroke)); } + // Determine text colors and classes + String primaryTextColor; + String subtextColor; + String primaryTextClass; + String subtextClass; + + if (n.customColor != null) { + primaryTextColor = n.customColor; + subtextColor = n.customColor; + primaryTextClass = ""; + subtextClass = ""; + } else if (n.customFill != null) { + boolean light = isLightColor(n.customFill); + primaryTextColor = light ? "#0f172a" : "#e8eaed"; + subtextColor = light ? "#475569" : "#94a3b8"; + primaryTextClass = ""; + subtextClass = ""; + } else { + primaryTextColor = "#0f172a"; + subtextColor = "#475569"; + primaryTextClass = "mermaid-node-text"; + subtextClass = "mermaid-node-subtext"; + } + + String textClassAttr = + primaryTextClass.isEmpty() ? "" : String.format(" class=\"%s\"", primaryTextClass); + // Node Text using structured AST labelLines double cx = n.x + n.width / 2.0; double textYOffset = n.shape == NodeShape.CYLINDER ? 4.0 : 0.0; @@ -2454,10 +2644,12 @@ svg.append( String.format( Locale.ROOT, - " <text x=\"%.1f\" y=\"%.1f\" font-size=\"12\" font-weight=\"500\" fill=\"#0f172a\"" + " <text%s x=\"%.1f\" y=\"%.1f\" font-size=\"12\" font-weight=\"500\" fill=\"%s\"" + " text-anchor=\"middle\" dominant-baseline=\"central\">%s</text>\n", + textClassAttr, cx, n.y + textYOffset + n.height / 2.0, + primaryTextColor, escapeXml(n.labelLines.get(0).trim()))); } else { svg.append( @@ -2468,13 +2660,17 @@ startTextY)); for (int i = 0; i < n.labelLines.size(); i++) { String weight = i == 0 ? "600" : "400"; - String textColor = i == 0 ? "#0f172a" : "#475569"; + String textColor = i == 0 ? primaryTextColor : subtextColor; + String tspanClass = i == 0 ? primaryTextClass : subtextClass; + String tspanClassAttr = + tspanClass.isEmpty() ? "" : String.format(" class=\"%s\"", tspanClass); String fontSize = i == 0 ? "12" : "10.5"; svg.append( String.format( Locale.ROOT, - " <tspan x=\"%.1f\" dy=\"%s\" font-size=\"%s\" font-weight=\"%s\"" + " <tspan%s x=\"%.1f\" dy=\"%s\" font-size=\"%s\" font-weight=\"%s\"" + " fill=\"%s\">%s</tspan>\n", + tspanClassAttr, cx, i == 0 ? "0" : "16", fontSize, @@ -2532,7 +2728,7 @@ svg.append( String.format( Locale.ROOT, - " <path d=\"M %.1f %.1f C %.1f %.1f, %.1f %.1f, %.1f %.1f\" fill=\"none\"" + " <path class=\"mermaid-edge\" d=\"M %.1f %.1f C %.1f %.1f, %.1f %.1f, %.1f %.1f\" fill=\"none\"" + " stroke=\"#64748b\" stroke-width=\"%s\" %s%s/>\n", startX, startY, @@ -2581,7 +2777,7 @@ svg.append( String.format( Locale.ROOT, - " <path d=\"M %.1f %.1f C %.1f %.1f, %.1f %.1f, %.1f %.1f\" fill=\"none\"" + " <path class=\"mermaid-edge\" d=\"M %.1f %.1f C %.1f %.1f, %.1f %.1f, %.1f %.1f\" fill=\"none\"" + " stroke=\"#64748b\" stroke-width=\"%s\" %s%s/>\n", startX, startY, @@ -2605,7 +2801,7 @@ svg.append( String.format( Locale.ROOT, - " <line x1=\"%.1f\" y1=\"%.1f\" x2=\"%.1f\" y2=\"%.1f\" stroke=\"#64748b\"" + " <line class=\"mermaid-edge\" x1=\"%.1f\" y1=\"%.1f\" x2=\"%.1f\" y2=\"%.1f\" stroke=\"#64748b\"" + " stroke-width=\"%s\" %s%s/>\n", startX, startY, @@ -2714,7 +2910,7 @@ svg.append( String.format( Locale.ROOT, - " <path d=\"%s\" fill=\"none\" stroke=\"#64748b\" stroke-width=\"%s\" %s%s/>\n", + " <path class=\"mermaid-edge\" d=\"%s\" fill=\"none\" stroke=\"#64748b\" stroke-width=\"%s\" %s%s/>\n", pathD, strokeWidth, strokeDash, @@ -2809,7 +3005,7 @@ svg.append( String.format( Locale.ROOT, - " <path d=\"%s\" fill=\"none\" stroke=\"#64748b\" stroke-width=\"%s\" %s%s/>\n", + " <path class=\"mermaid-edge\" d=\"%s\" fill=\"none\" stroke=\"#64748b\" stroke-width=\"%s\" %s%s/>\n", pathD, strokeWidth, strokeDash, @@ -2837,7 +3033,7 @@ svg.append( String.format( Locale.ROOT, - " <path d=\"M %.1f %.1f C %.1f %.1f, %.1f %.1f, %.1f %.1f\" fill=\"none\"" + " <path class=\"mermaid-edge\" d=\"M %.1f %.1f C %.1f %.1f, %.1f %.1f, %.1f %.1f\" fill=\"none\"" + " stroke=\"#64748b\" stroke-width=\"%s\" %s%s/>\n", x1, y1, @@ -2867,7 +3063,7 @@ svg.append( String.format( Locale.ROOT, - " <rect x=\"%.1f\" y=\"%.1f\" width=\"%.1f\" height=\"%.1f\" rx=\"3\" fill=\"#ffffff\"" + " <rect class=\"mermaid-edge-label-bg\" x=\"%.1f\" y=\"%.1f\" width=\"%.1f\" height=\"%.1f\" rx=\"3\" fill=\"#ffffff\"" + " fill-opacity=\"0.95\" />\n", midX - rectW / 2.0, midY - rectH / 2.0, @@ -2876,7 +3072,7 @@ svg.append( String.format( Locale.ROOT, - " <text x=\"%.1f\" y=\"%.1f\" font-size=\"10.5\" fill=\"#475569\"" + " <text class=\"mermaid-edge-label-text\" x=\"%.1f\" y=\"%.1f\" font-size=\"10.5\" fill=\"#475569\"" + " text-anchor=\"middle\" dominant-baseline=\"central\">%s</text>\n", midX, midY,
diff --git a/javatests/com/google/gitiles/doc/SimpleMermaidRendererTest.java b/javatests/com/google/gitiles/doc/SimpleMermaidRendererTest.java index 2d32676..b38a1e6 100644 --- a/javatests/com/google/gitiles/doc/SimpleMermaidRendererTest.java +++ b/javatests/com/google/gitiles/doc/SimpleMermaidRendererTest.java
@@ -1547,4 +1547,146 @@ List<SvgDoc.Rect2D> sgs = doc.getSubgraphBoundingBoxes(); assertThat(sgs).hasSize(3); } + + @Test + public void testThemeClassesOnDiagramElements() { + String code = + """ + graph TD + subgraph Outer ["Container"] + A[Node A] -->|link label| B[Node B] + end + """; + SvgDoc doc = render(code); + + // Arrow marker and drop shadow in defs + Element arrow = + (Element) doc.getElementsByTag("marker").get(0).getElementsByTagName("path").item(0); + assertThat(arrow.getAttribute("class")).isEqualTo("mermaid-arrow"); + Element shadow = + (Element) + doc.getElementsByTag("filter").get(0).getElementsByTagName("feDropShadow").item(0); + assertThat(shadow.getAttribute("class")).isEqualTo("mermaid-shadow"); + + // Subgraph rect and title + Element sgRect = doc.getElementsByTag("rect").get(0); + assertThat(sgRect.getAttribute("class")).isEqualTo("mermaid-subgraph"); + Element sgTitle = doc.findText("Container"); + assertThat(sgTitle).isNotNull(); + assertThat(sgTitle.getAttribute("class")).isEqualTo("mermaid-subgraph-title"); + + // Edge label badge rect and text (rendered with edge before nodes) + Element badgeBg = doc.getElementsByTag("rect").get(1); + assertThat(badgeBg.getAttribute("class")).isEqualTo("mermaid-edge-label-bg"); + Element badgeText = doc.findText("link label"); + assertThat(badgeText).isNotNull(); + assertThat(badgeText.getAttribute("class")).isEqualTo("mermaid-edge-label-text"); + + // Nodes and text + Element nodeA = doc.getElementsByTag("rect").get(2); + assertThat(nodeA.getAttribute("class")).isEqualTo("mermaid-node"); + Element textA = doc.findText("Node A"); + assertThat(textA).isNotNull(); + assertThat(textA.getAttribute("class")).isEqualTo("mermaid-node-text"); + + // Edge path + Element edge = doc.getEdgePaths().get(0); + assertThat(edge.getAttribute("class")).isEqualTo("mermaid-edge"); + } + + @Test + public void testCustomStyledNodesUseTargetedClasses() { + String code = + """ + graph TD + A[Custom Fill] --> B[Custom Stroke] + B --> C[Custom Both] + style A fill:#ff0000 + style B stroke:#00ff00 + style C fill:#ff0000,stroke:#00ff00 + """; + SvgDoc doc = render(code); + + List<Element> rects = doc.getElementsByTag("rect"); + Element nodeA = rects.get(0); + assertThat(nodeA.getAttribute("class")).isEqualTo("mermaid-node-stroke"); + assertThat(nodeA.getAttribute("fill")).isEqualTo("#ff0000"); + + Element nodeB = rects.get(1); + assertThat(nodeB.getAttribute("class")).isEqualTo("mermaid-node-fill"); + assertThat(nodeB.getAttribute("stroke")).isEqualTo("#00ff00"); + + Element nodeC = rects.get(2); + assertThat(nodeC.getAttribute("class")).isEmpty(); + assertThat(nodeC.getAttribute("fill")).isEqualTo("#ff0000"); + assertThat(nodeC.getAttribute("stroke")).isEqualTo("#00ff00"); + } + + @Test + public void testCustomStyledNodesPreserveTextContrast() { + String code = + """ + graph TD + GitRepo[(Git Repositories)] + Cache[(In-Memory Cache)] + DarkNode[Dark Server] + StyledText[Explicit Text Color] + MultiLine["Primary Line<br/>Secondary Line"] + DefaultNode[Default Box] + + style GitRepo fill:#e1bee7,stroke:#8e24aa + style Cache fill:#ffecb3,stroke:#ffa000 + style DarkNode fill:#1e293b,stroke:#0f172a + style StyledText fill:#e1bee7,stroke:#8e24aa,color:#123456 + style MultiLine fill:#ffecb3 + """; + SvgDoc doc = render(code); + + // Light custom fills (pastel purple, pastel yellow) should keep dark text without + // mermaid-node-text class, preventing text inversion to white in dark mode. + Element gitRepoText = doc.findText("Git Repositories"); + assertThat(gitRepoText).isNotNull(); + assertThat(gitRepoText.getAttribute("fill")).isEqualTo("#0f172a"); + assertThat(gitRepoText.getAttribute("class")).isEmpty(); + + Element cacheText = doc.findText("In-Memory Cache"); + assertThat(cacheText).isNotNull(); + assertThat(cacheText.getAttribute("fill")).isEqualTo("#0f172a"); + assertThat(cacheText.getAttribute("class")).isEmpty(); + + // Dark custom fill should use light text without theme class + Element darkNodeText = doc.findText("Dark Server"); + assertThat(darkNodeText).isNotNull(); + assertThat(darkNodeText.getAttribute("fill")).isEqualTo("#e8eaed"); + assertThat(darkNodeText.getAttribute("class")).isEmpty(); + + // Explicit color directive should be honored + Element styledText = doc.findText("Explicit Text Color"); + assertThat(styledText).isNotNull(); + assertThat(styledText.getAttribute("fill")).isEqualTo("#123456"); + assertThat(styledText.getAttribute("class")).isEmpty(); + + // Multiline labels on custom fill should use high-contrast text and subtext + List<Element> tspans = doc.getElementsByTag("tspan"); + Element primaryTspan = null; + Element secondaryTspan = null; + for (Element tspan : tspans) { + if ("Primary Line".equals(tspan.getTextContent())) { + primaryTspan = tspan; + } else if ("Secondary Line".equals(tspan.getTextContent())) { + secondaryTspan = tspan; + } + } + assertThat(primaryTspan).isNotNull(); + assertThat(primaryTspan.getAttribute("fill")).isEqualTo("#0f172a"); + assertThat(primaryTspan.getAttribute("class")).isEmpty(); + assertThat(secondaryTspan).isNotNull(); + assertThat(secondaryTspan.getAttribute("fill")).isEqualTo("#475569"); + assertThat(secondaryTspan.getAttribute("class")).isEmpty(); + + // Default unstyled nodes must keep semantic class to adapt with the theme + Element defaultText = doc.findText("Default Box"); + assertThat(defaultText).isNotNull(); + assertThat(defaultText.getAttribute("class")).isEqualTo("mermaid-node-text"); + } }
diff --git a/resources/com/google/gitiles/static/doc.css b/resources/com/google/gitiles/static/doc.css index dde3aa7..e421cd2 100644 --- a/resources/com/google/gitiles/static/doc.css +++ b/resources/com/google/gitiles/static/doc.css
@@ -45,6 +45,19 @@ --doc-aside-bg: #f9f9f9; --doc-aside-border: #f2f2f2; --doc-aside-text: #202124; + --mermaid-node-bg: #ffffff; + --mermaid-node-stroke: #64748b; + --mermaid-node-text: #0f172a; + --mermaid-node-subtext: #475569; + --mermaid-edge: #64748b; + --mermaid-edge-label-bg: #ffffff; + --mermaid-edge-label-text: #475569; + --mermaid-subgraph-bg: #fafafa; + --mermaid-subgraph-bg-alt: #f8fafc; + --mermaid-subgraph-stroke: #cbd5e1; + --mermaid-subgraph-text: #334155; + --mermaid-shadow-color: #0f172a; + --mermaid-shadow-opacity: 0.06; } @media (prefers-color-scheme: dark) { @@ -76,6 +89,19 @@ --doc-aside-bg: #292a2d; --doc-aside-border: #3c4043; --doc-aside-text: #e8eaed; + --mermaid-node-bg: #2d3035; + --mermaid-node-stroke: #5f6368; + --mermaid-node-text: #e8eaed; + --mermaid-node-subtext: #9aa0a6; + --mermaid-edge: #9aa0a6; + --mermaid-edge-label-bg: #202124; + --mermaid-edge-label-text: #bdc1c6; + --mermaid-subgraph-bg: #25262a; + --mermaid-subgraph-bg-alt: #202124; + --mermaid-subgraph-stroke: #3c4043; + --mermaid-subgraph-text: #bdc1c6; + --mermaid-shadow-color: #000000; + --mermaid-shadow-opacity: 0.35; } } @@ -107,6 +133,19 @@ --doc-aside-bg: #292a2d; --doc-aside-border: #3c4043; --doc-aside-text: #e8eaed; + --mermaid-node-bg: #2d3035; + --mermaid-node-stroke: #5f6368; + --mermaid-node-text: #e8eaed; + --mermaid-node-subtext: #9aa0a6; + --mermaid-edge: #9aa0a6; + --mermaid-edge-label-bg: #202124; + --mermaid-edge-label-text: #bdc1c6; + --mermaid-subgraph-bg: #25262a; + --mermaid-subgraph-bg-alt: #202124; + --mermaid-subgraph-stroke: #3c4043; + --mermaid-subgraph-text: #bdc1c6; + --mermaid-shadow-color: #000000; + --mermaid-shadow-opacity: 0.35; } .Site-Content--markdown { @@ -386,3 +425,46 @@ display: inline-block; vertical-align: middle; } + +.mermaid-svg .mermaid-node, +.mermaid-svg .mermaid-node-fill { + fill: var(--mermaid-node-bg); +} +.mermaid-svg .mermaid-node, +.mermaid-svg .mermaid-node-stroke { + stroke: var(--mermaid-node-stroke); +} +.mermaid-svg .mermaid-node-text { + fill: var(--mermaid-node-text); +} +.mermaid-svg .mermaid-node-subtext { + fill: var(--mermaid-node-subtext); +} +.mermaid-svg .mermaid-edge { + stroke: var(--mermaid-edge); +} +.mermaid-svg .mermaid-arrow { + fill: var(--mermaid-edge); +} +.mermaid-svg .mermaid-edge-label-bg { + fill: var(--mermaid-edge-label-bg); +} +.mermaid-svg .mermaid-edge-label-text { + fill: var(--mermaid-edge-label-text); +} +.mermaid-svg .mermaid-subgraph, +.mermaid-svg .mermaid-subgraph-fill { + fill: var(--mermaid-subgraph-bg); + stroke: var(--mermaid-subgraph-stroke); +} +.mermaid-svg .mermaid-subgraph--alt, +.mermaid-svg .mermaid-subgraph-fill--alt { + fill: var(--mermaid-subgraph-bg-alt); +} +.mermaid-svg .mermaid-subgraph-title { + fill: var(--mermaid-subgraph-text); +} +.mermaid-svg .mermaid-shadow { + flood-color: var(--mermaid-shadow-color); + flood-opacity: var(--mermaid-shadow-opacity); +}