Merge "Include PRETTIFY_CSS_URL on repository index landing page when README is present"
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);
+}