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

davsclaus pushed a commit to branch CAMEL-26348-diagram-card-style
in repository https://gitbox.apache.org/repos/asf/camel.git

commit 59d1e68452e7e1d4947dfc71e9a5782ab2e346cc
Author: Claus Ibsen <[email protected]>
AuthorDate: Tue Oct 27 16:41:44 2026 +0100

    CAMEL-26348: camel-diagram - a card style for route diagrams
    
    The card and card-dark themes draw each step of a route diagram as a card: 
the icon of its kind in a circle of the
    color of the kind, the kind in bold (setBody) and its detail below (groovy: 
body.items, from the code
    setBody[groovy{body.items}]). A custom color spec draws cards with 
style=card. The icons are the Lucide icons of the
    camel-route-diagram web component, drawn with Java2D from their path data.
    
    The themes are available in camel cmd route-diagram, camel get history 
--diagram, camel get error --diagram and the
    camel_render_route_diagram MCP tool. The existing themes do not change.
    
    Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
    Signed-off-by: Claus Ibsen <[email protected]>
---
 .../org/apache/camel/catalog/docs/diagram.adoc     |  32 ++
 .../camel-diagram/src/main/docs/diagram.adoc       |  32 ++
 .../camel/diagram/DefaultRouteDiagramDumper.java   |  11 +-
 .../apache/camel/diagram/RouteDiagramIcons.java    | 363 +++++++++++++++++++++
 .../camel/diagram/RouteDiagramLayoutEngine.java    | 113 ++++++-
 .../apache/camel/diagram/RouteDiagramRenderer.java | 145 +++++++-
 .../camel/diagram/THIRD-PARTY-NOTICES.txt          |   2 +-
 .../camel/diagram/RouteDiagramCardStyleTest.java   | 211 ++++++++++++
 .../modules/ROOT/images/route-diagram-card.png     | Bin 0 -> 41025 bytes
 .../camel-jbang-cmd-route-diagram.adoc             |   2 +-
 .../jbang-commands/camel-jbang-get-error.adoc      |   2 +-
 .../jbang-commands/camel-jbang-get-history.adoc    |   2 +-
 .../META-INF/camel-jbang-commands-metadata.json    |   4 +-
 .../core/commands/action/CamelHistoryAction.java   |   3 +-
 .../commands/action/CamelRouteDiagramAction.java   |   4 +-
 .../dsl/jbang/core/commands/process/ListError.java |   3 +-
 .../action/CamelRouteDiagramActionRunningTest.java |  20 ++
 .../jbang/core/commands/mcp/RouteDiagramTools.java |   3 +-
 18 files changed, 924 insertions(+), 28 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..42995806bb9a 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
@@ -135,6 +135,8 @@ The following built-in themes are available:
 * `dark` - dark background (default)
 * `light` - light background
 * `transparent` - transparent background
+* `card` - each step as a white card (see <<_card_style,Card Style>>)
+* `card-dark` - each step as a dark card
 
 Custom colors can be specified using the format:
 
@@ -152,6 +154,36 @@ To use dark theme
 camel cmd route-diagram MyRoute.java --theme=dark
 ----
 
