This is an automated email from the ASF dual-hosted git repository.

davsclaus pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git


The following commit(s) were added to refs/heads/main by this push:
     new de4c199e47c3 CAMEL-26348: camel-diagram - a route diagram image draws 
the steps as cards (#28575)
de4c199e47c3 is described below

commit de4c199e47c3bb8676b9e7512edc4f213376f00f
Author: Claus Ibsen <[email protected]>
AuthorDate: Wed Oct 28 21:35:39 2026 +0100

    CAMEL-26348: camel-diagram - a route diagram image draws the steps as cards 
(#28575)
    
    Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
---
 .../org/apache/camel/catalog/docs/diagram.adoc     |  40 ++-
 .../camel-diagram/src/main/docs/diagram.adoc       |  40 ++-
 .../src/main/docs/route-diagram-card.png           | Bin 0 -> 41066 bytes
 .../camel/diagram/DefaultRouteDiagramDumper.java   |  11 +-
 .../apache/camel/diagram/RouteDiagramIcons.java    | 377 +++++++++++++++++++++
 .../camel/diagram/RouteDiagramLayoutEngine.java    | 113 +++++-
 .../apache/camel/diagram/RouteDiagramRenderer.java | 154 ++++++---
 .../camel/diagram/THIRD-PARTY-NOTICES.txt          |   2 +-
 .../camel/diagram/RouteDiagramCardStyleTest.java   | 224 ++++++++++++
 .../modules/ROOT/images/route-diagram-card.png     |   1 +
 .../ROOT/images/images/route-diagram-sample.png    | Bin 18833 -> 26381 bytes
 .../ROOT/images/jbang/route-diagram-dark.png       | Bin 151329 -> 0 bytes
 .../ROOT/images/jbang/route-diagram-light.png      | Bin 144838 -> 0 bytes
 .../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc    |  28 ++
 .../camel-jbang-cmd-route-diagram.adoc             |   2 +-
 .../META-INF/camel-jbang-commands-metadata.json    |   2 +-
 .../core/commands/action/CamelHistoryAction.java   |   3 +-
 .../commands/action/CamelRouteDiagramAction.java   |  22 +-
 .../dsl/jbang/core/commands/process/ListError.java |   3 +-
 .../action/CamelRouteDiagramActionRunningTest.java |  52 +++
 .../jbang/core/commands/mcp/RouteDiagramTools.java |   4 +-
 .../core/commands/mcp/RouteDiagramToolsTest.java   |  32 ++
 22 files changed, 1043 insertions(+), 67 deletions(-)

diff --git 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/diagram.adoc
 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/diagram.adoc
index 65116747f00f..7bc0eddc8571 100644
--- 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/diagram.adoc
+++ 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/diagram.adoc
@@ -71,6 +71,8 @@ List<RouteInfo> routes = 
RouteDiagramHelper.parseRoutes(jsonObject);
 
 // Layout and render
 RouteDiagramLayoutEngine engine = new RouteDiagramLayoutEngine();
+// the renderer draws the steps as cards, so lay them out as cards
+engine.setCardStyle(true);
 RouteDiagramRenderer renderer = new RouteDiagramRenderer();
 
 LayoutRoute lr = engine.layoutRoute(routes.get(0), 
RouteDiagramLayoutEngine.PADDING);
@@ -132,9 +134,9 @@ frame below its steps, and in topology mode the routes 
reached only on error in
 
 The following built-in themes are available:
 
-* `dark` - dark background (default)
-* `light` - light background
-* `transparent` - transparent background
+* `dark` - dark background, dark cards (default)
+* `light` - light background, white cards
+* `transparent` - transparent background, white cards
 
 Custom colors can be specified using the format:
 
@@ -143,6 +145,14 @@ Custom colors can be specified using the format:
 bg=#1e1e1e:from=#2e7d32:to=#1565c0:eip=#8957e5:choice=#d29922
 ----
 
+A custom spec is on top of the `dark` theme. The colors of the cards are set 
with `card` (the background of a card),
+`cardBorder`, `cardText` (the kind of step) and `cardDetail` (its detail):
+
+[source,properties]
+----
+bg=#ffffff:card=#fafafa:cardBorder=#d0d7de:cardText=#1f2328:cardDetail=#59636e
+----
+
 Color values can be `#hex` codes or ANSI color names (e.g., `seagreen`, 
`steelblue`).
 
 To use dark theme
@@ -152,6 +162,27 @@ To use dark theme
 camel cmd route-diagram MyRoute.java --theme=dark
 ----
 
+=== Steps as Cards
+
+An image of a route draws each step as a card: an icon for the kind of step, 
in a circle of the color of its kind,
+the kind in bold (such as `setBody`), and its detail below (such as `groovy: 
body.items`, from the code
+`setBody[groovy{body.items}]`). A step without detail, such as `choice`, shows 
only its kind.
+
+[source,bash]
+----
+camel cmd route-diagram orders.camel.yaml --output=orders.png
+----
+
+`camel cmd route-diagram` saves a `.png` file in the `light` theme when no 
`--theme` is given (in the terminal it shows
+the `unicode` text diagram), and so does the `camel_render_route_diagram` MCP 
tool.
+
+image::ROOT:route-diagram-card.png[A route diagram: a file read, a groovy 
mapping, an http call and a JMS send as white cards]
+
+With `--node-label=both` (the default of `camel cmd route-diagram`) a step 
with a description shows the description
+and then the detail below its kind.
+
+The icons are from https://lucide.dev[Lucide] (ISC License), the same as the 
embeddable web components.
+
 == ASCII Art Rendering
 
 The ASCII art renderer produces plain text diagrams using box-drawing 
characters.
@@ -410,7 +441,8 @@ RouteDiagramAsciiRenderer renderer = new 
RouteDiagramAsciiRenderer(engine.getNod
 String diagram = renderer.renderDiagramAnsi(layoutRoutes, totalHeight, 
highlightedNodes, style);
 
 // For PNG rendering
-RouteDiagramRenderer pngRenderer = new RouteDiagramRenderer(nodeWidth, 
fontSize);
+RouteDiagramRenderer pngRenderer = new RouteDiagramRenderer(
+        engine.getNodeWidth(), fontSize * RouteDiagramLayoutEngine.SCALE, 
false);
 BufferedImage image = pngRenderer.renderDiagram(layoutRoutes, totalHeight, 
colors, highlightedNodes, style);
 ----
 
diff --git a/components/camel-diagram/src/main/docs/diagram.adoc 
b/components/camel-diagram/src/main/docs/diagram.adoc
index 65116747f00f..7bc0eddc8571 100644
--- a/components/camel-diagram/src/main/docs/diagram.adoc
+++ b/components/camel-diagram/src/main/docs/diagram.adoc
@@ -71,6 +71,8 @@ List<RouteInfo> routes = 
RouteDiagramHelper.parseRoutes(jsonObject);
 
 // Layout and render
 RouteDiagramLayoutEngine engine = new RouteDiagramLayoutEngine();
+// the renderer draws the steps as cards, so lay them out as cards
+engine.setCardStyle(true);
 RouteDiagramRenderer renderer = new RouteDiagramRenderer();
 
 LayoutRoute lr = engine.layoutRoute(routes.get(0), 
RouteDiagramLayoutEngine.PADDING);
@@ -132,9 +134,9 @@ frame below its steps, and in topology mode the routes 
reached only on error in
 
 The following built-in themes are available:
 
-* `dark` - dark background (default)
-* `light` - light background
-* `transparent` - transparent background
+* `dark` - dark background, dark cards (default)
+* `light` - light background, white cards
+* `transparent` - transparent background, white cards
 
 Custom colors can be specified using the format:
 
@@ -143,6 +145,14 @@ Custom colors can be specified using the format:
 bg=#1e1e1e:from=#2e7d32:to=#1565c0:eip=#8957e5:choice=#d29922
 ----
 
+A custom spec is on top of the `dark` theme. The colors of the cards are set 
with `card` (the background of a card),
+`cardBorder`, `cardText` (the kind of step) and `cardDetail` (its detail):
+
+[source,properties]
+----
+bg=#ffffff:card=#fafafa:cardBorder=#d0d7de:cardText=#1f2328:cardDetail=#59636e
+----
+
 Color values can be `#hex` codes or ANSI color names (e.g., `seagreen`, 
`steelblue`).
 
 To use dark theme
@@ -152,6 +162,27 @@ To use dark theme
 camel cmd route-diagram MyRoute.java --theme=dark
 ----
 
+=== Steps as Cards
+
+An image of a route draws each step as a card: an icon for the kind of step, 
in a circle of the color of its kind,
+the kind in bold (such as `setBody`), and its detail below (such as `groovy: 
body.items`, from the code
+`setBody[groovy{body.items}]`). A step without detail, such as `choice`, shows 
only its kind.
+
+[source,bash]
+----
+camel cmd route-diagram orders.camel.yaml --output=orders.png
+----
+
+`camel cmd route-diagram` saves a `.png` file in the `light` theme when no 
`--theme` is given (in the terminal it shows
+the `unicode` text diagram), and so does the `camel_render_route_diagram` MCP 
tool.
+
+image::ROOT:route-diagram-card.png[A route diagram: a file read, a groovy 
mapping, an http call and a JMS send as white cards]
+
+With `--node-label=both` (the default of `camel cmd route-diagram`) a step 
with a description shows the description
+and then the detail below its kind.
+
+The icons are from https://lucide.dev[Lucide] (ISC License), the same as the 
embeddable web components.
+
 == ASCII Art Rendering
 
 The ASCII art renderer produces plain text diagrams using box-drawing 
characters.
@@ -410,7 +441,8 @@ RouteDiagramAsciiRenderer renderer = new 
RouteDiagramAsciiRenderer(engine.getNod
 String diagram = renderer.renderDiagramAnsi(layoutRoutes, totalHeight, 
highlightedNodes, style);
 
 // For PNG rendering
-RouteDiagramRenderer pngRenderer = new RouteDiagramRenderer(nodeWidth, 
fontSize);
+RouteDiagramRenderer pngRenderer = new RouteDiagramRenderer(
+        engine.getNodeWidth(), fontSize * RouteDiagramLayoutEngine.SCALE, 
false);
 BufferedImage image = pngRenderer.renderDiagram(layoutRoutes, totalHeight, 
colors, highlightedNodes, style);
 ----
 
diff --git a/components/camel-diagram/src/main/docs/route-diagram-card.png 
b/components/camel-diagram/src/main/docs/route-diagram-card.png
new file mode 100644
index 000000000000..da37b2da2c66
Binary files /dev/null and 
b/components/camel-diagram/src/main/docs/route-diagram-card.png differ
diff --git 
a/components/camel-diagram/src/main/java/org/apache/camel/diagram/DefaultRouteDiagramDumper.java
 
b/components/camel-diagram/src/main/java/org/apache/camel/diagram/DefaultRouteDiagramDumper.java
index 7b14ced2d825..ed6025863ccb 100644
--- 
a/components/camel-diagram/src/main/java/org/apache/camel/diagram/DefaultRouteDiagramDumper.java
+++ 
b/components/camel-diagram/src/main/java/org/apache/camel/diagram/DefaultRouteDiagramDumper.java
@@ -197,8 +197,14 @@ public class DefaultRouteDiagramDumper extends 
ServiceSupport implements CamelCo
             boolean errors, List<ErrorPath> paths) {
         RouteDiagramRenderer renderer = new RouteDiagramRenderer(
                 nodeWidth * RouteDiagramLayoutEngine.SCALE, fontSize * 
RouteDiagramLayoutEngine.SCALE, metrics);
+        if (theme == null) {
+            theme = "transparent";
+        }
+        theme = theme.toLowerCase();
+        RouteDiagramRenderer.DiagramColors colors = 
RouteDiagramRenderer.DiagramColors.parse(theme);
         RouteDiagramLayoutEngine engine = new RouteDiagramLayoutEngine(
                 nodeWidth, fontSize, 
RouteDiagramLayoutEngine.NodeLabelMode.valueOf(nodeLabel.toUpperCase()));
+        engine.setCardStyle(true);
 
         List<RouteDiagramLayoutEngine.LayoutRoute> layoutRoutes = new 
ArrayList<>();
         int currentY = RouteDiagramLayoutEngine.PADDING;
@@ -208,11 +214,6 @@ public class DefaultRouteDiagramDumper extends 
ServiceSupport implements CamelCo
             layoutRoutes.add(lr);
             currentY = lr.maxY + RouteDiagramLayoutEngine.V_GAP;
         }
-        if (theme == null) {
-            theme = "transparent";
-        }
-        theme = theme.toLowerCase();
-        RouteDiagramRenderer.DiagramColors colors = 
RouteDiagramRenderer.DiagramColors.parse(theme);
         return renderer.renderDiagram(layoutRoutes, currentY, colors, 
highlightedNodeIds, highlightStyle);
     }
 
diff --git 
a/components/camel-diagram/src/main/java/org/apache/camel/diagram/RouteDiagramIcons.java
 
b/components/camel-diagram/src/main/java/org/apache/camel/diagram/RouteDiagramIcons.java
new file mode 100644
index 000000000000..fc9efe86a884
--- /dev/null
+++ 
b/components/camel-diagram/src/main/java/org/apache/camel/diagram/RouteDiagramIcons.java
@@ -0,0 +1,377 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.diagram;
+
+import java.awt.Shape;
+import java.awt.geom.AffineTransform;
+import java.awt.geom.Arc2D;
+import java.awt.geom.Ellipse2D;
+import java.awt.geom.Line2D;
+import java.awt.geom.Path2D;
+import java.awt.geom.RoundRectangle2D;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * The icons of the steps in the card style of a route diagram: Lucide icons 
(https://lucide.dev, ISC License; the same
+ * icons as the camel-route-diagram web component), as outlines on a 24x24 
grid to stroke with round caps and joins.
+ */
+final class RouteDiagramIcons {
+
+    /** The size of the grid the icons are drawn on. */
+    static final int GRID = 24;
+
+    private static final Map<String, Shape> ICONS = new HashMap<>();
+
+    static {
+        ICONS.put("workflow", shape(rect(3, 3, 8, 8, 2), path("M7 11v4a2 2 0 0 
0 2 2h4"), rect(13, 13, 8, 8, 2)));
+        ICONS.put("log-in", shape(path("M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 
2h-4"),
+                polyline(10, 17, 15, 12, 10, 7), line(15, 12, 3, 12)));
+        ICONS.put("log-out", shape(path("M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 
2-2h4"),
+                polyline(16, 17, 21, 12, 16, 7), line(21, 12, 9, 12)));
+        ICONS.put("file-text", shape(path("M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 
2 2h12a2 2 0 0 0 2-2V7Z"),
+                path("M14 2v6h6"), path("M16 13H8"), path("M16 17H8"), 
path("M10 9H8")));
+        ICONS.put("git-branch", shape(line(6, 3, 6, 15), circle(18, 6, 3), 
circle(6, 18, 3),
+                path("M18 9a9 9 0 0 1-9 9")));
+        ICONS.put("corner-down-right", shape(polyline(15, 10, 20, 15, 15, 20), 
path("M4 4v7a4 4 0 0 0 4 4h12")));
+        ICONS.put("split", shape(path("M16 3h5v5"), path("M8 3H3v5"),
+                path("M12 22v-8.3a4 4 0 0 0-1.172-2.872L3 3"), path("m15 9 
6-6")));
+        ICONS.put("merge", shape(path("m8 6 4-4 4 4"), path("M12 2v10.3a4 4 0 
0 1-1.172 2.872L4 22"),
+                path("m20 22-5-5")));
+        ICONS.put("shield", shape(path("M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 
1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1"
+                                       + "c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 
1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z")));
+        ICONS.put("alert-triangle", shape(path("m21.73 18-8-14a2 2 0 0 0-3.48 
0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"),
+                path("M12 9v4"), path("M12 17h.01")));
+        ICONS.put("flag", shape(path("M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 
1-5-2-8-2-4 1-4 1z"), line(4, 22, 4, 15)));
+        ICONS.put("zap", shape(path("M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 
0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7"
+                                    + "a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 
1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z")));
+        ICONS.put("braces", shape(path("M8 3H7a2 2 0 0 0-2 2v5a2 2 0 0 1-2 2 2 
2 0 0 1 2 2v5c0 1.1.9 2 2 2h1"),
+                path("M16 21h1a2 2 0 0 0 2-2v-5c0-1.1.9-2 2-2a2 2 0 0 
1-2-2V5a2 2 0 0 0-2-2h-1")));
+        ICONS.put("heading", shape(path("M6 12h12"), path("M6 20V4"), 
path("M18 20V4")));
+        ICONS.put("x", shape(path("M18 6 6 18"), path("m6 6 12 12")));
+        ICONS.put("repeat", shape(path("m17 2 4 4-4 4"), path("M3 11v-1a4 4 0 
0 1 4-4h14"), path("m7 22-4-4 4-4"),
+                path("M21 13v1a4 4 0 0 1-4 4H3")));
+        ICONS.put("code", shape(path("m16 18 6-6-6-6"), path("m8 6-6 6 6 6")));
+        ICONS.put("clock", shape(circle(12, 12, 10), polyline(12, 6, 12, 12, 
16, 14)));
+        ICONS.put("filter", shape(path("M22 3H2l8 9.46V19l4 2v-8.54L22 3z")));
+        ICONS.put("rotate-cw", shape(path("M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 
6.74 2.74L21 8"), path("M21 3v5h-5")));
+        ICONS.put("box", shape(path("M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 
0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4"
+                                    + "a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"),
+                path("m3.3 7 8.7 5 8.7-5"), path("M12 22V12")));
+    }
+
+    private RouteDiagramIcons() {
+    }
+
+    /** The names of the icons, for testing. */
+    static List<String> names() {
+        return List.copyOf(ICONS.keySet());
+    }
+
+    /** The icon by its Lucide name, or null. */
+    static Shape icon(String name) {
+        return ICONS.get(name);
+    }
+
+    /** The icon of a step, by its type (such as setBody); the box icon for a 
step without one of its own. */
+    static Shape iconFor(String type) {
+        return ICONS.get(iconName(type));
+    }
+
+    static String iconName(String type) {
+        if (type == null) {
+            return "box";
+        }
+        return switch (type) {
+            case "route" -> "workflow";
+            case "from" -> "log-in";
+            case "to", "toD", "wireTap", "enrich", "pollEnrich", "kamelet" -> 
"log-out";
+            case "log" -> "file-text";
+            case "choice", "switch" -> "git-branch";
+            case "when", "otherwise" -> "corner-down-right";
+            case "doTry" -> "shield";
+            case "doCatch", "onException" -> "alert-triangle";
+            case "doFinally", "onCompletion" -> "flag";
+            case "multicast", "split", "recipientList", "routingSlip", 
"dynamicRouter", "loadBalance" -> "split";
+            case "aggregate" -> "merge";
+            case "circuitBreaker" -> "zap";
+            case "setBody", "transform" -> "braces";
+            case "setHeader", "setHeaders", "setProperty", "setVariable", 
"setVariables" -> "heading";
+            case "removeHeader", "removeHeaders", "removeProperty", 
"removeProperties", "removeVariable" -> "x";
+            case "marshal", "unmarshal", "convertBodyTo", "convertHeaderTo", 
"convertVariableTo" -> "repeat";
+            case "bean", "process", "script" -> "code";
+            case "delay", "throttle" -> "clock";
+            case "filter", "validate" -> "filter";
+            case "loop" -> "rotate-cw";
+            default -> "box";
+        };
+    }
+
+    private static Shape shape(Shape... parts) {
+        Path2D.Double answer = new Path2D.Double();
+        for (Shape part : parts) {
+            answer.append(part, false);
+        }
+        return answer;
+    }
+
+    private static Shape rect(double x, double y, double w, double h, double 
r) {
+        return new RoundRectangle2D.Double(x, y, w, h, 2 * r, 2 * r);
+    }
+
+    private static Shape circle(double cx, double cy, double r) {
+        return new Ellipse2D.Double(cx - r, cy - r, 2 * r, 2 * r);
+    }
+
+    private static Shape line(double x1, double y1, double x2, double y2) {
+        return new Line2D.Double(x1, y1, x2, y2);
+    }
+
+    private static Shape polyline(double... points) {
+        Path2D.Double p = new Path2D.Double();
+        p.moveTo(points[0], points[1]);
+        for (int i = 2; i < points.length; i += 2) {
+            p.lineTo(points[i], points[i + 1]);
+        }
+        return p;
+    }
+
+    /**
+     * The outline of SVG path data: the commands M, L, H, V, C, S, A and Z, 
absolute and relative, as Lucide writes
+     * them (numbers run together such as {@code 1.1.9}, a command repeated by 
more numbers).
+     */
+    static Path2D path(String d) {
+        PathParser parser = new PathParser(d);
+        return parser.parse();
+    }
+
+    private static final class PathParser {
+        private final String d;
+        private int pos;
+        private final Path2D.Double path = new Path2D.Double();
+        private double x;
+        private double y;
+        private double startX;
+        private double startY;
+        // the second control point of the last curve, for the reflection of S
+        private double ctrlX;
+        private double ctrlY;
+        private boolean lastCurve;
+
+        PathParser(String d) {
+            this.d = d;
+        }
+
+        Path2D parse() {
+            char cmd = 0;
+            skipSeparators();
+            while (pos < d.length()) {
+                char c = d.charAt(pos);
+                if (Character.isLetter(c)) {
+                    cmd = c;
+                    pos++;
+                } else if (cmd == 0) {
+                    throw new IllegalArgumentException("Path data must start 
with a command: " + d);
+                }
+                cmd = command(cmd);
+                skipSeparators();
+            }
+            return path;
+        }
+
+        /** Runs one command, and gives the command its further numbers repeat 
(a moveto repeats as a lineto). */
+        private char command(char cmd) {
+            boolean rel = Character.isLowerCase(cmd);
+            double ox = rel ? x : 0;
+            double oy = rel ? y : 0;
+            boolean curve = false;
+            char next = cmd;
+            switch (Character.toUpperCase(cmd)) {
+                case 'M' -> {
+                    x = ox + number();
+                    y = oy + number();
+                    path.moveTo(x, y);
+                    startX = x;
+                    startY = y;
+                    next = rel ? 'l' : 'L';
+                }
+                case 'L' -> {
+                    x = ox + number();
+                    y = oy + number();
+                    path.lineTo(x, y);
+                }
+                case 'H' -> {
+                    x = ox + number();
+                    path.lineTo(x, y);
+                }
+                case 'V' -> {
+                    y = oy + number();
+                    path.lineTo(x, y);
+                }
+                case 'C' -> {
+                    double x1 = ox + number();
+                    double y1 = oy + number();
+                    ctrlX = ox + number();
+                    ctrlY = oy + number();
+                    x = ox + number();
+                    y = oy + number();
+                    path.curveTo(x1, y1, ctrlX, ctrlY, x, y);
+                    curve = true;
+                }
+                case 'S' -> {
+                    double x1 = lastCurve ? 2 * x - ctrlX : x;
+                    double y1 = lastCurve ? 2 * y - ctrlY : y;
+                    ctrlX = ox + number();
+                    ctrlY = oy + number();
+                    x = ox + number();
+                    y = oy + number();
+                    path.curveTo(x1, y1, ctrlX, ctrlY, x, y);
+                    curve = true;
+                }
+                case 'A' -> {
+                    double rx = number();
+                    double ry = number();
+                    double rotation = number();
+                    boolean large = flag();
+                    boolean sweep = flag();
+                    double ex = ox + number();
+                    double ey = oy + number();
+                    arc(rx, ry, rotation, large, sweep, ex, ey);
+                    x = ex;
+                    y = ey;
+                }
+                case 'Z' -> {
+                    path.closePath();
+                    x = startX;
+                    y = startY;
+                    next = 0;
+                }
+                default -> throw new IllegalArgumentException("Unsupported 
path command " + cmd + " in: " + d);
+            }
+            lastCurve = curve;
+            return next;
+        }
+
+        /** An elliptical arc from the current point, as SVG gives it 
(endpoint parameters, F.6.5 of SVG 1.1). */
+        private void arc(double rx, double ry, double rotation, boolean large, 
boolean sweep, double ex, double ey) {
+            if (x == ex && y == ey) {
+                return;
+            }
+            rx = Math.abs(rx);
+            ry = Math.abs(ry);
+            if (rx == 0 || ry == 0) {
+                path.lineTo(ex, ey);
+                return;
+            }
+            double phi = Math.toRadians(rotation);
+            double cos = Math.cos(phi);
+            double sin = Math.sin(phi);
+            double dx = (x - ex) / 2;
+            double dy = (y - ey) / 2;
+            double x1 = cos * dx + sin * dy;
+            double y1 = -sin * dx + cos * dy;
+            // radii too small to reach the end point are scaled up
+            double lambda = (x1 * x1) / (rx * rx) + (y1 * y1) / (ry * ry);
+            if (lambda > 1) {
+                rx *= Math.sqrt(lambda);
+                ry *= Math.sqrt(lambda);
+            }
+            double num = rx * rx * ry * ry - rx * rx * y1 * y1 - ry * ry * x1 
* x1;
+            double den = rx * rx * y1 * y1 + ry * ry * x1 * x1;
+            double coef = Math.sqrt(Math.max(0, num / den)) * (large == sweep 
? -1 : 1);
+            double cx1 = coef * rx * y1 / ry;
+            double cy1 = -coef * ry * x1 / rx;
+            double cx = cos * cx1 - sin * cy1 + (x + ex) / 2;
+            double cy = sin * cx1 + cos * cy1 + (y + ey) / 2;
+            double theta = angle(1, 0, (x1 - cx1) / rx, (y1 - cy1) / ry);
+            double delta = angle((x1 - cx1) / rx, (y1 - cy1) / ry, (-x1 - cx1) 
/ rx, (-y1 - cy1) / ry);
+            if (!sweep && delta > 0) {
+                delta -= 2 * Math.PI;
+            } else if (sweep && delta < 0) {
+                delta += 2 * Math.PI;
+            }
+            // Arc2D counts angles counterclockwise on screen, SVG clockwise 
(the y axis points down)
+            Arc2D.Double arc = new Arc2D.Double(
+                    cx - rx, cy - ry, 2 * rx, 2 * ry, -Math.toDegrees(theta), 
-Math.toDegrees(delta), Arc2D.OPEN);
+            Shape shape = phi == 0 ? arc : 
AffineTransform.getRotateInstance(phi, cx, cy).createTransformedShape(arc);
+            path.append(shape, true);
+        }
+
+        private static double angle(double ux, double uy, double vx, double 
vy) {
+            return Math.atan2(ux * vy - uy * vx, ux * vx + uy * vy);
+        }
+
+        private boolean flag() {
+            skipSeparators();
+            char c = d.charAt(pos++);
+            if (c != '0' && c != '1') {
+                throw new IllegalArgumentException("Expected an arc flag at " 
+ (pos - 1) + " in: " + d);
+            }
+            return c == '1';
+        }
+
+        private double number() {
+            skipSeparators();
+            int start = pos;
+            if (pos < d.length() && (d.charAt(pos) == '-' || d.charAt(pos) == 
'+')) {
+                pos++;
+            }
+            boolean dot = false;
+            boolean digits = false;
+            boolean exponent = false;
+            while (pos < d.length()) {
+                char c = d.charAt(pos);
+                if (Character.isDigit(c)) {
+                    digits = true;
+                    pos++;
+                } else if (c == '.' && !dot) {
+                    dot = true;
+                    pos++;
+                } else if ((c == 'e' || c == 'E') && digits && !exponent && 
exponentDigits(pos + 1)) {
+                    // an exponent only with its digits (1e-3); an e without 
them ends the number
+                    exponent = true;
+                    dot = true;
+                    pos++;
+                    if (d.charAt(pos) == '-' || d.charAt(pos) == '+') {
+                        pos++;
+                    }
+                } else {
+                    break;
+                }
+            }
+            if (pos == start) {
+                throw new IllegalArgumentException("Expected a number at " + 
pos + " in: " + d);
+            }
+            return Double.parseDouble(d.substring(start, pos));
+        }
+
+        /** Whether the digits of an exponent start at the index, after an 
optional sign. */
+        private boolean exponentDigits(int i) {
+            if (i < d.length() && (d.charAt(i) == '-' || d.charAt(i) == '+')) {
+                i++;
+            }
+            return i < d.length() && Character.isDigit(d.charAt(i));
+        }
+
+        private void skipSeparators() {
+            while (pos < d.length() && (Character.isWhitespace(d.charAt(pos)) 
|| d.charAt(pos) == ',')) {
+                pos++;
+            }
+        }
+    }
+}
diff --git 
a/components/camel-diagram/src/main/java/org/apache/camel/diagram/RouteDiagramLayoutEngine.java
 
b/components/camel-diagram/src/main/java/org/apache/camel/diagram/RouteDiagramLayoutEngine.java
index 85f31f1a2415..40281d9ea2a7 100644
--- 
a/components/camel-diagram/src/main/java/org/apache/camel/diagram/RouteDiagramLayoutEngine.java
+++ 
b/components/camel-diagram/src/main/java/org/apache/camel/diagram/RouteDiagramLayoutEngine.java
@@ -24,6 +24,8 @@ import java.text.BreakIterator;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
 
 /**
  * Layout engine that builds a tree from flat route node lists and computes 
positions for diagram rendering.
@@ -41,6 +43,16 @@ public class RouteDiagramLayoutEngine {
     private static final int MIN_BOX_WIDTH = 80;
     private static final int DEFAULT_NODE_HEIGHT = 32;
 
+    /** The space between the border of a card and its icon, and its text. */
+    public static final int CARD_PAD = 10 * SCALE;
+    /** The space above and below the text of a card. */
+    public static final int CARD_PAD_Y = 9 * SCALE;
+    /** The diameter of the circle of the icon of a card. */
+    public static final int CARD_ICON_SIZE = 24 * SCALE;
+    /** Where the text of a card starts, from its left border: after the icon. 
*/
+    public static final int CARD_TEXT_INSET = CARD_PAD + CARD_ICON_SIZE + 
CARD_PAD;
+    private static final Pattern CARD_EXPRESSION = 
Pattern.compile("^([A-Za-z][\\w-]*)\\{(.*)\\}$", Pattern.DOTALL);
+
     public enum NodeLabelMode {
         CODE,
         DESCRIPTION,
@@ -52,8 +64,10 @@ public class RouteDiagramLayoutEngine {
     private final int nodeTextPadding;
     private final int baseNodeHeight;
     private final FontMetrics fontMetrics;
+    private final FontMetrics titleMetrics;
     private final NodeLabelMode nodeLabelMode;
     private boolean tableLayout;
+    private boolean cardStyle;
 
     static final Set<String> BRANCHING_EIPS = Set.of(
             "choice", "multicast", "doTry", "loadBalance", "recipientList", 
"circuitBreaker");
@@ -120,15 +134,35 @@ public class RouteDiagramLayoutEngine {
         try {
             g.setFont(new Font("SansSerif", Font.PLAIN, fontSize * SCALE));
             this.fontMetrics = g.getFontMetrics();
+            g.setFont(cardTitleFont(fontSize * SCALE));
+            this.titleMetrics = g.getFontMetrics();
         } finally {
             g.dispose();
         }
     }
 
+    /** The font of the title of a card: bold, a size larger than the text of 
the nodes. */
+    public static Font cardTitleFont(int fontSizeScaled) {
+        return new Font("SansSerif", Font.BOLD, fontSizeScaled + SCALE);
+    }
+
     public int getNodeWidth() {
         return nodeWidth;
     }
 
+    /**
+     * Lays the nodes out as cards: a title (the kind of step, such as 
setBody) and the detail below it (such as
+     * {@code groovy: body.items}), after an icon. The first of the {@link 
LayoutNode#wrappedLines} is the title. On for
+     * an image ({@link RouteDiagramRenderer} draws cards), off (the default) 
for the ASCII and Unicode diagrams.
+     */
+    public void setCardStyle(boolean cardStyle) {
+        this.cardStyle = cardStyle;
+    }
+
+    public boolean isCardStyle() {
+        return cardStyle;
+    }
+
     /**
      * Draws the {@link #TABLE_EIPS} as one wide node with a row per child 
instead of a chain of boxes. Off by default:
      * a renderer turns it on when it draws the rows ({@link 
LayoutNode#tableRow}).
@@ -298,7 +332,10 @@ public class RouteDiagramLayoutEngine {
     }
 
     private List<String> wrapLabel(String label) {
-        int maxTextWidth = nodeWidth - nodeTextPadding;
+        return wrapLabel(label, nodeWidth - nodeTextPadding);
+    }
+
+    private List<String> wrapLabel(String label, int maxTextWidth) {
         if (fontMetrics.stringWidth(label) <= maxTextWidth) {
             return List.of(label);
         }
@@ -368,6 +405,69 @@ public class RouteDiagramLayoutEngine {
         return baseNodeHeight + (lineCount - 1) * lineSpacing;
     }
 
+    /** The lines of a card: its title, then its detail wrapped to the width 
beside the icon, at most three lines. */
+    private List<String> cardLines(NodeInfo info) {
+        List<String> label = cardLabel(info, nodeLabelMode);
+        List<String> lines = new ArrayList<>();
+        lines.add(label.get(0));
+        int maxTextWidth = nodeWidth - CARD_TEXT_INSET - CARD_PAD;
+        List<String> detail = new ArrayList<>();
+        for (String text : label.subList(1, label.size())) {
+            detail.addAll(wrapLabel(text, maxTextWidth));
+        }
+        if (detail.size() > MAX_WRAP_LINES) {
+            // the renderer cuts the last line with an ellipsis
+            String rest = String.join(" ", detail.subList(MAX_WRAP_LINES - 1, 
detail.size()));
+            detail = new ArrayList<>(detail.subList(0, MAX_WRAP_LINES - 1));
+            detail.add(rest);
+        }
+        lines.addAll(detail);
+        return lines;
+    }
+
+    private int computeCardHeight(List<String> lines) {
+        int textHeight = titleMetrics.getHeight() + (lines.size() - 1) * 
fontMetrics.getHeight();
+        return Math.max(baseNodeHeight, Math.max(textHeight, CARD_ICON_SIZE) + 
2 * CARD_PAD_Y);
+    }
+
+    /**
+     * The text of a node as a card: the kind of step first (such as {@code 
setBody}), then its detail (such as
+     * {@code groovy: body.items} for the code {@code 
setBody[groovy{body.items}]}), or its description, by the label
+     * mode. A step without detail (such as {@code choice}) has only its kind.
+     */
+    public static List<String> cardLabel(NodeInfo info, NodeLabelMode mode) {
+        String code = cleanLabel(info.code);
+        String kind = code;
+        String detail = "";
+        int bracket = code.indexOf('[');
+        if (bracket > 0 && code.endsWith("]")) {
+            kind = code.substring(0, bracket);
+            detail = code.substring(bracket + 1, code.length() - 1);
+            Matcher m = CARD_EXPRESSION.matcher(detail);
+            if (m.matches()) {
+                // an expression in its language: groovy{body.items} as 
groovy: body.items
+                detail = m.group(1) + ": " + m.group(2);
+            }
+        }
+        if (kind.isEmpty() && info.type != null) {
+            kind = info.type;
+        }
+        boolean hasDesc = info.description != null && 
!info.description.isBlank() && !info.description.equals(code);
+        List<String> below = switch (mode) {
+            case DESCRIPTION -> List.of(hasDesc ? info.description : detail);
+            case BOTH -> hasDesc ? List.of(info.description, detail) : 
List.of(detail);
+            default -> List.of(detail);
+        };
+        List<String> lines = new ArrayList<>();
+        lines.add(kind);
+        for (String line : below) {
+            if (!line.isBlank()) {
+                lines.add(line);
+            }
+        }
+        return lines;
+    }
+
     public static List<String> resolveLabel(NodeInfo info, NodeLabelMode mode) 
{
         String code = cleanLabel(info.code);
         boolean hasDesc = info.description != null && 
!info.description.isBlank();
@@ -474,9 +574,14 @@ public class RouteDiagramLayoutEngine {
         ln.id = node.info.id;
         ln.x = nodeX;
         ln.y = y;
-        ln.wrappedLines = resolveLabel(node.info, nodeLabelMode).stream()
-                .flatMap(s -> wrapLabel(s).stream()).toList();
-        ln.height = computeNodeHeight(ln.wrappedLines);
+        if (cardStyle) {
+            ln.wrappedLines = cardLines(node.info);
+            ln.height = computeCardHeight(ln.wrappedLines);
+        } else {
+            ln.wrappedLines = resolveLabel(node.info, nodeLabelMode).stream()
+                    .flatMap(s -> wrapLabel(s).stream()).toList();
+            ln.height = computeNodeHeight(ln.wrappedLines);
+        }
         ln.treeNode = node;
         node.layoutNode = ln;
         lr.nodes.add(ln);
diff --git 
a/components/camel-diagram/src/main/java/org/apache/camel/diagram/RouteDiagramRenderer.java
 
b/components/camel-diagram/src/main/java/org/apache/camel/diagram/RouteDiagramRenderer.java
index 0977012212bb..a9a1782bed8e 100644
--- 
a/components/camel-diagram/src/main/java/org/apache/camel/diagram/RouteDiagramRenderer.java
+++ 
b/components/camel-diagram/src/main/java/org/apache/camel/diagram/RouteDiagramRenderer.java
@@ -17,6 +17,7 @@
 package org.apache.camel.diagram;
 
 import java.awt.*;
+import java.awt.geom.AffineTransform;
 import java.awt.image.BufferedImage;
 import java.util.ArrayList;
 import java.util.HashMap;
@@ -66,45 +67,57 @@ public class RouteDiagramRenderer {
     private final int nodeWidth;
     private final int fontSizeNode;
     private final int fontSizeLabel;
-    private final int nodeTextPadding;
     private final boolean metrics;
 
+    // the steps are cards (card, cardBorder, cardText for the kind, 
cardDetail for the detail) with an icon in a
+    // circle of the color of the kind of step (from, to, eip, ...); text is 
the text in the nodes of the topology
     private static final String DARK_COLORS
             = 
"bg=#0d1117:text=#f0f6fc:arrow=#656c76:label=#d1d7e0:from=#238636:to=#1f6feb:eip=#8957e5"
-              + 
":choice=#d29922:default=#3d444d:transform=#1b7c83:processor=#bf4b8a";
+              + 
":choice=#d29922:default=#3d444d:transform=#1b7c83:processor=#bf4b8a"
+              + 
":card=#151b23:cardBorder=#3d444d:cardText=#f0f6fc:cardDetail=#9198a1";
     private static final String LIGHT_COLORS
             = 
"bg=#f6f8fa:text=#f0f6fc:arrow=#59636e:label=#1f2328:from=#238636:to=#1f6feb:eip=#8957e5"
-              + 
":choice=#d29922:default=#3d444d:transform=#1b7c83:processor=#bf4b8a";
+              + 
":choice=#d29922:default=#3d444d:transform=#1b7c83:processor=#bf4b8a"
+              + 
":card=#ffffff:cardBorder=#d0d7de:cardText=#1f2328:cardDetail=#59636e";
+    // white cards, readable on any background
     private static final String TRANSPARENT_COLORS
             = 
"bg=:text=#f0f6fc:arrow=#656c76:label=#d1d7e0:from=#238636:to=#1f6feb:eip=#8957e5"
-              + 
":choice=#d29922:default=#3d444d:transform=#1b7c83:processor=#bf4b8a";
+              + 
":choice=#d29922:default=#3d444d:transform=#1b7c83:processor=#bf4b8a"
+              + 
":card=#ffffff:cardBorder=#d0d7de:cardText=#1f2328:cardDetail=#59636e";
 
     private static final Map<String, String> COLOR_PRESETS = Map.of(
             "dark", DARK_COLORS,
             "light", LIGHT_COLORS,
             "transparent", TRANSPARENT_COLORS);
 
+    /** The size of the icon inside its circle on a card. */
+    private static final int CARD_ICON_GLYPH = 14 * SCALE;
+    private static final float CARD_ICON_STROKE_WIDTH = 1.4f * SCALE;
+    private static final Color CARD_SHADOW = new Color(0, 0, 0, 22);
+    private static final Color CARD_SHADOW_SOFT = new Color(0, 0, 0, 10);
+
     public RouteDiagramRenderer() {
-        this(RouteDiagramLayoutEngine.DEFAULT_BOX_WIDTH * SCALE,
-             RouteDiagramLayoutEngine.DEFAULT_FONT_SIZE * SCALE,
-             new RouteDiagramLayoutEngine().getNodeTextPadding(),
+        this(RouteDiagramLayoutEngine.DEFAULT_BOX_WIDTH * SCALE, 
RouteDiagramLayoutEngine.DEFAULT_FONT_SIZE * SCALE,
              false);
     }
 
     public RouteDiagramRenderer(int nodeWidth, int fontSizeScaled, boolean 
metrics) {
-        this(nodeWidth, fontSizeScaled, new RouteDiagramLayoutEngine(
-                nodeWidth / SCALE, fontSizeScaled / 
SCALE).getNodeTextPadding(),
-             metrics);
-    }
-
-    public RouteDiagramRenderer(int nodeWidth, int fontSizeScaled, int 
nodeTextPadding, boolean metrics) {
         this.nodeWidth = nodeWidth;
         this.fontSizeNode = fontSizeScaled;
         this.fontSizeLabel = fontSizeScaled + SCALE;
-        this.nodeTextPadding = nodeTextPadding;
         this.metrics = metrics;
     }
 
+    /**
+     * @param      nodeTextPadding not used: the text of a card starts after 
its icon
+     *                             ({@link 
RouteDiagramLayoutEngine#CARD_TEXT_INSET})
+     * @deprecated                 use {@link #RouteDiagramRenderer(int, int, 
boolean)}
+     */
+    @Deprecated(since = "4.24.0")
+    public RouteDiagramRenderer(int nodeWidth, int fontSizeScaled, int 
nodeTextPadding, boolean metrics) {
+        this(nodeWidth, fontSizeScaled, metrics);
+    }
+
     public static class DiagramColors {
         private Color bg;
         private Color text;
@@ -120,9 +133,18 @@ public class RouteDiagramRenderer {
         private Color nodeTransform;
         private Color nodeProcessor;
         private Color nodeExternal;
-
+        private Color cardBg;
+        private Color cardBorder;
+        private Color cardText;
+        private Color cardDetail;
+
+        /**
+         * The colors of a theme: a preset (dark, light, transparent) or a 
spec of {@code key=color} pairs separated by
+         * colons, on top of the dark theme.
+         */
         public static DiagramColors parse(String spec) {
-            String resolved = COLOR_PRESETS.getOrDefault(spec, spec);
+            // no spec is the dark theme (the presets are an immutable map, 
which cannot look up null)
+            String resolved = spec == null ? DARK_COLORS : 
COLOR_PRESETS.getOrDefault(spec, spec);
             Map<String, String> map = new HashMap<>();
             for (String entry : DARK_COLORS.split(":")) {
                 int eq = entry.indexOf('=');
@@ -151,6 +173,10 @@ public class RouteDiagramRenderer {
             c.nodeTransform = parseColor(map.getOrDefault("transform", 
"#00838f"));
             c.nodeProcessor = parseColor(map.getOrDefault("processor", 
"#d84315"));
             c.nodeExternal = parseColor(map.getOrDefault("external", 
"#0277bd"));
+            c.cardBg = parseColor(map.getOrDefault("card", "#151b23"));
+            c.cardBorder = parseColor(map.getOrDefault("cardBorder", 
"#3d444d"));
+            c.cardText = parseColor(map.getOrDefault("cardText", "#f0f6fc"));
+            c.cardDetail = parseColor(map.getOrDefault("cardDetail", 
"#9198a1"));
             return c;
         }
 
@@ -227,6 +253,22 @@ public class RouteDiagramRenderer {
         public Color getNodeExternal() {
             return nodeExternal;
         }
+
+        public Color getCardBg() {
+            return cardBg;
+        }
+
+        public Color getCardBorder() {
+            return cardBorder;
+        }
+
+        public Color getCardText() {
+            return cardText;
+        }
+
+        public Color getCardDetail() {
+            return cardDetail;
+        }
     }
 
     public BufferedImage renderDiagram(
@@ -494,42 +536,76 @@ public class RouteDiagramRenderer {
         drawArrowFromMerge(g, to, colors, false, null);
     }
 
+    /**
+     * A step as a card: a card with a soft shadow, the icon of its kind in a 
circle of the color of the kind, its title
+     * in bold and its detail below (laid out by {@link 
RouteDiagramLayoutEngine#setCardStyle}).
+     */
     private void drawNode(Graphics2D g, LayoutNode node, DiagramColors colors, 
boolean highlightFail) {
-        Color color = getNodeColor(node.type, colors);
+        int x = node.x;
+        int y = node.y;
+        int h = node.height;
 
-        g.setColor(color);
-        g.fillRoundRect(node.x, node.y, nodeWidth, node.height, ARC, ARC);
+        g.setColor(CARD_SHADOW_SOFT);
+        g.fillRoundRect(x - SCALE, y + SCALE, nodeWidth + 2 * SCALE, h + 2 * 
SCALE, ARC + 2 * SCALE, ARC + 2 * SCALE);
+        g.setColor(CARD_SHADOW);
+        g.fillRoundRect(x, y + SCALE, nodeWidth, h, ARC, ARC);
 
+        g.setColor(colors.getCardBg());
+        g.fillRoundRect(x, y, nodeWidth, h, ARC, ARC);
         if (highlightFail) {
             g.setColor(HIGHLIGHT_FAIL_COLOR);
             g.setStroke(new BasicStroke(HIGHLIGHT_STROKE_WIDTH));
         } else {
-            g.setColor(color.brighter());
+            g.setColor(colors.getCardBorder());
             g.setStroke(new BasicStroke(BORDER_STROKE_WIDTH));
         }
-        g.drawRoundRect(node.x, node.y, nodeWidth, node.height, ARC, ARC);
-
-        g.setColor(colors.getText());
-        g.setFont(new Font("SansSerif", Font.PLAIN, fontSizeNode));
-        FontMetrics fm = g.getFontMetrics();
+        g.drawRoundRect(x, y, nodeWidth, h, ARC, ARC);
+
+        // the icon of the kind of step, in a circle of its color
+        int d = RouteDiagramLayoutEngine.CARD_ICON_SIZE;
+        int circleX = x + RouteDiagramLayoutEngine.CARD_PAD;
+        int circleY = y + (h - d) / 2;
+        g.setColor(getNodeColor(node.type, colors));
+        g.fillOval(circleX, circleY, d, d);
+        double scale = (double) CARD_ICON_GLYPH / RouteDiagramIcons.GRID;
+        AffineTransform at = new AffineTransform();
+        at.translate(circleX + (d - CARD_ICON_GLYPH) / 2.0, circleY + (d - 
CARD_ICON_GLYPH) / 2.0);
+        at.scale(scale, scale);
+        g.setColor(Color.WHITE);
+        g.setStroke(new BasicStroke(CARD_ICON_STROKE_WIDTH, 
BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND));
+        
g.draw(at.createTransformedShape(RouteDiagramIcons.iconFor(node.type)));
+        g.setStroke(new BasicStroke(STROKE_WIDTH));
 
+        // the title, and the detail below it, centered in the height of the 
card
         List<String> lines = node.wrappedLines;
+        int textX = x + RouteDiagramLayoutEngine.CARD_TEXT_INSET;
+        int maxWidth = nodeWidth - RouteDiagramLayoutEngine.CARD_TEXT_INSET - 
RouteDiagramLayoutEngine.CARD_PAD;
+        g.setFont(RouteDiagramLayoutEngine.cardTitleFont(fontSizeNode));
+        FontMetrics titleFm = g.getFontMetrics();
+        Font detailFont = new Font("SansSerif", Font.PLAIN, fontSizeNode);
+        FontMetrics detailFm = g.getFontMetrics(detailFont);
+        int textHeight = titleFm.getHeight() + (lines.size() - 1) * 
detailFm.getHeight();
+        int baseline = y + (h - textHeight) / 2 + titleFm.getAscent();
+        g.setColor(colors.getCardText());
+        g.drawString(fit(titleFm, lines.get(0), maxWidth), textX, baseline);
+        g.setFont(detailFont);
+        g.setColor(colors.getCardDetail());
+        baseline += titleFm.getDescent() + detailFm.getAscent();
+        for (int i = 1; i < lines.size(); i++) {
+            g.drawString(fit(detailFm, lines.get(i), maxWidth), textX, 
baseline);
+            baseline += detailFm.getHeight();
+        }
+    }
 
-        int lineHeight = fm.getHeight();
-        int totalTextHeight = lines.size() * lineHeight;
-        int startY = node.y + (node.height - totalTextHeight) / 2 + 
fm.getAscent();
-
-        for (int i = 0; i < lines.size(); i++) {
-            String text = lines.get(i);
-            if (fm.stringWidth(text) > nodeWidth - nodeTextPadding) {
-                while (text.length() > 1 && fm.stringWidth(text + "…") > 
nodeWidth - nodeTextPadding) {
-                    text = text.substring(0, text.length() - 1);
-                }
-                text = text + "…";
-            }
-            int textX = node.x + (nodeWidth - fm.stringWidth(text)) / 2;
-            g.drawString(text, textX, startY + i * lineHeight);
+    /** The text cut with an ellipsis when it is wider than the given width. */
+    private static String fit(FontMetrics fm, String text, int maxWidth) {
+        if (fm.stringWidth(text) <= maxWidth) {
+            return text;
+        }
+        while (text.length() > 1 && fm.stringWidth(text + "…") > maxWidth) {
+            text = text.substring(0, text.length() - 1);
         }
+        return text + "…";
     }
 
     private void drawArrow(
diff --git 
a/components/camel-diagram/src/main/resources/META-INF/resources/camel/diagram/THIRD-PARTY-NOTICES.txt
 
b/components/camel-diagram/src/main/resources/META-INF/resources/camel/diagram/THIRD-PARTY-NOTICES.txt
index 1a892479e696..2959df604243 100644
--- 
a/components/camel-diagram/src/main/resources/META-INF/resources/camel/diagram/THIRD-PARTY-NOTICES.txt
+++ 
b/components/camel-diagram/src/main/resources/META-INF/resources/camel/diagram/THIRD-PARTY-NOTICES.txt
@@ -1,7 +1,7 @@
 camel-route-diagram bundles the following third-party content.
 
 
--------------------------------------------------------------------------------
-Lucide (icon SVG paths inlined in camel-route-diagram.js):
+Lucide (icon SVG paths inlined in camel-route-diagram.js, and in 
RouteDiagramIcons.java for the card style of the PNG diagrams):
 
--------------------------------------------------------------------------------
 
   ISC License
diff --git 
a/components/camel-diagram/src/test/java/org/apache/camel/diagram/RouteDiagramCardStyleTest.java
 
b/components/camel-diagram/src/test/java/org/apache/camel/diagram/RouteDiagramCardStyleTest.java
new file mode 100644
index 000000000000..b9910c6d1b42
--- /dev/null
+++ 
b/components/camel-diagram/src/test/java/org/apache/camel/diagram/RouteDiagramCardStyleTest.java
@@ -0,0 +1,224 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.diagram;
+
+import java.awt.Color;
+import java.awt.Shape;
+import java.awt.geom.Rectangle2D;
+import java.awt.image.BufferedImage;
+import java.util.List;
+
+import org.apache.camel.diagram.RouteDiagramLayoutEngine.LayoutNode;
+import org.apache.camel.diagram.RouteDiagramLayoutEngine.LayoutRoute;
+import org.apache.camel.diagram.RouteDiagramLayoutEngine.NodeInfo;
+import org.apache.camel.diagram.RouteDiagramLayoutEngine.NodeLabelMode;
+import org.apache.camel.diagram.RouteDiagramLayoutEngine.RouteInfo;
+import org.apache.camel.diagram.RouteDiagramRenderer.DiagramColors;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.assertj.core.api.Assertions.within;
+
+/**
+ * The card style of a route diagram: the steps as cards with an icon, a title 
and the detail below it.
+ */
+class RouteDiagramCardStyleTest {
+
+    private static NodeInfo node(String type, String code, int level) {
+        NodeInfo n = new NodeInfo();
+        n.type = type;
+        n.id = type + level;
+        n.code = code;
+        n.level = level;
+        return n;
+    }
+
+    /** The route of the payload mode design: a file read, a groovy mapping, 
an http call and a JMS send. */
+    private static RouteInfo orders() {
+        RouteInfo r = new RouteInfo();
+        r.routeId = "orders";
+        r.source = "orders.camel.yaml";
+        r.nodes.add(node("route", "route[orders]", 0));
+        r.nodes.add(node("from", "from[file:orders]", 1));
+        r.nodes.add(node("setBody", "setBody[groovy{body.items}]", 2));
+        r.nodes.add(node("to", "to[http:pricing]", 2));
+        r.nodes.add(node("setHeader", "setHeader[skus]", 2));
+        r.nodes.add(node("to", "to[jms:priced]", 2));
+        return r;
+    }
+
+    @Test
+    void themesHaveCardColors() {
+        
assertThat(DiagramColors.parse("light").getCardBg()).isEqualTo(Color.WHITE);
+        
assertThat(DiagramColors.parse("transparent").getCardBg()).isEqualTo(Color.WHITE);
+        
assertThat(DiagramColors.parse("dark").getCardBg()).isNotEqualTo(Color.WHITE);
+        // a custom spec is on top of the dark theme, and can set the colors 
of the cards
+        DiagramColors custom = DiagramColors.parse("bg=#ffffff:card=#fafafa");
+        assertThat(custom.getCardBg()).isEqualTo(new Color(0xfafafa));
+        
assertThat(custom.getCardText()).isEqualTo(DiagramColors.parse("dark").getCardText());
+    }
+
+    @Test
+    void noSpecIsTheDarkTheme() {
+        DiagramColors none = DiagramColors.parse(null);
+        
assertThat(none.getBg()).isEqualTo(DiagramColors.parse("dark").getBg());
+        
assertThat(none.getCardBg()).isEqualTo(DiagramColors.parse("dark").getCardBg());
+    }
+
+    @Test
+    void cardLabelSplitsKindAndDetail() {
+        assertThat(RouteDiagramLayoutEngine.cardLabel(node("setBody", 
"setBody[groovy{body.items}]", 1),
+                NodeLabelMode.CODE)).containsExactly("setBody", "groovy: 
body.items");
+        assertThat(RouteDiagramLayoutEngine.cardLabel(node("from", 
"from[file:orders]", 1), NodeLabelMode.CODE))
+                .containsExactly("from", "file:orders");
+        assertThat(RouteDiagramLayoutEngine.cardLabel(node("setHeader", 
"setHeader[skus]", 1), NodeLabelMode.CODE))
+                .containsExactly("setHeader", "skus");
+        // a step without detail has only its kind
+        assertThat(RouteDiagramLayoutEngine.cardLabel(node("choice", "choice", 
1), NodeLabelMode.CODE))
+                .containsExactly("choice");
+        // a predicate keeps its text, only the language moves in front
+        assertThat(RouteDiagramLayoutEngine.cardLabel(node("when", 
"when[simple{${header.kind} == 0}]", 1),
+                NodeLabelMode.CODE)).containsExactly("when", "simple: 
${header.kind} == 0");
+    }
+
+    @Test
+    void cardLabelByMode() {
+        NodeInfo n = node("to", "to[kafka:orders]", 1);
+        n.description = "Publish the order";
+        assertThat(RouteDiagramLayoutEngine.cardLabel(n, 
NodeLabelMode.CODE)).containsExactly("to", "kafka:orders");
+        assertThat(RouteDiagramLayoutEngine.cardLabel(n, 
NodeLabelMode.DESCRIPTION))
+                .containsExactly("to", "Publish the order");
+        assertThat(RouteDiagramLayoutEngine.cardLabel(n, NodeLabelMode.BOTH))
+                .containsExactly("to", "Publish the order", "kafka:orders");
+
+        // without a description the detail is shown in every mode
+        NodeInfo plain = node("to", "to[kafka:orders]", 1);
+        assertThat(RouteDiagramLayoutEngine.cardLabel(plain, 
NodeLabelMode.DESCRIPTION))
+                .containsExactly("to", "kafka:orders");
+        assertThat(RouteDiagramLayoutEngine.cardLabel(plain, 
NodeLabelMode.BOTH))
+                .containsExactly("to", "kafka:orders");
+    }
+
+    @Test
+    void cardLayoutGivesTitleAndDetail() {
+        RouteDiagramLayoutEngine engine = new RouteDiagramLayoutEngine();
+        engine.setCardStyle(true);
+        LayoutRoute lr = engine.layoutRoute(orders(), 
RouteDiagramLayoutEngine.PADDING);
+
+        LayoutNode setBody = lr.nodes.stream().filter(n -> 
"setBody".equals(n.type)).findFirst().orElseThrow();
+        assertThat(setBody.wrappedLines).containsExactly("setBody", "groovy: 
body.items");
+        // a card with a detail is taller than one with only its title
+        LayoutNode route = lr.nodes.get(0);
+        assertThat(route.wrappedLines).containsExactly("route", "orders");
+
+        // the plain box keeps the whole label (how it wraps depends on the 
fonts of the platform)
+        RouteDiagramLayoutEngine plain = new RouteDiagramLayoutEngine();
+        LayoutRoute plainRoute = plain.layoutRoute(orders(), 
RouteDiagramLayoutEngine.PADDING);
+        assertThat(String.join("", 
plainRoute.nodes.get(2).wrappedLines)).isEqualTo("setBody[groovy{body.items}]");
+        assertThat(setBody.height).isGreaterThan(plain.getBaseNodeHeight());
+    }
+
+    @Test
+    void longDetailWrapsToThreeLines() {
+        RouteDiagramLayoutEngine engine = new RouteDiagramLayoutEngine();
+        engine.setCardStyle(true);
+        RouteInfo r = new RouteInfo();
+        r.routeId = "long";
+        r.nodes.add(node("route", "route[long]", 0));
+        r.nodes.add(node("to", 
"to[https:www.example.com/a/very/long/path/that/does/not/fit/on/one/line/of/a/card"
+                               + 
"?with=many&query=parameters&and=more&of=them&until=it&takes=several&lines=to&show]",
+                1));
+        LayoutRoute lr = engine.layoutRoute(r, 
RouteDiagramLayoutEngine.PADDING);
+        List<String> lines = lr.nodes.get(1).wrappedLines;
+        assertThat(lines.get(0)).isEqualTo("to");
+        // the title and at most three lines of detail; the renderer cuts the 
last one
+        assertThat(lines).hasSize(4);
+    }
+
+    @Test
+    void everyIconHasAnOutlineOnTheGrid() {
+        assertThat(RouteDiagramIcons.names()).hasSizeGreaterThan(15);
+        for (String name : RouteDiagramIcons.names()) {
+            Rectangle2D bounds = RouteDiagramIcons.icon(name).getBounds2D();
+            assertThat(bounds.getWidth()).as(name).isPositive();
+            assertThat(bounds.getHeight()).as(name).isPositive();
+            assertThat(bounds.getMinX()).as(name).isGreaterThanOrEqualTo(0.5);
+            assertThat(bounds.getMinY()).as(name).isGreaterThanOrEqualTo(0.5);
+            
assertThat(bounds.getMaxX()).as(name).isLessThanOrEqualTo(RouteDiagramIcons.GRID
 - 0.5);
+            
assertThat(bounds.getMaxY()).as(name).isLessThanOrEqualTo(RouteDiagramIcons.GRID
 - 0.5);
+        }
+    }
+
+    @Test
+    void iconOfEachKindOfStep() {
+        assertThat(RouteDiagramIcons.iconName("from")).isEqualTo("log-in");
+        assertThat(RouteDiagramIcons.iconName("to")).isEqualTo("log-out");
+        assertThat(RouteDiagramIcons.iconName("setBody")).isEqualTo("braces");
+        
assertThat(RouteDiagramIcons.iconName("setHeader")).isEqualTo("heading");
+        
assertThat(RouteDiagramIcons.iconName("choice")).isEqualTo("git-branch");
+        assertThat(RouteDiagramIcons.iconName("someNewEip")).isEqualTo("box");
+        assertThat(RouteDiagramIcons.iconName(null)).isEqualTo("box");
+    }
+
+    @Test
+    void pathDataAsLucideWritesIt() {
+        // numbers run together (1.1.9 is 1.1 and .9) and an arc repeated by 
more numbers
+        Shape braces = RouteDiagramIcons.path("M8 3H7a2 2 0 0 0-2 2v5a2 2 0 0 
1-2 2 2 2 0 0 1 2 2v5c0 1.1.9 2 2 2h1");
+        Rectangle2D b = braces.getBounds2D();
+        assertThat(b.getMinX()).isEqualTo(3.0, within(0.01));
+        assertThat(b.getMaxX()).isEqualTo(8.0, within(0.01));
+        assertThat(b.getMinY()).isEqualTo(3.0, within(0.01));
+        assertThat(b.getMaxY()).isEqualTo(21.0, within(0.01));
+
+        // a half circle with the sweep flag bulges to the right of its chord
+        Rectangle2D arc = RouteDiagramIcons.path("M12 2a10 10 0 0 1 0 
20").getBounds2D();
+        assertThat(arc.getMaxX()).isEqualTo(22.0, within(0.01));
+        assertThat(arc.getMinX()).isEqualTo(12.0, within(0.01));
+
+        // an exponent, and an e without its digits which is not a number
+        Rectangle2D exp = RouteDiagramIcons.path("M1e1 2.5E-1L20 
2e+1").getBounds2D();
+        assertThat(exp.getMinX()).isEqualTo(10.0, within(0.01));
+        assertThat(exp.getMinY()).isEqualTo(0.25, within(0.01));
+        assertThat(exp.getMaxY()).isEqualTo(20.0, within(0.01));
+        assertThatThrownBy(() -> RouteDiagramIcons.path("M1e 
2")).isInstanceOf(IllegalArgumentException.class)
+                .hasMessageStartingWith("Expected a number at 2");
+
+        assertThatThrownBy(() -> RouteDiagramIcons.path("12 
2")).isInstanceOf(IllegalArgumentException.class);
+        assertThatThrownBy(() -> RouteDiagramIcons.path("M12 2Q1 1 2 
2")).isInstanceOf(IllegalArgumentException.class);
+    }
+
+    @Test
+    void renderCards() {
+        for (String theme : List.of("light", "dark", "transparent")) {
+            DiagramColors colors = DiagramColors.parse(theme);
+            RouteDiagramLayoutEngine engine = new RouteDiagramLayoutEngine();
+            engine.setCardStyle(true);
+            LayoutRoute lr = engine.layoutRoute(orders(), 
RouteDiagramLayoutEngine.PADDING);
+            BufferedImage image = new 
RouteDiagramRenderer().renderDiagram(List.of(lr), lr.maxY, colors);
+
+            // the card is filled with the card color, right of its text
+            LayoutNode setBody = lr.nodes.get(2);
+            int x = setBody.x + engine.getNodeWidth() - 
RouteDiagramLayoutEngine.CARD_PAD / 2;
+            int y = setBody.y + setBody.height / 2;
+            assertThat(new Color(image.getRGB(x, 
y))).as(theme).isEqualTo(colors.getCardBg());
+            // the circle of the icon has the color of the kind of step (a 
transform)
+            int cx = setBody.x + RouteDiagramLayoutEngine.CARD_PAD + 2 * 
RouteDiagramLayoutEngine.SCALE;
+            assertThat(new Color(image.getRGB(cx, 
y))).as(theme).isEqualTo(colors.getNodeTransform());
+        }
+    }
+}
diff --git a/docs/components/modules/ROOT/images/route-diagram-card.png 
b/docs/components/modules/ROOT/images/route-diagram-card.png
new file mode 120000
index 000000000000..d28d9654f6d0
--- /dev/null
+++ b/docs/components/modules/ROOT/images/route-diagram-card.png
@@ -0,0 +1 @@
+../../../../../components/camel-diagram/src/main/docs/route-diagram-card.png
\ No newline at end of file
diff --git 
a/docs/user-manual/modules/ROOT/images/images/route-diagram-sample.png 
b/docs/user-manual/modules/ROOT/images/images/route-diagram-sample.png
index 38b90972e375..92f73fc6903d 100644
Binary files 
a/docs/user-manual/modules/ROOT/images/images/route-diagram-sample.png and 
b/docs/user-manual/modules/ROOT/images/images/route-diagram-sample.png differ
diff --git a/docs/user-manual/modules/ROOT/images/jbang/route-diagram-dark.png 
b/docs/user-manual/modules/ROOT/images/jbang/route-diagram-dark.png
deleted file mode 100644
index 70699e1b9348..000000000000
Binary files 
a/docs/user-manual/modules/ROOT/images/jbang/route-diagram-dark.png and 
/dev/null differ
diff --git a/docs/user-manual/modules/ROOT/images/jbang/route-diagram-light.png 
b/docs/user-manual/modules/ROOT/images/jbang/route-diagram-light.png
deleted file mode 100644
index 8ec2e522c132..000000000000
Binary files 
a/docs/user-manual/modules/ROOT/images/jbang/route-diagram-light.png and 
/dev/null differ
diff --git 
a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc 
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
index 8c6f5613aff5..4b78b06a04c7 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
@@ -2602,6 +2602,34 @@ cannot work out, such as a lambda or a processor, is 
shown as a placeholder, and
 listed (with `--json`, as an `unread` array in the JSON). Use the new 
`--compile` option to compile the sources and
 load their routes as before.
 
+=== camel-diagram - a route diagram image draws the steps as cards
+
+The PNG image of a route diagram draws each step as a card: an icon for the 
kind of step, in a circle of the color of
+its kind, the kind in bold (such as `setBody`) and its detail below (such as 
`groovy: body.items`). Before, a step was
+a box filled with the color of its kind, with its code 
(`setBody[groovy{body.items}]`) in one line. This applies to
+the `dark`, `light` and `transparent` themes, in `camel cmd route-diagram`, 
`camel get history --diagram`,
+`camel get error --diagram`, the `camel_render_route_diagram` MCP tool, the 
`route-diagram` developer console
+(`format=png`) and the `RouteDiagramDumper`. A card is taller than a box, so 
the image of the same route is taller.
+The ASCII and Unicode diagrams, the topology diagram and the web component do 
not change.
+
+A custom color spec sets the colors of the cards with `card`, `cardBorder`, 
`cardText` and `cardDetail`. The `text`
+color no longer applies to the steps of a route diagram (it still does to the 
topology diagram).
+
+The `RouteDiagramRenderer(nodeWidth, fontSizeScaled, nodeTextPadding, 
metrics)` constructor is deprecated, as the text
+of a card starts after its icon and `nodeTextPadding` is not used. Use 
`RouteDiagramRenderer(nodeWidth, fontSizeScaled,
+metrics)` instead.
+
+A program that lays out routes with `RouteDiagramLayoutEngine` and draws them 
with `RouteDiagramRenderer` must call
+`engine.setCardStyle(true)` before `layoutRoute`. Without it, the cards get 
the height and text of the old boxes.
+
+`camel cmd route-diagram --output=diagram.png` without `--theme` now saves the 
PNG image in the `light` theme. Before,
+the default theme was `unicode` also for a file, so the command saved the text 
diagram in `diagram.txt` instead. A
+diagram in the terminal is still text (`unicode`) by default, and a file that 
does not end with `.png` still gets the
+text diagram. Use `--theme=unicode` to save the text diagram.
+
+The `camel_render_route_diagram` MCP tool also draws in the `light` theme when 
no theme is given (before, it failed
+without a theme).
+
 === camel-jbang - export to Quarkus keeps the names in `quarkus.*` keys
 
 `camel export --runtime=quarkus` wrote every `quarkus.*` key except 
`quarkus.hawtio.*` in dash case: an upper-case
diff --git 
a/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-cmd-route-diagram.adoc
 
b/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-cmd-route-diagram.adoc
index 29f4701ff341..998388542e89 100644
--- 
a/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-cmd-route-diagram.adoc
+++ 
b/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-cmd-route-diagram.adoc
@@ -31,7 +31,7 @@ camel cmd route-diagram [options]
 | `--metric` | Whether to include live metrics (only possible for running 
Camel application) | true | boolean
 | `--node-label` | What text to display in diagram nodes: code, description, 
or both (default) | both | String
 | `--output` | Save diagram to a file (PNG for image themes, text for ascii 
theme) |  | String
-| `--theme` | Color theme preset (dark, light, transparent, ascii, unicode) or 
custom colors (e.g. bg=#1e1e1e:from=#2e7d32:to=#1565c0). Values can be #hex or 
ANSI color names (e.g. from=seagreen:to=steelblue). Use bg= for transparent. 
Use ascii/unicode for plain text output. Can also be set via DIAGRAM_COLORS env 
var. | unicode | String
+| `--theme` | Color theme preset (dark, light, transparent, ascii, unicode) or 
custom colors (e.g. bg=#1e1e1e:from=#2e7d32:to=#1565c0). Values can be #hex or 
ANSI color names (e.g. from=seagreen:to=steelblue). Use bg= for transparent. 
Use ascii/unicode for plain text output. Can also be set via DIAGRAM_COLORS env 
var. Default: unicode in the terminal, and light when --output is a .png file. 
|  | String
 | `--timeout` | How long to wait for the integration to answer, in millis or 
as a duration such as 30s | 10000 | long
 | `--watch` | Execute periodically and showing output fullscreen |  | boolean
 | `--width` | Image width in pixels (0 = auto) | 0 | int
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/generated/resources/META-INF/camel-jbang-commands-metadata.json
 
b/dsl/camel-jbang/camel-jbang-core/src/generated/resources/META-INF/camel-jbang-commands-metadata.json
index 42e987a3ac18..2b2de7207d08 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/generated/resources/META-INF/camel-jbang-commands-metadata.json
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/generated/resources/META-INF/camel-jbang-commands-metadata.json
@@ -3,7 +3,7 @@
     { "name": "ask", "fullName": "ask", "description": "Ask a question about a 
running Camel application using AI", "sourceClass": 
"org.apache.camel.dsl.jbang.core.commands.Ask", "options": [ { "names": 
"--api-key", "description": "API key. Also reads ANTHROPIC_API_KEY, 
OPENAI_API_KEY, WATSONX_APIKEY, or LLM_API_KEY env vars", "javaType": 
"java.lang.String", "type": "string" }, { "names": "--api-type", "description": 
"API type: 'ollama', 'openai', 'anthropic', or 'watsonx'", "javaType":  [...]
     { "name": "bind", "fullName": "bind", "description": "DEPRECATED: Bind 
source and sink Kamelets as a new Camel integration", "deprecated": true, 
"sourceClass": "org.apache.camel.dsl.jbang.core.commands.bind.Bind", "options": 
[ { "names": "--error-handler", "description": "Add error handler 
(none|log|sink:<endpoint>). Sink endpoints are expected in the format 
[[apigroup\/]version:]kind:[namespace\/]name, plain Camel URIs or Kamelet 
name.", "javaType": "java.lang.String", "type": "stri [...]
     { "name": "catalog", "fullName": "catalog", "description": "List artifacts 
from Camel Catalog", "sourceClass": 
"org.apache.camel.dsl.jbang.core.commands.catalog.CatalogCommand", "options": [ 
{ "names": "-h,--help", "description": "Display the help and sub-commands", 
"javaType": "boolean", "type": "boolean" } ], "subcommands": [ { "name": 
"component", "fullName": "catalog component", "description": "List components 
from the Camel Catalog", "sourceClass": "org.apache.camel.dsl.jbang.co [...]
-    { "name": "cmd", "fullName": "cmd", "description": "Performs commands in 
the running Camel integrations, such as start\/stop route, or change logging 
levels.", "sourceClass": 
"org.apache.camel.dsl.jbang.core.commands.action.CamelAction", "options": [ { 
"names": "-h,--help", "description": "Display the help and sub-commands", 
"javaType": "boolean", "type": "boolean" } ], "subcommands": [ { "name": 
"browse", "fullName": "cmd browse", "description": "Browse pending messages on 
endpoints [...]
+    { "name": "cmd", "fullName": "cmd", "description": "Performs commands in 
the running Camel integrations, such as start\/stop route, or change logging 
levels.", "sourceClass": 
"org.apache.camel.dsl.jbang.core.commands.action.CamelAction", "options": [ { 
"names": "-h,--help", "description": "Display the help and sub-commands", 
"javaType": "boolean", "type": "boolean" } ], "subcommands": [ { "name": 
"browse", "fullName": "cmd browse", "description": "Browse pending messages on 
endpoints [...]
     { "name": "completion", "fullName": "completion", "description": "Generate 
completion script for bash\/zsh", "sourceClass": 
"org.apache.camel.dsl.jbang.core.commands.Complete", "options": [ { "names": 
"-h,--help", "description": "Display the help and sub-commands", "javaType": 
"boolean", "type": "boolean" } ] },
     { "name": "config", "fullName": "config", "description": "Get and set user 
configuration values", "sourceClass": 
"org.apache.camel.dsl.jbang.core.commands.config.ConfigCommand", "options": [ { 
"names": "-h,--help", "description": "Display the help and sub-commands", 
"javaType": "boolean", "type": "boolean" } ], "subcommands": [ { "name": "get", 
"fullName": "config get", "description": "Display user configuration value", 
"sourceClass": "org.apache.camel.dsl.jbang.core.commands.config. [...]
     { "name": "debug", "fullName": "debug", "description": "Debug local Camel 
integration", "sourceClass": "org.apache.camel.dsl.jbang.core.commands.Debug", 
"options": [ { "names": "--ago", "description": "Use ago instead of yyyy-MM-dd 
HH:mm:ss in timestamp.", "javaType": "boolean", "type": "boolean" }, { "names": 
"--background", "description": "Run in the background", "defaultValue": 
"false", "javaType": "boolean", "type": "boolean" }, { "names": 
"--background-wait", "description": "How [...]
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/CamelHistoryAction.java
 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/CamelHistoryAction.java
index 056b02e7ef6f..05b5e049428b 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/CamelHistoryAction.java
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/CamelHistoryAction.java
@@ -448,6 +448,7 @@ public class CamelHistoryAction extends ActionWatchCommand {
         // layout
         RouteDiagramLayoutEngine engine
                 = new RouteDiagramLayoutEngine(180, 12, NodeLabelMode.BOTH);
+        engine.setCardStyle(!isTextTheme());
 
         List<LayoutRoute> layoutRoutes = new ArrayList<>();
         int currentY = RouteDiagramLayoutEngine.PADDING;
@@ -466,7 +467,7 @@ public class CamelHistoryAction extends ActionWatchCommand {
         } else {
             DiagramColors colors = DiagramColors.parse(theme);
             RouteDiagramRenderer renderer = new RouteDiagramRenderer(
-                    engine.getNodeWidth(), 12 * 
RouteDiagramLayoutEngine.SCALE, engine.getNodeTextPadding(), false);
+                    engine.getNodeWidth(), 12 * 
RouteDiagramLayoutEngine.SCALE, false);
 
             BufferedImage image;
             try {
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/CamelRouteDiagramAction.java
 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/CamelRouteDiagramAction.java
index 7b4d499a3252..3d757d43cb91 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/CamelRouteDiagramAction.java
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/CamelRouteDiagramAction.java
@@ -26,6 +26,7 @@ import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.LinkedHashSet;
 import java.util.List;
+import java.util.Locale;
 import java.util.Set;
 import java.util.Stack;
 import java.util.stream.Stream;
@@ -103,8 +104,8 @@ public class CamelRouteDiagramAction extends 
ActionWatchCommand {
                                       + "(e.g. 
bg=#1e1e1e:from=#2e7d32:to=#1565c0). Values can be #hex or "
                                       + "ANSI color names (e.g. 
from=seagreen:to=steelblue). "
                                       + "Use bg= for transparent. Use 
ascii/unicode for plain text output. "
-                                      + "Can also be set via DIAGRAM_COLORS 
env var.",
-                        defaultValue = "unicode")
+                                      + "Can also be set via DIAGRAM_COLORS 
env var. "
+                                      + "Default: unicode in the terminal, and 
light when --output is a .png file.")
     String theme;
 
     @CommandLine.Option(names = { "--font-size" },
@@ -171,6 +172,7 @@ public class CamelRouteDiagramAction extends 
ActionWatchCommand {
     public Integer doCall() throws Exception {
         System.setProperty("java.awt.headless", "true");
 
+        resolveTheme();
         boolean textMode = isTextTheme();
         if (!textMode) {
             String colorSpec = System.getenv("DIAGRAM_COLORS");
@@ -204,6 +206,7 @@ public class CamelRouteDiagramAction extends 
ActionWatchCommand {
 
     @Override
     protected Integer doWatchCall() throws Exception {
+        resolveTheme();
         // only attempt to resolve a running integration when a single token 
is given (or none, defaulting to "*"):
         // multiple tokens can only be a set of source files
         String nameOrPid = files.isEmpty() ? "*" : files.get(0);
@@ -271,6 +274,8 @@ public class CamelRouteDiagramAction extends 
ActionWatchCommand {
 
         NodeLabelMode labelMode = parseNodeLabelMode(nodeLabel);
         RouteDiagramLayoutEngine engine = new 
RouteDiagramLayoutEngine(boxWidth, fontSize, labelMode);
+        // an image draws the steps as cards: a title and its detail beside an 
icon
+        engine.setCardStyle(!isTextTheme());
 
         List<LayoutRoute> layoutRoutes = new ArrayList<>();
         int currentY = RouteDiagramLayoutEngine.PADDING;
@@ -309,8 +314,7 @@ public class CamelRouteDiagramAction extends 
ActionWatchCommand {
             }
         } else {
             RouteDiagramRenderer renderer = new RouteDiagramRenderer(
-                    engine.getNodeWidth(), fontSize * 
RouteDiagramLayoutEngine.SCALE, engine.getNodeTextPadding(),
-                    pid > 0 && metric);
+                    engine.getNodeWidth(), fontSize * 
RouteDiagramLayoutEngine.SCALE, pid > 0 && metric);
 
             BufferedImage image;
             try {
@@ -465,6 +469,16 @@ public class CamelRouteDiagramAction extends 
ActionWatchCommand {
         return RouteDiagramHelper.parseRoutes(jo);
     }
 
+    /**
+     * The theme when none is given: a diagram in the terminal is text (an 
image needs a terminal with a graphics
+     * protocol), and a diagram saved as a .png file is an image in the light 
theme.
+     */
+    private void resolveTheme() {
+        if (theme == null || theme.isBlank()) {
+            theme = output != null && 
output.toLowerCase(Locale.ROOT).endsWith(".png") ? "light" : "unicode";
+        }
+    }
+
     private boolean isTextTheme() {
         return "ascii".equalsIgnoreCase(theme) || 
"unicode".equalsIgnoreCase(theme);
     }
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/process/ListError.java
 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/process/ListError.java
index e6914be3dffb..ad429dd85229 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/process/ListError.java
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/process/ListError.java
@@ -387,6 +387,7 @@ public class ListError extends ProcessWatchCommand {
         // layout
         RouteDiagramLayoutEngine engine
                 = new RouteDiagramLayoutEngine(180, 12, NodeLabelMode.BOTH);
+        engine.setCardStyle(!isTextTheme());
 
         List<LayoutRoute> layoutRoutes = new ArrayList<>();
         int currentY = RouteDiagramLayoutEngine.PADDING;
@@ -405,7 +406,7 @@ public class ListError extends ProcessWatchCommand {
         } else {
             DiagramColors colors = DiagramColors.parse(theme);
             RouteDiagramRenderer renderer = new RouteDiagramRenderer(
-                    engine.getNodeWidth(), 12 * 
RouteDiagramLayoutEngine.SCALE, engine.getNodeTextPadding(), false);
+                    engine.getNodeWidth(), 12 * 
RouteDiagramLayoutEngine.SCALE, false);
 
             BufferedImage image;
             try {
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/action/CamelRouteDiagramActionRunningTest.java
 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/action/CamelRouteDiagramActionRunningTest.java
index 3df2a593adc8..ac652c4384b2 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/action/CamelRouteDiagramActionRunningTest.java
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/action/CamelRouteDiagramActionRunningTest.java
@@ -16,10 +16,17 @@
  */
 package org.apache.camel.dsl.jbang.core.commands.action;
 
+import java.awt.image.BufferedImage;
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.util.List;
 
+import javax.imageio.ImageIO;
+
+import org.apache.camel.diagram.RouteDiagramHelper;
+import org.apache.camel.diagram.RouteDiagramLayoutEngine;
+import org.apache.camel.diagram.RouteDiagramLayoutEngine.NodeLabelMode;
+import org.apache.camel.diagram.RouteDiagramLayoutEngine.RouteInfo;
 import org.apache.camel.dsl.jbang.core.commands.CamelJBangMain;
 import org.apache.camel.dsl.jbang.core.common.CliException;
 import org.apache.camel.dsl.jbang.core.common.CliExitCode;
@@ -121,6 +128,51 @@ class CamelRouteDiagramActionRunningTest extends 
ActionCommandTestSupport {
         assertThat(printer.getOutput()).contains("Diagram saved to: " + 
txt.toAbsolutePath());
     }
 
+    @Test
+    void imageDrawsTheStepsAsCards() throws Exception {
+        writeStatusFile(TEST_PID, "myApp");
+        Path light = dir.resolve("light.png");
+
+        assertThat(callWithResponse(command("--theme=light", "--output=" + 
light)::doCall, structure("route1")))
+                .isZero();
+
+        // the route laid out as cards (a title and its detail), not as boxes 
of one line of code
+        RouteDiagramLayoutEngine engine = new RouteDiagramLayoutEngine(180, 
12, NodeLabelMode.BOTH);
+        engine.setCardStyle(true);
+        RouteInfo route = 
RouteDiagramHelper.parseRoutes(structure("route1")).get(0);
+        int height = engine.layoutRoute(route, 
RouteDiagramLayoutEngine.PADDING).maxY + RouteDiagramLayoutEngine.V_GAP
+                     + RouteDiagramLayoutEngine.PADDING;
+        assertThat(ImageIO.read(light.toFile()).getHeight()).isEqualTo(height);
+    }
+
+    @Test
+    void pngFileIsInTheLightThemeByDefault() throws Exception {
+        writeStatusFile(TEST_PID, "myApp");
+        Path light = dir.resolve("light.png");
+        Path byDefault = dir.resolve("default.png");
+
+        assertThat(callWithResponse(command("--theme=light", "--output=" + 
light)::doCall, structure("route1")))
+                .isZero();
+        assertThat(callWithResponse(command("--output=" + byDefault)::doCall, 
structure("route1"))).isZero();
+
+        // without a theme a .png file gets the image, not the text diagram 
next to it
+        assertThat(dir.resolve("default.txt")).doesNotExist();
+        BufferedImage lightImage = ImageIO.read(light.toFile());
+        BufferedImage defaultImage = ImageIO.read(byDefault.toFile());
+        assertThat(defaultImage.getHeight()).isEqualTo(lightImage.getHeight());
+        assertThat(defaultImage.getRGB(0, 0)).isEqualTo(lightImage.getRGB(0, 
0));
+    }
+
+    @Test
+    void otherFileIsTextByDefault() throws Exception {
+        writeStatusFile(TEST_PID, "myApp");
+        Path txt = dir.resolve("diagram.txt");
+
+        assertThat(callWithResponse(command("--output=" + txt)::doCall, 
structure("route1"))).isZero();
+
+        assertThat(Files.readString(txt)).contains("route1", "─");
+    }
+
     @Test
     void anIntegrationWithoutRoutesIsAFailure() throws Exception {
         writeStatusFile(TEST_PID, "myApp");
diff --git 
a/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/RouteDiagramTools.java
 
b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/RouteDiagramTools.java
index d71340dc24fb..6c810a18bda2 100644
--- 
a/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/RouteDiagramTools.java
+++ 
b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/RouteDiagramTools.java
@@ -46,7 +46,7 @@ public class RouteDiagramTools {
                         + "Java routes are read by the Java DSL parser, 
without compiling them; a step it cannot "
                         + "work out (a lambda, a processor) is shown as a 
placeholder, and the parts it could not read "
                         + "are listed in the message. "
-                        + "For image themes (dark, light, transparent) the PNG 
is written to disk and the absolute path "
+                        + "For image themes (light, dark, transparent) the PNG 
is written to disk and the absolute path "
                         + "is returned. For text themes (ascii, unicode) the 
diagram is returned directly as text so "
                         + "it can be read and understood by the caller. "
                         + "Useful to visualize a route structure for review, 
documentation, or troubleshooting.")
@@ -55,7 +55,7 @@ public class RouteDiagramTools {
             @ToolArg(description = "Optional output file path. For image 
themes a PNG is written; for text themes a .txt "
                                    + "file is written. If not specified, a 
temporary file is created.",
                      required = false) String outputFile,
-            @ToolArg(description = "Color theme: 'dark' (default), 'light', 
'transparent', 'ascii' (plain ASCII art), "
+            @ToolArg(description = "Color theme: 'light' (default), 'dark', 
'transparent', 'ascii' (plain ASCII art), "
                                    + "'unicode' (box-drawing characters), or a 
custom spec like "
                                    + "'bg=#1e1e1e:from=#2e7d32:to=#1565c0'. "
                                    + "Use 'ascii' or 'unicode' to get a text 
diagram that can be read directly.",
diff --git 
a/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/RouteDiagramToolsTest.java
 
b/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/RouteDiagramToolsTest.java
index a257762b4bf6..95966074082d 100644
--- 
a/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/RouteDiagramToolsTest.java
+++ 
b/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/RouteDiagramToolsTest.java
@@ -16,9 +16,14 @@
  */
 package org.apache.camel.dsl.jbang.core.commands.mcp;
 
+import java.nio.file.Files;
+import java.nio.file.Path;
+
 import io.quarkiverse.mcp.server.ToolCallException;
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
 
+import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatThrownBy;
 
 class RouteDiagramToolsTest {
@@ -70,4 +75,31 @@ class RouteDiagramToolsTest {
                 .isInstanceOf(ToolCallException.class)
                 .hasMessageContaining("does not exist");
     }
+
+    @Test
+    void imageWithoutATheme(@TempDir Path dir) throws Exception {
+        Path source = dir.resolve("orders.camel.yaml");
+        Files.writeString(source, """
+                - route:
+                    id: orders
+                    from:
+                      uri: timer:orders
+                      steps:
+                        - setBody:
+                            constant: hello
+                        - to:
+                            uri: log:orders
+                """);
+        Path png = dir.resolve("orders.png");
+        RouteDiagramTools tools = new RouteDiagramTools();
+
+        RouteDiagramTools.RouteDiagramResult result = 
tools.camel_render_route_diagram(
+                source.toString(), png.toString(), null, null, null, null, 
null, null, null);
+
+        assertThat(result.success()).isTrue();
+        
assertThat(result.outputFile()).isEqualTo(png.toAbsolutePath().toString());
+        // a PNG image, not the text diagram
+        assertThat(Files.readAllBytes(png)).startsWith(0x89, 'P', 'N', 'G');
+        assertThat(result.asciiDiagram()).isNull();
+    }
 }

Reply via email to