+=== Card Style
+
+The `card` and `card-dark` themes draw 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 --theme=card --output=orders.png
+----
+
+image::ROOT:route-diagram-card.png[A route diagram in the card style: 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.
+
+A custom color spec draws cards with the key `style=card`, and sets the colors 
of the cards with `card` (the
+background of a card), `cardBorder`, `cardText` (the kind) and `cardDetail` 
(the detail):
+
+[source,properties]
+----
+style=card:bg=#ffffff:card=#fafafa:cardBorder=#d0d7de:cardText=#1f2328:cardDetail=#59636e
+----
+
+The icons are from https://lucide.dev[Lucide] (ISC License), the same as the 
embeddable web components.
+
+The card themes are for the PNG diagrams of the Camel CLI (`camel cmd 
route-diagram`, `camel get history --diagram`,
+`camel get error --diagram`) and its MCP tool. The developer console and the 
`RouteDiagramDumper` keep the `dark`, `light`
+and `transparent` themes.
+
 == ASCII Art Rendering
 
 The ASCII art renderer produces plain text diagrams using box-drawing 
characters.
diff --git a/components/camel-diagram/src/main/docs/diagram.adoc 
b/components/camel-diagram/src/main/docs/diagram.adoc
index 65116747f00f..42995806bb9a 100644
--- a/components/camel-diagram/src/main/docs/diagram.adoc
+++ b/components/camel-diagram/src/main/docs/diagram.adoc
@@ -135,6 +135,8 @@ The following built-in themes are available:
 * `dark` - dark background (default)
 * `light` - light background
 * `transparent` - transparent background
+* `card` - each step as a white card (see <<_card_style,Card Style>>)
+* `card-dark` - each step as a dark card
 
 Custom colors can be specified using the format:
 
@@ -152,6 +154,36 @@ To use dark theme
 camel cmd route-diagram MyRoute.java --theme=dark
 ----
 
+=== Card Style
+
+The `card` and `card-dark` themes draw 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 --theme=card --output=orders.png
+----
+
+image::ROOT:route-diagram-card.png[A route diagram in the card style: 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.
+
+A custom color spec draws cards with the key `style=card`, and sets the colors 
of the cards with `card` (the
+background of a card), `cardBorder`, `cardText` (the kind) and `cardDetail` 
(the detail):
+
+[source,properties]
+----
+style=card:bg=#ffffff:card=#fafafa:cardBorder=#d0d7de:cardText=#1f2328:cardDetail=#59636e
+----
+
+The icons are from https://lucide.dev[Lucide] (ISC License), the same as the 
embeddable web components.
+
+The card themes are for the PNG diagrams of the Camel CLI (`camel cmd 
route-diagram`, `camel get history --diagram`,
+`camel get error --diagram`) and its MCP tool. The developer console and the 
`RouteDiagramDumper` keep the `dark`, `light`
+and `transparent` themes.
+
 == ASCII Art Rendering
 
 The ASCII art renderer produces plain text diagrams using box-drawing 
characters.
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..b6a3daafda9d 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(colors.isCard());
 
         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..1045fb0689e2
--- /dev/null
+++ 
b/components/camel-diagram/src/main/java/org/apache/camel/diagram/RouteDiagramIcons.java
@@ -0,0 +1,363 @@
+/*
+ * 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;
+            while (pos < d.length()) {
+                char c = d.charAt(pos);
+                if (Character.isDigit(c)) {
+                    pos++;
+                } else if (c == '.' && !dot) {
+                    dot = true;
+                    pos++;
+                } else if ((c == 'e' || c == 'E') && pos > start) {
+                    pos++;
+                    if (pos < d.length() && (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));
+        }
+
+        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..6c370efdf0f8 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. Off by
+     * default: a renderer turns it on when it draws cards.
+     */
+    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..8587546daff0 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;
@@ -79,10 +80,28 @@ public class RouteDiagramRenderer {
             = 
"bg=:text=#f0f6fc:arrow=#656c76:label=#d1d7e0:from=#238636:to=#1f6feb:eip=#8957e5"
               + 
":choice=#d29922:default=#3d444d:transform=#1b7c83:processor=#bf4b8a";
 
+    // the card style: white (or dark) cards with an icon in a circle of the 
color of the kind of step
+    private static final String CARD_COLORS
+            = 
"style=card:bg=#f6f8fa:arrow=#8c959f:label=#1f2328:card=#ffffff:cardBorder=#d0d7de:cardText=#1f2328"
+              + 
":cardDetail=#59636e:from=#238636:to=#1f6feb:eip=#8957e5:choice=#d29922:default=#59636e"
+              + ":transform=#1b7c83:processor=#bf4b8a";
+    private static final String CARD_DARK_COLORS
+            = 
"style=card:bg=#0d1117:arrow=#656c76:label=#d1d7e0:card=#151b23:cardBorder=#3d444d:cardText=#f0f6fc"
+              + 
":cardDetail=#9198a1:from=#238636:to=#1f6feb:eip=#8957e5:choice=#d29922:default=#656c76"
+              + ":transform=#1b7c83:processor=#bf4b8a";
+
     private static final Map<String, String> COLOR_PRESETS = Map.of(
             "dark", DARK_COLORS,
             "light", LIGHT_COLORS,
-            "transparent", TRANSPARENT_COLORS);
+            "transparent", TRANSPARENT_COLORS,
+            "card", CARD_COLORS,
+            "card-dark", CARD_DARK_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,
@@ -120,7 +139,16 @@ public class RouteDiagramRenderer {
         private Color nodeTransform;
         private Color nodeProcessor;
         private Color nodeExternal;
-
+        private boolean card;
+        private Color cardBg;
+        private Color cardBorder;
+        private Color cardText;
+        private Color cardDetail;
+
+        /**
+         * The colors of a theme: a preset (dark, light, transparent, card, 
card-dark) or a spec of {@code key=color}
+         * pairs separated by colons. The key {@code style=card} draws the 
steps as cards.
+         */
         public static DiagramColors parse(String spec) {
             String resolved = COLOR_PRESETS.getOrDefault(spec, spec);
             Map<String, String> map = new HashMap<>();
@@ -151,6 +179,11 @@ 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.card = "card".equalsIgnoreCase(map.get("style"));
+            c.cardBg = parseColor(map.getOrDefault("card", "#ffffff"));
+            c.cardBorder = parseColor(map.getOrDefault("cardBorder", 
"#d0d7de"));
+            c.cardText = parseColor(map.getOrDefault("cardText", "#1f2328"));
+            c.cardDetail = parseColor(map.getOrDefault("cardDetail", 
"#59636e"));
             return c;
         }
 
@@ -227,6 +260,30 @@ public class RouteDiagramRenderer {
         public Color getNodeExternal() {
             return nodeExternal;
         }
+
+        /**
+         * Whether the steps are drawn as cards (the style key {@code 
style=card}): the layout engine must lay them out
+         * as cards too ({@link RouteDiagramLayoutEngine#setCardStyle}).
+         */
+        public boolean isCard() {
+            return card;
+        }
+
+        public Color getCardBg() {
+            return cardBg;
+        }
+
+        public Color getCardBorder() {
+            return cardBorder;
+        }
+
+        public Color getCardText() {
+            return cardText;
+        }
+
+        public Color getCardDetail() {
+            return cardDetail;
+        }
     }
 
     public BufferedImage renderDiagram(
@@ -495,6 +552,10 @@ public class RouteDiagramRenderer {
     }
 
     private void drawNode(Graphics2D g, LayoutNode node, DiagramColors colors, 
boolean highlightFail) {
+        if (colors.isCard()) {
+            drawCard(g, node, colors, highlightFail);
+            return;
+        }
         Color color = getNodeColor(node.type, colors);
 
         g.setColor(color);
@@ -520,18 +581,84 @@ public class RouteDiagramRenderer {
         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 + "…";
-            }
+            String text = fit(fm, lines.get(i), nodeWidth - nodeTextPadding);
             int textX = node.x + (nodeWidth - fm.stringWidth(text)) / 2;
             g.drawString(text, textX, startY + i * lineHeight);
         }
     }
 
+    /**
+     * A step as a card: a white (or dark) 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 ({@link 
RouteDiagramLayoutEngine#setCardStyle}).
+     */
+    private void drawCard(Graphics2D g, LayoutNode node, DiagramColors colors, 
boolean highlightFail) {
+        int x = node.x;
+        int y = node.y;
+        int h = node.height;
+
+        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(colors.getCardBorder());
+            g.setStroke(new BasicStroke(BORDER_STROKE_WIDTH));
+        }
+        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();
+        }
+    }
+
+    /** 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(
             Graphics2D g, LayoutNode from, LayoutNode to, DiagramColors colors,
             boolean highlighted, RouteDiagramHelper.HighlightStyle 
highlightStyle) {
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..7b35d5034aa9
--- /dev/null
+++ 
b/components/camel-diagram/src/test/java/org/apache/camel/diagram/RouteDiagramCardStyleTest.java
@@ -0,0 +1,211 @@
+/*
+ * 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 cardPresetsDrawCards() {
+        assertThat(DiagramColors.parse("card").isCard()).isTrue();
+        
assertThat(DiagramColors.parse("card").getCardBg()).isEqualTo(Color.WHITE);
+        assertThat(DiagramColors.parse("card-dark").isCard()).isTrue();
+        
assertThat(DiagramColors.parse("card-dark").getCardBg()).isNotEqualTo(Color.WHITE);
+        assertThat(DiagramColors.parse("dark").isCard()).isFalse();
+        assertThat(DiagramColors.parse("light").isCard()).isFalse();
+        // a custom spec turns the card style on with style=card
+        DiagramColors custom = 
DiagramColors.parse("style=card:bg=#ffffff:card=#fafafa");
+        assertThat(custom.isCard()).isTrue();
+        assertThat(custom.getCardBg()).isEqualTo(new Color(0xfafafa));
+    }
+
+    @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");
+
+        RouteDiagramLayoutEngine plain = new RouteDiagramLayoutEngine();
+        LayoutRoute plainRoute = plain.layoutRoute(orders(), 
RouteDiagramLayoutEngine.PADDING);
+        
assertThat(plainRoute.nodes.get(2).wrappedLines).containsExactly("setBody[groovy{body.items}]");
+        
assertThat(setBody.height).isGreaterThan(plainRoute.nodes.get(2).height);
+    }
+
+    @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));
+
+        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("card", "card-dark")) {
+            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 100644
index 000000000000..d06cfa3d941e
Binary files /dev/null and 
b/docs/components/modules/ROOT/images/route-diagram-card.png differ
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..71f62d716aba 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, card, card-dark, 
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
 | `--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/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-get-error.adoc 
b/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-get-error.adoc
index ff1de0e237bf..6460e462aba3 100644
--- 
a/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-get-error.adoc
+++ 
b/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-get-error.adoc
@@ -33,7 +33,7 @@ camel get error [options]
 | `--route` | Filter by route ID |  | String
 | `--show` | Comma-separated detail sections to show: body, headers, 
properties, variables, history, stackTrace, or 'all' for all sections |  | 
String
 | `--sort` | Sort by pid, name or age | pid | String
-| `--theme` | Diagram color theme (ascii, unicode, dark, light, transparent, 
or custom) | unicode | String
+| `--theme` | Diagram color theme (ascii, unicode, dark, light, transparent, 
card, card-dark, or custom) | unicode | String
 | `--watch` | Execute periodically and showing output fullscreen |  | boolean
 | `-h,--help` | Display the help and sub-commands |  | boolean
 |===
diff --git 
a/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-get-history.adoc
 
b/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-get-history.adoc
index 0f642d305015..c99b5cd76209 100644
--- 
a/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-get-history.adoc
+++ 
b/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-get-history.adoc
@@ -35,7 +35,7 @@ camel get history [options]
 | `--show-exchange-variables` | Show exchange variables in debug messages | 
true | boolean
 | `--show-headers` | Show message headers in debug messages | true | boolean
 | `--source` | Prefer to display source filename/code instead of IDs |  | 
boolean
-| `--theme` | Diagram color theme (ascii, unicode, dark, light, transparent, 
or custom) | unicode | String
+| `--theme` | Diagram color theme (ascii, unicode, dark, light, transparent, 
card, card-dark, or custom) | unicode | String
 | `--timeout` | How long to wait for the integration to answer, in millis or 
as a duration such as 30s | 10000 | long
 | `--timestamp` | Print timestamp. | true | boolean
 | `--watch` | Execute periodically and showing output fullscreen |  | boolean
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..3c96ebbe28ff 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 [...]
@@ -15,7 +15,7 @@
     { "name": "eval", "fullName": "eval", "description": "Evaluate Camel 
expressions and scripts", "sourceClass": 
"org.apache.camel.dsl.jbang.core.commands.EvalCommand", "options": [ { "names": 
"-h,--help", "description": "Display the help and sub-commands", "javaType": 
"boolean", "type": "boolean" } ], "subcommands": [ { "name": "expression", 
"fullName": "eval expression", "description": "Evaluates Camel expression", 
"sourceClass": "org.apache.camel.dsl.jbang.core.commands.action.EvalEx [...]
     { "name": "explain", "fullName": "explain", "description": "Explain what a 
Camel route does using AI\/LLM", "sourceClass": 
"org.apache.camel.dsl.jbang.core.commands.Explain", "options": [ { "names": 
"--api-key", "description": "API key for authentication. 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' (OpenAI-compatible), ' [...]
     { "name": "export", "fullName": "export", "description": "Export to other 
runtimes (Camel Main, Spring Boot, or Quarkus)", "sourceClass": 
"org.apache.camel.dsl.jbang.core.commands.Export", "options": [ { "names": 
"--build-property", "description": "Maven build properties, ex. 
--build-property=prop1=foo", "javaType": "java.util.List", "type": "array" }, { 
"names": "--camel-spring-boot-version", "description": "Camel version to use 
with Spring Boot", "javaType": "java.lang.String", "ty [...]
-    { "name": "get", "fullName": "get", "description": "Get status of Camel 
integrations", "sourceClass": 
"org.apache.camel.dsl.jbang.core.commands.process.CamelStatus", "options": [ { 
"names": "--watch", "description": "Execute periodically and showing output 
fullscreen", "javaType": "boolean", "type": "boolean" }, { "names": 
"-h,--help", "description": "Display the help and sub-commands", "javaType": 
"boolean", "type": "boolean" } ], "subcommands": [ { "name": "activity", 
"fullName": " [...]
+    { "name": "get", "fullName": "get", "description": "Get status of Camel 
integrations", "sourceClass": 
"org.apache.camel.dsl.jbang.core.commands.process.CamelStatus", "options": [ { 
"names": "--watch", "description": "Execute periodically and showing output 
fullscreen", "javaType": "boolean", "type": "boolean" }, { "names": 
"-h,--help", "description": "Display the help and sub-commands", "javaType": 
"boolean", "type": "boolean" } ], "subcommands": [ { "name": "activity", 
"fullName": " [...]
     { "name": "harden", "fullName": "harden", "description": "Suggest security 
hardening for Camel routes using AI\/LLM", "sourceClass": 
"org.apache.camel.dsl.jbang.core.commands.Harden", "options": [ { "names": 
"--api-key", "description": "API key for authentication. Also reads 
OPENAI_API_KEY or LLM_API_KEY env vars", "javaType": "java.lang.String", 
"type": "string" }, { "names": "--api-type", "description": "API type: 'ollama' 
or 'openai' (OpenAI-compatible)", "defaultValue": "ollama", [...]
     { "name": "hawtio", "fullName": "hawtio", "description": "Launch Hawtio 
web console", "sourceClass": 
"org.apache.camel.dsl.jbang.core.commands.process.Hawtio", "options": [ { 
"names": "--host", "description": "Hostname to bind the Hawtio web console to", 
"defaultValue": "127.0.0.1", "javaType": "java.lang.String", "type": "string" 
}, { "names": "--open-url,--openUrl", "description": "To automatic open Hawtio 
web console in the web browser (without an interactive terminal the default  
[...]
     { "name": "infra", "fullName": "infra", "description": "List and Run 
external services for testing and prototyping", "sourceClass": 
"org.apache.camel.dsl.jbang.core.commands.infra.InfraCommand", "options": [ { 
"names": "--json", "description": "Output in JSON Format", "javaType": 
"boolean", "type": "boolean" }, { "names": "-h,--help", "description": "Display 
the help and sub-commands", "javaType": "boolean", "type": "boolean" } ], 
"subcommands": [ { "name": "get", "fullName": "infra  [...]
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..fc93ab392e6a 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
@@ -151,7 +151,7 @@ public class CamelHistoryAction extends ActionWatchCommand {
     boolean diagram;
 
     @CommandLine.Option(names = { "--theme" }, defaultValue = "unicode",
-                        description = "Diagram color theme (ascii, unicode, 
dark, light, transparent, or custom)")
+                        description = "Diagram color theme (ascii, unicode, 
dark, light, transparent, card, card-dark, or custom)")
     String theme = "unicode";
 
     @CommandLine.Mixin
@@ -448,6 +448,7 @@ public class CamelHistoryAction extends ActionWatchCommand {
         // layout
         RouteDiagramLayoutEngine engine
                 = new RouteDiagramLayoutEngine(180, 12, NodeLabelMode.BOTH);
+        engine.setCardStyle(!isTextTheme() && 
DiagramColors.parse(theme).isCard());
 
         List<LayoutRoute> layoutRoutes = new ArrayList<>();
         int currentY = RouteDiagramLayoutEngine.PADDING;
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..79be0a657076 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
@@ -99,7 +99,7 @@ public class CamelRouteDiagramAction extends 
ActionWatchCommand {
     String output;
 
     @CommandLine.Option(names = { "--theme" },
-                        description = "Color theme preset (dark, light, 
transparent, ascii, unicode) or custom colors "
+                        description = "Color theme preset (dark, light, 
transparent, card, card-dark, 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. "
@@ -271,6 +271,8 @@ public class CamelRouteDiagramAction extends 
ActionWatchCommand {
 
         NodeLabelMode labelMode = parseNodeLabelMode(nodeLabel);
         RouteDiagramLayoutEngine engine = new 
RouteDiagramLayoutEngine(boxWidth, fontSize, labelMode);
+        // the card themes lay the steps out as cards: a title and its detail 
beside an icon
+        engine.setCardStyle(colors != null && colors.isCard());
 
         List<LayoutRoute> layoutRoutes = new ArrayList<>();
         int currentY = RouteDiagramLayoutEngine.PADDING;
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..d4556a39e7f8 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
@@ -118,7 +118,7 @@ public class ListError extends ProcessWatchCommand {
     boolean diagram;
 
     @CommandLine.Option(names = { "--theme" }, defaultValue = "unicode",
-                        description = "Diagram color theme (ascii, unicode, 
dark, light, transparent, or custom)")
+                        description = "Diagram color theme (ascii, unicode, 
dark, light, transparent, card, card-dark, or custom)")
     String theme = "unicode";
 
     public ListError(CamelJBangMain main) {
@@ -387,6 +387,7 @@ public class ListError extends ProcessWatchCommand {
         // layout
         RouteDiagramLayoutEngine engine
                 = new RouteDiagramLayoutEngine(180, 12, NodeLabelMode.BOTH);
+        engine.setCardStyle(!isTextTheme() && 
DiagramColors.parse(theme).isCard());
 
         List<LayoutRoute> layoutRoutes = new ArrayList<>();
         int currentY = RouteDiagramLayoutEngine.PADDING;
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..41c1972a57f6 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,13 @@
  */
 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.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 +124,23 @@ class CamelRouteDiagramActionRunningTest extends 
ActionCommandTestSupport {
         assertThat(printer.getOutput()).contains("Diagram saved to: " + 
txt.toAbsolutePath());
     }
 
+    @Test
+    void cardThemeDrawsTheStepsAsCards() throws Exception {
+        writeStatusFile(TEST_PID, "myApp");
+        Path light = dir.resolve("light.png");
+        Path card = dir.resolve("card.png");
+
+        assertThat(callWithResponse(command("--theme=light", "--output=" + 
light)::doCall, structure("route1")))
+                .isZero();
+        assertThat(callWithResponse(command("--theme=card", "--output=" + 
card)::doCall, structure("route1")))
+                .isZero();
+
+        // the cards have their title and detail on two lines, so the same 
route is taller than in the light theme
+        BufferedImage lightImage = ImageIO.read(light.toFile());
+        BufferedImage cardImage = ImageIO.read(card.toFile());
+        
assertThat(cardImage.getHeight()).isGreaterThan(lightImage.getHeight());
+    }
+
     @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..cad504decc17 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
@@ -55,7 +55,8 @@ 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: 'dark' (default), 'light', 
'transparent', 'card' (white cards with an icon, "
+                                   + "the kind of each step and its detail), 
'card-dark', '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.",

Reply via email to