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 357f39759dda CAMEL-23636: Add camel-route-diagram web component
357f39759dda is described below

commit 357f39759dda137b0d95b80f32feee0288411480
Author: Adriano Machado <[email protected]>
AuthorDate: Fri Jun 19 08:35:15 2026 -0400

    CAMEL-23636: Add camel-route-diagram web component
    
    Add a <camel-route-diagram> vanilla Web Component to the camel-diagram
    module that renders interactive SVG route diagrams in the browser. The
    component is a single self-contained JS file with no npm/Node.js
    dependency, served as a static resource from META-INF/resources. It
    features a JS port of RouteDiagramLayoutEngine, CSS custom-property
    theming with automatic dark mode, Lucide icons (ISC), ARIA accessibility,
    and AbortController-based fetch cancellation. Also consolidates the
    duplicate wrapText logic from RouteDiagramAsciiRenderer and
    TopologyAsciiRenderer into RouteDiagramHelper.
    
    Closes #24064
    
    Co-Authored-By: Claude <[email protected]>
---
 components/camel-diagram/pom.xml                   |  24 +-
 .../camel-diagram/src/main/docs/diagram.adoc       |  69 ++
 .../camel/diagram/RouteDiagramAsciiRenderer.java   |  47 +-
 .../apache/camel/diagram/RouteDiagramHelper.java   |  68 +-
 .../apache/camel/diagram/RouteDiagramRenderer.java |   6 +-
 .../camel/diagram/TopologyAsciiRenderer.java       |  47 +-
 .../camel/diagram/THIRD-PARTY-NOTICES.txt          |  23 +
 .../resources/camel/diagram/camel-route-diagram.js | 492 ++++++++++++
 .../camel/diagram/RouteDiagramHelperTest.java      |  55 ++
 .../diagram/RouteDiagramLayoutEngineTest.java      | 234 ++++++
 .../org/apache/camel/diagram/RouteDiagramTest.java |   6 +-
 .../apache/camel/diagram/TopologyDiagramTest.java  |  10 -
 .../camel/diagram/WebComponentBundleTest.java      |  91 +++
 .../src/test/resources/integration-test.html       | 862 +++++++++++++++++++++
 .../src/test/resources/smoke-test.html             | 720 +++++++++++++++++
 .../ROOT/pages/camel-4x-upgrade-guide-4_21.adoc    |  17 +-
 16 files changed, 2652 insertions(+), 119 deletions(-)

diff --git a/components/camel-diagram/pom.xml b/components/camel-diagram/pom.xml
index e25ac06f9f72..f216e2b81ada 100644
--- a/components/camel-diagram/pom.xml
+++ b/components/camel-diagram/pom.xml
@@ -17,7 +17,8 @@
     limitations under the License.
 
 -->
-<project xmlns="http://maven.apache.org/POM/4.0.0"; 
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"; 
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 
http://maven.apache.org/maven-v4_0_0.xsd";>
+<project xmlns="http://maven.apache.org/POM/4.0.0"; 
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance";
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 
http://maven.apache.org/maven-v4_0_0.xsd";>
     <modelVersion>4.0.0</modelVersion>
 
     <parent>
@@ -99,7 +100,26 @@
             <scope>test</scope>
         </dependency>
 
-
     </dependencies>
 
+    <build>
+        <plugins>
+            <plugin>
+                <groupId>com.mycila</groupId>
+                <artifactId>license-maven-plugin</artifactId>
+                <configuration>
+                    <licenseSets>
+                        <licenseSet>
+                            <excludes combine.children="append">
+                                <!-- third-party attribution notice (plain 
text, no license header) -->
+                                
<exclude>src/main/resources/META-INF/resources/camel/diagram/THIRD-PARTY-NOTICES.txt
+                                </exclude>
+                            </excludes>
+                        </licenseSet>
+                    </licenseSets>
+                </configuration>
+            </plugin>
+        </plugins>
+    </build>
+
 </project>
diff --git a/components/camel-diagram/src/main/docs/diagram.adoc 
b/components/camel-diagram/src/main/docs/diagram.adoc
index f624170dfa06..a7cde4eb2f5e 100644
--- a/components/camel-diagram/src/main/docs/diagram.adoc
+++ b/components/camel-diagram/src/main/docs/diagram.adoc
@@ -276,3 +276,72 @@ String diagram = renderer.renderDiagramAnsi(layoutRoutes, 
totalHeight, highlight
 RouteDiagramRenderer pngRenderer = new RouteDiagramRenderer(nodeWidth, 
fontSize);
 BufferedImage image = pngRenderer.renderDiagram(layoutRoutes, totalHeight, 
colors, highlightedNodes, style);
 ----
+
+== Embeddable Web Component
+
+`camel-diagram` ships a lightweight `<camel-route-diagram>` web component that 
renders
+interactive route diagrams as SVG directly in the browser.
+Any application with `camel-diagram` on the classpath automatically serves the 
component
+as a static resource — no extra server configuration needed.
+
+=== Usage
+
+Include the bundled script served from 
`META-INF/resources/camel/diagram/camel-route-diagram.js`
+(automatically exposed by Servlet 3 containers and Quarkus/Spring Boot 
static-resource mechanisms):
+
+[source,html]
+----
+<script type="module" src="/camel/diagram/camel-route-diagram.js"></script>
+
+<camel-route-diagram
+  src="/q/dev/route-structure"
+  refresh="5000"
+  filter="my-route">
+</camel-route-diagram>
+----
+
+The `src` attribute must point to an endpoint returning the `route-structure` 
dev console JSON
+(for example the Quarkus Dev UI endpoint `/q/dev/route-structure`).
+The component automatically appends `?metric=true` so that per-processor 
exchange statistics
+are included in the diagram.
+
+=== Attributes
+
+[width="100%",cols="2,5,2",options="header"]
+|===
+| Attribute | Description | Default
+| `src` | URL to fetch the route-structure JSON from (required) | —
+| `refresh` | Polling interval in milliseconds; `0` disables polling | `0`
+| `filter` | Route ID filter, forwarded as `?filter=` query parameter | (all 
routes)
+|===
+
+=== Theming
+
+The component is theme-agnostic.
+It respects `prefers-color-scheme` automatically for dark/light mode,
+and exposes CSS custom properties so the host application can override every 
visual aspect:
+
+[source,css]
+----
+camel-route-diagram {
+  --crd-bg:                   #ffffff;   /* canvas background */
+  --crd-fg:                   #1e293b;   /* text colour */
+  --crd-edge:                 #94a3b8;   /* edge/arrow colour */
+  --crd-stat:                 #64748b;   /* metric overlay text */
+  --crd-font:                 system-ui; /* font family */
+  --crd-font-size:            12px;      /* base font size */
+  --crd-color-route:          #6366f1;   /* "route" node */
+  --crd-color-from:           #0ea5e9;   /* "from" node */
+  --crd-color-to:             #0ea5e9;   /* "to" node */
+  --crd-color-log:            #64748b;   /* "log" node */
+  --crd-color-choice:         #f59e0b;   /* "choice" node */
+  --crd-color-when:           #fbbf24;   /* "when" branch */
+  --crd-color-otherwise:      #fbbf24;   /* "otherwise" branch */
+  --crd-color-doTry:          #f59e0b;   /* "doTry" scope */
+  --crd-color-doCatch:        #fbbf24;   /* "doCatch" clause */
+  --crd-color-doFinally:      #fbbf24;   /* "doFinally" clause */
+  --crd-color-multicast:      #8b5cf6;   /* "multicast" node */
+  --crd-color-circuitBreaker: #ef4444;   /* "circuitBreaker" node */
+  --crd-color-default:        #6366f1;   /* all other EIP nodes */
+}
+----
diff --git 
a/components/camel-diagram/src/main/java/org/apache/camel/diagram/RouteDiagramAsciiRenderer.java
 
b/components/camel-diagram/src/main/java/org/apache/camel/diagram/RouteDiagramAsciiRenderer.java
index d32e850300c0..b3a30dafb103 100644
--- 
a/components/camel-diagram/src/main/java/org/apache/camel/diagram/RouteDiagramAsciiRenderer.java
+++ 
b/components/camel-diagram/src/main/java/org/apache/camel/diagram/RouteDiagramAsciiRenderer.java
@@ -36,7 +36,6 @@ import static 
org.apache.camel.diagram.RouteDiagramLayoutEngine.SCOPE_BOX_PAD;
  */
 public class RouteDiagramAsciiRenderer {
 
-    private static final int MAX_WRAP_LINES = 3;
     private static final int Y_SCALE = 20;
     private static final int MIN_BOX_WIDTH = 16;
     private static final int X_DIVISOR = 15;
@@ -484,51 +483,7 @@ public class RouteDiagramAsciiRenderer {
 
     private List<String> rewrapText(LayoutNode node, int maxWidth) {
         String label = String.join("", node.wrappedLines);
-        return wrapText(label, maxWidth);
-    }
-
-    static List<String> wrapText(String text, int maxWidth) {
-        if (maxWidth <= 0 || text.length() <= maxWidth) {
-            return List.of(text);
-        }
-
-        List<String> lines = new ArrayList<>();
-        String remaining = text;
-
-        while (!remaining.isEmpty() && lines.size() < MAX_WRAP_LINES) {
-            if (remaining.length() <= maxWidth) {
-                lines.add(remaining);
-                remaining = "";
-                break;
-            }
-
-            int breakAt = -1;
-            for (int i = 0; i < maxWidth && i < remaining.length(); i++) {
-                char c = remaining.charAt(i);
-                if (c == ' ' || c == ':' || c == '/' || c == '.' || c == ',' 
|| c == '&' || c == '?') {
-                    breakAt = i + 1;
-                }
-            }
-            if (breakAt <= 0) {
-                breakAt = maxWidth;
-            }
-
-            lines.add(remaining.substring(0, breakAt).stripTrailing());
-            remaining = remaining.substring(breakAt).stripLeading();
-        }
-
-        if (!remaining.isEmpty()) {
-            int lastIdx = lines.size() - 1;
-            String lastLine = lines.get(lastIdx);
-            if (lastLine.length() + remaining.length() <= maxWidth) {
-                lines.set(lastIdx, lastLine + remaining);
-            } else {
-                String combined = lastLine + remaining;
-                lines.set(lastIdx, combined.substring(0, Math.max(1, maxWidth 
- 3)) + "...");
-            }
-        }
-
-        return lines;
+        return RouteDiagramHelper.wrapText(label, maxWidth);
     }
 
     private int toCol(int pixelX) {
diff --git 
a/components/camel-diagram/src/main/java/org/apache/camel/diagram/RouteDiagramHelper.java
 
b/components/camel-diagram/src/main/java/org/apache/camel/diagram/RouteDiagramHelper.java
index 1d298aa1f792..603b827594f7 100644
--- 
a/components/camel-diagram/src/main/java/org/apache/camel/diagram/RouteDiagramHelper.java
+++ 
b/components/camel-diagram/src/main/java/org/apache/camel/diagram/RouteDiagramHelper.java
@@ -36,6 +36,8 @@ import org.apache.camel.util.json.Jsoner;
  */
 public final class RouteDiagramHelper {
 
+    static final int MAX_WRAP_LINES = 3;
+
     private RouteDiagramHelper() {
     }
 
@@ -52,12 +54,10 @@ public final class RouteDiagramHelper {
             return routes;
         }
 
-        for (int i = 0; i < arr.size(); i++) {
-            Object item = arr.get(i);
-            if (!(item instanceof JsonObject)) {
+        for (Object item : arr) {
+            if (!(item instanceof JsonObject o)) {
                 continue;
             }
-            JsonObject o = (JsonObject) item;
             RouteInfo route = new RouteInfo();
             route.routeId = o.getString("routeId");
             String source = o.getString("source");
@@ -99,13 +99,15 @@ public final class RouteDiagramHelper {
                             stat = new RouteDiagramLayoutEngine.StatInfo();
                         }
                         node.stat = stat;
-                        stat.idleSince = ls.getLong("idleSince");
-                        stat.exchangesTotal = ls.getLong("exchangesTotal");
-                        stat.exchangesFailed = ls.getLong("exchangesFailed");
-                        stat.exchangesInflight = 
ls.getLong("exchangesInflight");
-                        stat.meanProcessingTime = 
ls.getLong("meanProcessingTime");
-                        stat.maxProcessingTime = 
ls.getLong("maxProcessingTime");
-                        stat.minProcessingTime = 
ls.getLong("minProcessingTime");
+                        // counters default to 0 so a partial statistics 
object (missing a field) does not NPE on
+                        // auto-unboxing into the primitive long fields of 
StatInfo
+                        stat.idleSince = ls.getLongOrDefault("idleSince", 0);
+                        stat.exchangesTotal = 
ls.getLongOrDefault("exchangesTotal", 0);
+                        stat.exchangesFailed = 
ls.getLongOrDefault("exchangesFailed", 0);
+                        stat.exchangesInflight = 
ls.getLongOrDefault("exchangesInflight", 0);
+                        stat.meanProcessingTime = 
ls.getLongOrDefault("meanProcessingTime", 0);
+                        stat.maxProcessingTime = 
ls.getLongOrDefault("maxProcessingTime", 0);
+                        stat.minProcessingTime = 
ls.getLongOrDefault("minProcessingTime", 0);
                         stat.lastProcessingTime = 
ls.getLongOrDefault("lastProcessingTime", -1);
                         stat.deltaProcessingTime = 
ls.getLongOrDefault("deltaProcessingTime", -1);
                         stat.lastCreatedExchangeTimestamp = 
ls.getLongOrDefault("lastCreatedExchangeTimestamp", -1);
@@ -120,6 +122,50 @@ public final class RouteDiagramHelper {
         return routes;
     }
 
+    static List<String> wrapText(String text, int maxWidth) {
+        if (maxWidth <= 0 || text.length() <= maxWidth) {
+            return List.of(text);
+        }
+
+        List<String> lines = new ArrayList<>();
+        String remaining = text;
+
+        while (!remaining.isEmpty() && lines.size() < MAX_WRAP_LINES) {
+            if (remaining.length() <= maxWidth) {
+                lines.add(remaining);
+                remaining = "";
+                break;
+            }
+
+            int breakAt = -1;
+            for (int i = 0; i < maxWidth && i < remaining.length(); i++) {
+                char c = remaining.charAt(i);
+                if (c == ' ' || c == ':' || c == '/' || c == '.' || c == ',' 
|| c == '&' || c == '?') {
+                    breakAt = i + 1;
+                }
+            }
+            if (breakAt <= 0) {
+                breakAt = maxWidth;
+            }
+
+            lines.add(remaining.substring(0, breakAt).stripTrailing());
+            remaining = remaining.substring(breakAt).stripLeading();
+        }
+
+        if (!remaining.isEmpty()) {
+            int lastIdx = lines.size() - 1;
+            String lastLine = lines.get(lastIdx);
+            if (lastLine.length() + remaining.length() <= maxWidth) {
+                lines.set(lastIdx, lastLine + remaining);
+            } else {
+                String combined = lastLine + remaining;
+                lines.set(lastIdx, combined.substring(0, Math.max(1, maxWidth 
- 3)) + "...");
+            }
+        }
+
+        return lines;
+    }
+
     public enum HighlightStyle {
         SUCCESS,
         FAIL
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 9da413e0ef0c..267c2e422b98 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
@@ -100,7 +100,7 @@ public class RouteDiagramRenderer {
     public RouteDiagramRenderer(int nodeWidth, int fontSizeScaled, int 
nodeTextPadding, boolean metrics) {
         this.nodeWidth = nodeWidth;
         this.fontSizeNode = fontSizeScaled;
-        this.fontSizeLabel = fontSizeScaled + 1 * SCALE;
+        this.fontSizeLabel = fontSizeScaled + SCALE;
         this.nodeTextPadding = nodeTextPadding;
         this.metrics = metrics;
     }
@@ -141,7 +141,7 @@ public class RouteDiagramRenderer {
             c.text = parseColor(map.getOrDefault("text", "#ffffff"));
             c.arrow = parseColor(map.getOrDefault("arrow", "#b4b4b4"));
             c.counter = parseColor(map.getOrDefault("counter", "#2e7d32"));
-            c.counterFail = parseColor(map.getOrDefault("counter", "#ff0000"));
+            c.counterFail = parseColor(map.getOrDefault("counterFail", 
"#ff0000"));
             c.routeLabel = parseColor(map.getOrDefault("label", "#c8c8c8"));
             c.nodeFrom = parseColor(map.getOrDefault("from", "#2e7d32"));
             c.nodeTo = parseColor(map.getOrDefault("to", "#1565c0"));
@@ -167,7 +167,7 @@ public class RouteDiagramRenderer {
             }
             Integer idx = Colors.rgbColor(value);
             if (idx != null) {
-                return new Color(Colors.rgbColor(idx.intValue()));
+                return new Color(Colors.rgbColor(idx));
             }
             return null;
         }
diff --git 
a/components/camel-diagram/src/main/java/org/apache/camel/diagram/TopologyAsciiRenderer.java
 
b/components/camel-diagram/src/main/java/org/apache/camel/diagram/TopologyAsciiRenderer.java
index 9c1b6b9b3400..0803f642cc38 100644
--- 
a/components/camel-diagram/src/main/java/org/apache/camel/diagram/TopologyAsciiRenderer.java
+++ 
b/components/camel-diagram/src/main/java/org/apache/camel/diagram/TopologyAsciiRenderer.java
@@ -25,6 +25,8 @@ import 
org.apache.camel.diagram.TopologyLayoutEngine.TopologyLayoutEdge;
 import org.apache.camel.diagram.TopologyLayoutEngine.TopologyLayoutNode;
 import org.apache.camel.diagram.TopologyLayoutEngine.TopologyLayoutResult;
 
+import static org.apache.camel.diagram.RouteDiagramHelper.wrapText;
+
 /**
  * Renders topology diagrams as ASCII art or Unicode box-drawing text.
  */
@@ -33,7 +35,7 @@ public class TopologyAsciiRenderer {
     private static final int Y_SCALE = 20;
     private static final int MIN_BOX_WIDTH = 16;
     private static final int X_DIVISOR = 15;
-    private static final int MAX_WRAP_LINES = 3;
+    private static final int MAX_WRAP_LINES = 
RouteDiagramHelper.MAX_WRAP_LINES;
 
     private static final char UNI_H = '─';
     private static final char UNI_V = '│';
@@ -151,8 +153,7 @@ public class TopologyAsciiRenderer {
             line1 = node.routeId;
         }
 
-        List<String> lines = new ArrayList<>();
-        lines.addAll(wrapText(line1, boxWidth - 4));
+        List<String> lines = new ArrayList<>(wrapText(line1, boxWidth - 4));
         if (!isExternalNode(node) && !showDescription) {
             String line2 = "(" + node.from + ")";
             List<String> fromLines = wrapText(line2, boxWidth - 4);
@@ -331,46 +332,6 @@ public class TopologyAsciiRenderer {
         return 2 + Math.min(lines, MAX_WRAP_LINES + 1);
     }
 
-    static List<String> wrapText(String text, int maxWidth) {
-        if (maxWidth <= 0 || text.length() <= maxWidth) {
-            return new ArrayList<>(List.of(text));
-        }
-
-        List<String> lines = new ArrayList<>();
-        String remaining = text;
-
-        while (!remaining.isEmpty() && lines.size() < MAX_WRAP_LINES) {
-            if (remaining.length() <= maxWidth) {
-                lines.add(remaining);
-                remaining = "";
-                break;
-            }
-
-            int breakAt = -1;
-            for (int i = 0; i < maxWidth && i < remaining.length(); i++) {
-                char c = remaining.charAt(i);
-                if (c == ' ' || c == ':' || c == '/' || c == '.' || c == ',' 
|| c == '&' || c == '?') {
-                    breakAt = i + 1;
-                }
-            }
-            if (breakAt <= 0) {
-                breakAt = maxWidth;
-            }
-
-            lines.add(remaining.substring(0, breakAt).stripTrailing());
-            remaining = remaining.substring(breakAt).stripLeading();
-        }
-
-        if (!remaining.isEmpty()) {
-            int lastIdx = lines.size() - 1;
-            String lastLine = lines.get(lastIdx);
-            String combined = lastLine + remaining;
-            lines.set(lastIdx, combined.substring(0, Math.max(1, maxWidth - 
3)) + "...");
-        }
-
-        return lines;
-    }
-
     private String applyAnsiColors(String plain) {
         if (counterPositions.isEmpty()) {
             return plain;
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
new file mode 100644
index 000000000000..1a892479e696
--- /dev/null
+++ 
b/components/camel-diagram/src/main/resources/META-INF/resources/camel/diagram/THIRD-PARTY-NOTICES.txt
@@ -0,0 +1,23 @@
+camel-route-diagram bundles the following third-party content.
+
+--------------------------------------------------------------------------------
+Lucide (icon SVG paths inlined in camel-route-diagram.js):
+--------------------------------------------------------------------------------
+
+  ISC License
+  Copyright (c) for portions of Lucide are held by Cole Bemis 2013-2022 as 
part of Feather (MIT).
+  All other copyright (c) for Lucide are held by Lucide Contributors 2022.
+  SPDX-License-Identifier: ISC
+  https://lucide.dev
+
+Permission to use, copy, modify, and/or distribute this software for any
+purpose with or without fee is hereby granted, provided that the above 
copyright
+notice and this permission notice appear in all copies.
+
+THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
+REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
+FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
+INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM 
LOSS
+OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER
+TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF
+THIS SOFTWARE.
diff --git 
a/components/camel-diagram/src/main/resources/META-INF/resources/camel/diagram/camel-route-diagram.js
 
b/components/camel-diagram/src/main/resources/META-INF/resources/camel/diagram/camel-route-diagram.js
new file mode 100644
index 000000000000..869f075288d1
--- /dev/null
+++ 
b/components/camel-diagram/src/main/resources/META-INF/resources/camel/diagram/camel-route-diagram.js
@@ -0,0 +1,492 @@
+/*
+ * 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.
+ */
+
+// ─── Layout engine (ported from RouteDiagramLayoutEngine.java) 
───────────────
+
+const NODE_W = 180;
+const NODE_H = 36;
+const H_GAP = NODE_W / 2;
+const V_GAP = 40;
+const PADDING = 30;
+const ARROW_SIZE = 6;
+
+const BRANCHING_EIPS = new Set([
+    'choice', 'multicast', 'doTry', 'loadBalance', 'recipientList', 
'circuitBreaker',
+]);
+
+function buildTree(nodes) {
+    if (!nodes.length) return null;
+    const root = { info: nodes[0], children: [], parent: null, subtreeWidth: 0 
};
+    let current = root;
+
+    for (let i = 1; i < nodes.length; i++) {
+        const ni = nodes[i];
+        if (!ni.id) {
+            console.warn('camel-route-diagram: node without an id is omitted 
from the diagram', ni);
+            continue;
+        }
+        const tn = { info: ni, children: [], parent: null, subtreeWidth: 0 };
+
+        if (ni.level > current.info.level) {
+            current.children.push(tn);
+            tn.parent = current;
+        } else if (ni.level === current.info.level) {
+            const parent = current.parent ?? root;
+            parent.children.push(tn);
+            tn.parent = parent;
+        } else {
+            let ancestor = current.parent;
+            while (ancestor && ancestor.info.level >= ni.level) {
+                ancestor = ancestor.parent;
+            }
+            const target = ancestor ?? root;
+            target.children.push(tn);
+            tn.parent = target;
+        }
+        current = tn;
+    }
+    return root;
+}
+
+function computeSubtreeWidth(node) {
+    if (!node.children.length) {
+        node.subtreeWidth = NODE_W;
+        return NODE_W;
+    }
+    if (BRANCHING_EIPS.has(node.info.type)) {
+        let total = 0;
+        node.children.forEach((c, i) => {
+            if (i > 0) total += H_GAP;
+            total += computeSubtreeWidth(c);
+        });
+        node.subtreeWidth = Math.max(NODE_W, total);
+    } else {
+        node.subtreeWidth = node.children.reduce(
+            (max, c) => Math.max(max, computeSubtreeWidth(c)),
+            NODE_W,
+        );
+    }
+    return node.subtreeWidth;
+}
+
+function visualParentId(node) {
+    if (!node.parent) return null;
+    const parent = node.parent;
+    if (BRANCHING_EIPS.has(parent.info.type)) {
+        return parent.info.id;
+    }
+    const idx = parent.children.indexOf(node);
+    if (idx === 0) {
+        return parent.info.id;
+    }
+    return lastChainId(parent.children[idx - 1]);
+}
+
+function lastChainId(node) {
+    if (BRANCHING_EIPS.has(node.info.type) || !node.children.length) {
+        return node.info.id;
+    }
+    return lastChainId(node.children[node.children.length - 1]);
+}
+
+function assignPositions(node, x, y, parentWidth, positions) {
+    if (!node.info.id) {
+        console.warn('camel-route-diagram: node without an id is omitted from 
the diagram', node.info);
+        return y + NODE_H;
+    }
+
+    const available = Math.max(node.subtreeWidth, parentWidth);
+    const nodeX = x + (available - NODE_W) / 2;
+
+    positions[node.info.id] = {
+        x: nodeX,
+        y,
+        w: NODE_W,
+        h: NODE_H,
+        parentId: visualParentId(node),
+        type: node.info.type,
+        code: node.info.code,
+        description: node.info.description ?? null,
+        uri: node.info.uri ?? null,
+        statistics: node.info.statistics ?? null,
+    };
+
+    if (!node.children.length) return y + NODE_H;
+
+    const childY = y + NODE_H + V_GAP;
+
+    if (BRANCHING_EIPS.has(node.info.type)) {
+        let childX = x + (available - node.subtreeWidth) / 2;
+        let maxBottom = childY;
+        for (const child of node.children) {
+            const bottom = assignPositions(child, childX, childY, 
child.subtreeWidth, positions);
+            if (bottom > maxBottom) maxBottom = bottom;
+            childX += child.subtreeWidth + H_GAP;
+        }
+        return maxBottom;
+    } else {
+        let curY = childY;
+        for (const child of node.children) {
+            curY = assignPositions(child, x, curY, available, positions) + 
V_GAP;
+        }
+        return curY - V_GAP;
+    }
+}
+
+function layoutRoute(route) {
+    const nodes = route.code ?? [];
+    if (!nodes.length) {
+        return { positions: {}, width: NODE_W + PADDING * 2, height: NODE_H + 
PADDING * 2 };
+    }
+
+    const tree = buildTree(nodes);
+    computeSubtreeWidth(tree);
+
+    const positions = {};
+    assignPositions(tree, PADDING, PADDING, tree.subtreeWidth, positions);
+
+    let maxX = 0;
+    let maxYVal = 0;
+    for (const p of Object.values(positions)) {
+        maxX = Math.max(maxX, p.x + p.w);
+        maxYVal = Math.max(maxYVal, p.y + p.h);
+    }
+
+    return { positions, width: maxX + PADDING, height: maxYVal + PADDING };
+}
+
+// ─── Web component 
────────────────────────────────────────────────────────────
+
+const TYPE_COLORS = {
+    route:          'var(--crd-color-route,          #6366f1)',
+    from:           'var(--crd-color-from,           #0ea5e9)',
+    to:             'var(--crd-color-to,             #0ea5e9)',
+    log:            'var(--crd-color-log,            #64748b)',
+    choice:         'var(--crd-color-choice,         #f59e0b)',
+    when:           'var(--crd-color-when,           #fbbf24)',
+    otherwise:      'var(--crd-color-otherwise,      #fbbf24)',
+    doTry:          'var(--crd-color-doTry,          #f59e0b)',
+    doCatch:        'var(--crd-color-doCatch,        #fbbf24)',
+    doFinally:      'var(--crd-color-doFinally,      #fbbf24)',
+    multicast:      'var(--crd-color-multicast,      #8b5cf6)',
+    circuitBreaker: 'var(--crd-color-circuitBreaker, #ef4444)',
+};
+
+// SVG icon paths from Lucide (https://lucide.dev) — ISC License
+// Copyright (c) Lucide Contributors 2022; portions © Cole Bemis 2013-2022 
(Feather, MIT)
+const ICONS = {
+    workflow:            '<rect width="8" height="8" x="3" y="3" rx="2"/><path 
d="M7 11v4a2 2 0 0 0 2 2h4"/><rect width="8" height="8" x="13" y="13" rx="2"/>',
+    'log-in':            '<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 
2h-4"/><polyline points="10 17 15 12 10 7"/><line x1="15" x2="3" y1="12" 
y2="12"/>',
+    'log-out':           '<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 
2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" x2="9" y1="12" 
y2="12"/>',
+    'file-text':         '<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 
2 0 0 0 2-2V7Z"/><path d="M14 2v6h6"/><path d="M16 13H8"/><path d="M16 
17H8"/><path d="M10 9H8"/>',
+    'git-branch':        '<line x1="6" x2="6" y1="3" y2="15"/><circle cx="18" 
cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/>',
+    'corner-down-right': '<polyline points="15 10 20 15 15 20"/><path d="M4 
4v7a4 4 0 0 0 4 4h12"/>',
+    split:               '<path d="M16 3h5v5"/><path d="M8 3H3v5"/><path 
d="M12 22v-8.3a4 4 0 0 0-1.172-2.872L3 3"/><path d="m15 9 6-6"/>',
+    shield:              '<path d="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-1c2 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"/>',
+    'alert-triangle':    '<path d="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 d="M12 9v4"/><path d="M12 17h.01"/>',
+    flag:                '<path d="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 x1="4" x2="4" y1="22" y2="15"/>',
+    zap:                 '<path d="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 10h7a1 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"/>',
+    box:                 '<path d="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 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 
16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/>',
+};
+
+const TYPE_ICON = {
+    route: 'workflow', from: 'log-in', to: 'log-out', log: 'file-text',
+    choice: 'git-branch', when: 'corner-down-right', otherwise: 
'corner-down-right',
+    doTry: 'shield', doCatch: 'alert-triangle', doFinally: 'flag',
+    multicast: 'split', circuitBreaker: 'zap',
+};
+
+function iconFor(type) {
+    return ICONS[TYPE_ICON[type]] ?? ICONS.box;
+}
+
+function nodeColor(type) {
+    return TYPE_COLORS[type] ?? 'var(--crd-color-default, #6366f1)';
+}
+
+function truncate(text, maxLen = 28) {
+    if (!text) return '';
+    const clean = text.replace(/^\.+/, '');
+    return clean.length > maxLen ? clean.slice(0, maxLen - 1) + '…' : clean;
+}
+
+function formatStat(stats) {
+    if (!stats) return null;
+    const total = stats.exchangesTotal ?? 0;
+    const failed = stats.exchangesFailed ?? 0;
+    return `✓${total} ✗${failed}`;
+}
+
+function esc(s) {
+    return String(s ?? '')
+        .replace(/&/g, '&amp;')
+        .replace(/</g, '&lt;')
+        .replace(/>/g, '&gt;')
+        .replace(/"/g, '&quot;');
+}
+
+// Node ids are interpolated into a DOM id and a url(#...) reference, so 
restrict them to characters that are
+// safe in both contexts (esc() alone would not produce a valid id fragment).
+function safeId(id) {
+    return String(id).replace(/[^A-Za-z0-9_-]/g, '_');
+}
+
+const COMPONENT_STYLE = `
+  :host {
+    display: block;
+    /*
+     * fit-content makes the host expand to the SVG's intrinsic width so the
+     * parent scroll container sees real overflow and shows a scrollbar.
+     * min-width: 100% prevents collapsing when the diagram is narrower than
+     * the container.
+     */
+    width: fit-content;
+    min-width: 100%;
+    font-family: var(--crd-font, system-ui, sans-serif);
+    font-size: var(--crd-font-size, 12px);
+    color: var(--crd-fg, #1e293b);
+  }
+  @media (prefers-color-scheme: dark) {
+    :host { color: var(--crd-fg, #e2e8f0); }
+  }
+  /* Background on .wrap (not :host) so it tracks the SVG width on scroll. */
+  .wrap {
+    display: flex;
+    flex-direction: row;
+    align-items: flex-start;
+    gap: 24px;
+    background: var(--crd-bg, transparent);
+    --crd-node-bg: var(--crd-bg, #ffffff);
+  }
+  @media (prefers-color-scheme: dark) {
+    .wrap {
+      background: var(--crd-bg, #0f172a);
+      --crd-node-bg: var(--crd-bg, #0f172a);
+    }
+  }
+  .route-col { flex-shrink: 0; }
+  .error   { color: #ef4444; padding: 8px; }
+  .loading { opacity: .6; padding: 8px; }
+  .route-label {
+    font-weight: 600;
+    font-size: 0.9em;
+    padding: 4px 0 2px 0;
+    opacity: .8;
+  }
+  svg { display: block; overflow: visible; }
+`;
+
+/**
+ * A web component that renders Apache Camel route diagrams as interactive SVG.
+ *
+ * Attributes:
+ *   src     - URL of the route-structure dev console endpoint (required)
+ *   refresh - polling interval in ms; 0 = disabled (default: 0)
+ *   filter  - route ID filter, forwarded as ?filter= query param (default: 
all routes)
+ *
+ * CSS custom properties (all optional):
+ *   --crd-bg, --crd-node-bg, --crd-fg, --crd-edge, --crd-font, 
--crd-font-size, --crd-stat
+ *   
--crd-color-{route,from,to,log,choice,when,otherwise,doTry,doCatch,doFinally,...,default}
+ *
+ * @since 4.21
+ */
+class CamelRouteDiagram extends HTMLElement {
+    static observedAttributes = ['src', 'refresh', 'filter'];
+
+    #src = '';
+    #refresh = 0;
+    #filter = '';
+    #timer = null;
+    #uid = Math.random().toString(36).slice(2);
+    #controller = null;
+    #data = null;
+    #error = null;
+
+    constructor() {
+        super();
+        this.attachShadow({ mode: 'open' });
+    }
+
+    //noinspection JSUnusedGlobalSymbols
+    connectedCallback() {
+        this.#scheduleRefresh();
+        this.#render();
+        this.#doFetch();
+    }
+
+    //noinspection JSUnusedGlobalSymbols
+    disconnectedCallback() {
+        clearInterval(this.#timer);
+        this.#timer = null;
+        this.#controller?.abort();
+    }
+
+    //noinspection JSUnusedGlobalSymbols
+    attributeChangedCallback(name, oldValue, newValue) {
+        if (oldValue === newValue) return;
+        switch (name) {
+            case 'src':
+                this.#src = newValue ?? '';
+                if (this.isConnected) this.#doFetch();
+                break;
+            case 'filter':
+                this.#filter = newValue ?? '';
+                if (this.isConnected) this.#doFetch();
+                break;
+            case 'refresh':
+                this.#refresh = Number(newValue) || 0;
+                if (this.isConnected) this.#scheduleRefresh();
+                break;
+        }
+    }
+
+    #scheduleRefresh() {
+        clearInterval(this.#timer);
+        this.#timer = null;
+        if (this.#refresh > 0) {
+            this.#timer = setInterval(() => this.#doFetch(), this.#refresh);
+        }
+    }
+
+    async #doFetch() {
+        const src = this.#src?.trim();
+        if (!src) return;
+        // Cancel any in-flight request so the last-sent response always wins.
+        this.#controller?.abort();
+        this.#controller = new AbortController();
+        try {
+            const url = new URL(src, location.href);
+            if (this.#filter) url.searchParams.set('filter', this.#filter);
+            url.searchParams.set('metric', 'true');
+            const res = await fetch(url, { signal: this.#controller.signal });
+            if (!res.ok) {
+                this.#error = `HTTP ${res.status} ${res.statusText}`;
+                this.#render();
+                return;
+            }
+            const data = await res.json();
+            if (!Array.isArray(data?.routes)) {
+                this.#error = 'Unexpected response: missing routes array';
+                this.#render();
+                return;
+            }
+            this.#data = data;
+            this.#error = null;
+            this.#render();
+        } catch (e) {
+            if (e.name !== 'AbortError') {
+                this.#error = e.message;
+                this.#render();
+            }
+        }
+    }
+
+    #render() {
+        this.shadowRoot.innerHTML = this.#buildHTML();
+    }
+
+    #buildHTML() {
+        const style = `<style>${COMPONENT_STYLE}</style>`;
+        if (this.#error) return `${style}<div class="wrap"><p class="error">⚠ 
${esc(this.#error)}</p></div>`;
+        if (!this.#data) return `${style}<div class="wrap"><p 
class="loading">Loading diagram…</p></div>`;
+        return style + `<div class="wrap">${this.#data.routes.map((r, i) => 
this.#routeHTML(r, i)).join('')}</div>`;
+    }
+
+    #routeHTML(route, routeIdx) {
+        const { positions, width, height } = layoutRoute(route);
+        const ids = Object.keys(positions);
+        const pfx  = `t${this.#uid}r${routeIdx}`;
+        const defs = ids.map(id => {
+            const p = positions[id];
+            return `<clipPath id="${pfx}${safeId(id)}">` +
+                   `<rect x="${p.x + 28}" y="${p.y}" width="${NODE_W - 30}" 
height="${NODE_H}"/></clipPath>`;
+        }).join('');
+        return `<div class="route-col">
+      <div class="route-label">${esc(route.routeId)}</div>
+      <svg width="${width}" height="${height}" viewBox="0 0 ${width} ${height}"
+           aria-label="Route diagram for ${esc(route.routeId)}">
+        <defs>${defs}</defs>
+        ${ids.map(id => this.#edgeHTML(id, positions)).join('')}
+        ${ids.map(id => this.#nodeHTML(positions[id], 
`${pfx}${safeId(id)}`)).join('')}
+      </svg>
+    </div>`;
+    }
+
+    #edgeHTML(id, positions) {
+        const pos = positions[id];
+        if (!pos.parentId) return '';
+        const parent = positions[pos.parentId];
+        if (!parent) return '';
+
+        const x1 = parent.x + NODE_W / 2;
+        const y1 = parent.y + NODE_H;
+        const x2 = pos.x + NODE_W / 2;
+        const y2 = pos.y;
+        const endY = y2 - ARROW_SIZE / 2;
+        const edge = x1 === x2
+            ? `M${x1},${y1} L${x2},${endY}`
+            : `M${x1},${y1} L${x1},${(y1 + y2) / 2} L${x2},${(y1 + y2) / 2} 
L${x2},${endY}`;
+
+        return `
+      <path
+        d="${edge}"
+        fill="none"
+        stroke="var(--crd-edge, #94a3b8)"
+        stroke-width="1.5"
+        stroke-linecap="round"
+        stroke-linejoin="round"/>
+      <polygon
+        points="${x2 - ARROW_SIZE},${y2 - ARROW_SIZE} ${x2},${y2} ${x2 + 
ARROW_SIZE},${y2 - ARROW_SIZE}"
+        fill="var(--crd-edge, #94a3b8)"/>`;
+    }
+
+    #nodeHTML(pos, clipId) {
+        const label  = truncate(pos.description ?? pos.code);
+        const stat   = formatStat(pos.statistics);
+        const fill   = nodeColor(pos.type);
+        const textX  = pos.x + 30;
+        const textY  = pos.y + NODE_H / 2 + 4;
+
+        return `
+      <g role="img" aria-label="${esc(pos.type)}: ${esc(label)}">
+        <rect x="${pos.x}" y="${pos.y}" width="${NODE_W}" height="${NODE_H}"
+              rx="6" ry="6" fill="var(--crd-node-bg, #ffffff)"/>
+        <rect x="${pos.x}" y="${pos.y}" width="${NODE_W}" height="${NODE_H}"
+              rx="6" ry="6"
+              fill="${fill}" fill-opacity="0.15"
+              stroke="${fill}" stroke-width="1.5"/>
+        <text x="${textX}" y="${stat ? textY - 4 : textY}"
+              text-anchor="start" fill="currentColor" font-size="11"
+              clip-path="url(#${clipId})">
+          ${esc(label)}
+        </text>
+        ${stat ? `
+        <text x="${textX}" y="${pos.y + NODE_H - 3}"
+              text-anchor="start" fill="var(--crd-stat, #64748b)" font-size="9"
+              clip-path="url(#${clipId})">
+          ${esc(stat)}
+        </text>` : ''}
+        <g transform="translate(${pos.x + 12},${pos.y + (NODE_H - 14) / 2}) 
scale(0.5833)"
+              fill="none" stroke="${fill}" stroke-width="2.4"
+              stroke-linecap="round" stroke-linejoin="round" 
pointer-events="none">
+          ${iconFor(pos.type)}
+        </g>
+      </g>`;
+    }
+}
+
+customElements.define('camel-route-diagram', CamelRouteDiagram);
diff --git 
a/components/camel-diagram/src/test/java/org/apache/camel/diagram/RouteDiagramHelperTest.java
 
b/components/camel-diagram/src/test/java/org/apache/camel/diagram/RouteDiagramHelperTest.java
new file mode 100644
index 000000000000..f1415f6bf9e8
--- /dev/null
+++ 
b/components/camel-diagram/src/test/java/org/apache/camel/diagram/RouteDiagramHelperTest.java
@@ -0,0 +1,55 @@
+/*
+ * 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.util.List;
+
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class RouteDiagramHelperTest {
+
+    @Test
+    void wrapTextShortReturnedAsIs() {
+        assertThat(RouteDiagramHelper.wrapText("short", 
20)).containsExactly("short");
+    }
+
+    @Test
+    void wrapTextLongWrapsAtBreakCharacters() {
+        List<String> lines = 
RouteDiagramHelper.wrapText("kafka:my-topic?brokers=localhost:9092", 15);
+        assertThat(lines).hasSizeGreaterThan(1);
+        assertThat(String.join("", 
lines)).contains("kafka").contains("localhost");
+    }
+
+    @Test
+    void wrapTextRemainingThatFitsOnLastLineIsAppendedWithoutEllipsis() {
+        // "aaa bbb ccc dd" with maxWidth=5:
+        //   round 1 → "aaa", round 2 → "bbb", round 3 → "ccc", remaining = 
"dd"
+        //   lastLine("ccc").len=3 + remaining("dd").len=2 = 5 <= maxWidth → 
append, no "..."
+        List<String> lines = RouteDiagramHelper.wrapText("aaa bbb ccc dd", 5);
+        assertThat(lines).containsExactly("aaa", "bbb", "cccdd");
+    }
+
+    @Test
+    void wrapTextRemainingThatDoesNotFitOnLastLineIsTruncatedWithEllipsis() {
+        // Same structure but remaining = "ddddd" (len 5): 3+5=8 > 5 → 
truncate with "..."
+        List<String> lines = RouteDiagramHelper.wrapText("aaa bbb ccc ddddd", 
5);
+        assertThat(lines).hasSize(3);
+        assertThat(lines.get(2)).endsWith("...");
+    }
+}
diff --git 
a/components/camel-diagram/src/test/java/org/apache/camel/diagram/RouteDiagramLayoutEngineTest.java
 
b/components/camel-diagram/src/test/java/org/apache/camel/diagram/RouteDiagramLayoutEngineTest.java
new file mode 100644
index 000000000000..c8a569a079f4
--- /dev/null
+++ 
b/components/camel-diagram/src/test/java/org/apache/camel/diagram/RouteDiagramLayoutEngineTest.java
@@ -0,0 +1,234 @@
+/*
+ * 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.util.List;
+
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Verifies computeSubtreeWidth and assignPositions behaviour through the 
public layoutRoute() API. The same scenarios
+ * are mirrored in the browser tests in integration-test.html.
+ *
+ * Java constants (default constructor, SCALE=2): nodeWidth = 360 
(DEFAULT_BOX_WIDTH * SCALE) hGap = 180 (nodeWidth / 2)
+ * V_GAP = 80 (40 * SCALE) PADDING = 60 (30 * SCALE)
+ */
+class RouteDiagramLayoutEngineTest {
+
+    private static final RouteDiagramLayoutEngine ENGINE = new 
RouteDiagramLayoutEngine();
+    private static final int NODE_W = ENGINE.getNodeWidth();           // 360
+    private static final int H_GAP = NODE_W / 2;                      // 180
+    private static final int PADDING = RouteDiagramLayoutEngine.PADDING; // 60
+
+    // ─── helpers 
─────────────────────────────────────────────────────────────
+
+    private static RouteDiagramLayoutEngine.NodeInfo node(String type, String 
id, int level) {
+        RouteDiagramLayoutEngine.NodeInfo n = new 
RouteDiagramLayoutEngine.NodeInfo();
+        n.type = type;
+        n.id = id;
+        n.level = level;
+        n.code = type;
+        return n;
+    }
+
+    private static RouteDiagramLayoutEngine.RouteInfo 
route(RouteDiagramLayoutEngine.NodeInfo... nodes) {
+        RouteDiagramLayoutEngine.RouteInfo r = new 
RouteDiagramLayoutEngine.RouteInfo();
+        r.routeId = "test";
+        r.nodes.addAll(List.of(nodes));
+        return r;
+    }
+
+    private static RouteDiagramLayoutEngine.LayoutNode findNode(
+            RouteDiagramLayoutEngine.LayoutRoute lr, String id) {
+        return lr.nodes.stream()
+                .filter(n -> id.equals(n.id))
+                .findFirst()
+                .orElseThrow(() -> new AssertionError("No layout node with id: 
" + id));
+    }
+
+    // ─── computeSubtreeWidth (verified through node positions) 
───────────────
+
+    @Test
+    void leafNodeSubtreeWidthEqualsNodeWidth() {
+        // A single leaf node fills exactly one node-width slot.
+        // nodeX = PADDING + (subtreeWidth - NODE_W) / 2; if subtreeWidth == 
NODE_W, nodeX == PADDING.
+        RouteDiagramLayoutEngine.LayoutRoute lr = ENGINE.layoutRoute(
+                route(node("log", "l1", 0)), 0);
+
+        RouteDiagramLayoutEngine.LayoutNode l1 = findNode(lr, "l1");
+        assertThat(l1.x).as("leaf node must be placed at PADDING (subtreeWidth 
== nodeWidth)").isEqualTo(PADDING);
+    }
+
+    @Test
+    void branchingEipSubtreeWidthIsSumOfBranchWidthsPlusGaps() {
+        // choice -> [when, otherwise], both leaves.
+        // subtreeWidth(choice) = NODE_W + H_GAP + NODE_W = NODE_W*2 + H_GAP
+        // when.x  = PADDING  (leftmost child)
+        // ow.x    = PADDING + NODE_W + H_GAP
+        // gap between children = NODE_W + H_GAP
+        RouteDiagramLayoutEngine.LayoutRoute lr = ENGINE.layoutRoute(
+                route(node("choice", "ch", 0),
+                        node("when", "w1", 1),
+                        node("otherwise", "ow", 1)),
+                0);
+
+        RouteDiagramLayoutEngine.LayoutNode w1 = findNode(lr, "w1");
+        RouteDiagramLayoutEngine.LayoutNode ow = findNode(lr, "ow");
+        assertThat(ow.x - w1.x)
+                .as("gap between two leaf branches of a branching EIP must 
equal NODE_W + H_GAP")
+                .isEqualTo(NODE_W + H_GAP);
+    }
+
+    @Test
+    void nonBranchingNodeSubtreeWidthIsMaxOfChildWidths() {
+        // route -> [from, to] (linear siblings, both leaves, equal widths).
+        // subtreeWidth(route) = max(NODE_W, NODE_W) = NODE_W
+        // Both children should be placed at x == PADDING.
+        RouteDiagramLayoutEngine.LayoutRoute lr = ENGINE.layoutRoute(
+                route(node("route", "r1", 0),
+                        node("from", "f1", 1),
+                        node("to", "t1", 1)),
+                0);
+
+        RouteDiagramLayoutEngine.LayoutNode f1 = findNode(lr, "f1");
+        RouteDiagramLayoutEngine.LayoutNode t1 = findNode(lr, "t1");
+        assertThat(f1.x).as("first linear child x must equal 
PADDING").isEqualTo(PADDING);
+        assertThat(t1.x).as("second linear child x must equal 
PADDING").isEqualTo(PADDING);
+    }
+
+    // ─── assignPositions 
─────────────────────────────────────────────────────
+
+    @Test
+    void linearChainEachNodeConnectsToItsVisualPredecessor() {
+        // route -> from -> log -> to (flat level-1 siblings processed 
linearly).
+        // f1.parentNode == r1, l1.parentNode == f1, t1.parentNode == l1.
+        RouteDiagramLayoutEngine.LayoutRoute lr = ENGINE.layoutRoute(
+                route(node("route", "r1", 0),
+                        node("from", "f1", 1),
+                        node("log", "l1", 1),
+                        node("to", "t1", 1)),
+                0);
+
+        RouteDiagramLayoutEngine.LayoutNode r1 = findNode(lr, "r1");
+        RouteDiagramLayoutEngine.LayoutNode f1 = findNode(lr, "f1");
+        RouteDiagramLayoutEngine.LayoutNode l1 = findNode(lr, "l1");
+        RouteDiagramLayoutEngine.LayoutNode t1 = findNode(lr, "t1");
+
+        assertThat(r1.parentNode).as("root must have no parent").isNull();
+        assertThat(f1.parentNode).as("f1 must connect from r1").isSameAs(r1);
+        assertThat(l1.parentNode).as("l1 must connect from f1, not 
r1").isSameAs(f1);
+        assertThat(t1.parentNode).as("t1 must connect from l1, not 
r1").isSameAs(l1);
+    }
+
+    @Test
+    void singleChainRouteAssignsStrictlyIncreasingYValues() {
+        RouteDiagramLayoutEngine.LayoutRoute lr = ENGINE.layoutRoute(
+                route(node("route", "r1", 0),
+                        node("from", "f1", 1),
+                        node("log", "l1", 1),
+                        node("to", "t1", 1)),
+                0);
+
+        int yr1 = findNode(lr, "r1").y;
+        int yf1 = findNode(lr, "f1").y;
+        int yl1 = findNode(lr, "l1").y;
+        int yt1 = findNode(lr, "t1").y;
+
+        assertThat(yf1).as("f1.y must be below r1").isGreaterThan(yr1);
+        assertThat(yl1).as("l1.y must be below f1").isGreaterThan(yf1);
+        assertThat(yt1).as("t1.y must be below l1").isGreaterThan(yl1);
+    }
+
+    @Test
+    void branchingEipChildrenAreLaidOutSideBySide() {
+        // choice -> [when, otherwise]: children must share the same y, 
different x.
+        RouteDiagramLayoutEngine.LayoutRoute lr = ENGINE.layoutRoute(
+                route(node("choice", "ch", 0),
+                        node("when", "w1", 1),
+                        node("otherwise", "ow", 1)),
+                0);
+
+        RouteDiagramLayoutEngine.LayoutNode w1 = findNode(lr, "w1");
+        RouteDiagramLayoutEngine.LayoutNode ow = findNode(lr, "ow");
+
+        assertThat(w1.x).as("when must be to the left of 
otherwise").isLessThan(ow.x);
+        assertThat(w1.y).as("both branches must start at the same 
y").isEqualTo(ow.y);
+    }
+
+    @Test
+    void nextSiblingIsPlacedBelowDeepestDescendantOfPreviousSibling() {
+        // route -> choice -> [when -> log_a, otherwise -> log_b], log_after
+        // log_after must be below BOTH log_a and log_b.
+        RouteDiagramLayoutEngine.LayoutRoute lr = ENGINE.layoutRoute(
+                route(node("route", "r1", 0),
+                        node("choice", "ch", 1),
+                        node("when", "wh", 2),
+                        node("log", "la", 3),
+                        node("otherwise", "ow", 2),
+                        node("log", "lb", 3),
+                        node("log", "lafter", 1)),
+                0);
+
+        RouteDiagramLayoutEngine.LayoutNode la = findNode(lr, "la");
+        RouteDiagramLayoutEngine.LayoutNode lb = findNode(lr, "lb");
+        RouteDiagramLayoutEngine.LayoutNode lafter = findNode(lr, "lafter");
+
+        assertThat(lafter.y)
+                .as("lafter must be below la")
+                .isGreaterThan(la.y + la.height);
+        assertThat(lafter.y)
+                .as("lafter must be below lb")
+                .isGreaterThan(lb.y + lb.height);
+    }
+
+    @Test
+    void linearChainAfterBranchingEipConnectsFromBranchingEip() {
+        // route -> choice -> [when, otherwise], log_after
+        // log_after.parentNode must be the choice node, not when or otherwise.
+        RouteDiagramLayoutEngine.LayoutRoute lr = ENGINE.layoutRoute(
+                route(node("route", "r1", 0),
+                        node("choice", "ch", 1),
+                        node("when", "wh", 2),
+                        node("otherwise", "ow", 2),
+                        node("log", "lafter", 1)),
+                0);
+
+        RouteDiagramLayoutEngine.LayoutNode ch = findNode(lr, "ch");
+        RouteDiagramLayoutEngine.LayoutNode lafter = findNode(lr, "lafter");
+
+        assertThat(lafter.parentNode)
+                .as("node after a branching EIP must connect from the 
branching EIP itself")
+                .isSameAs(ch);
+    }
+
+    @Test
+    void layoutRouteMaxYEqualsDeepestNodeBottom() {
+        // route -> from -> log: maxY must equal log.y + log.height.
+        RouteDiagramLayoutEngine.LayoutRoute lr = ENGINE.layoutRoute(
+                route(node("route", "r1", 0),
+                        node("from", "f1", 1),
+                        node("log", "l1", 1)),
+                0);
+
+        RouteDiagramLayoutEngine.LayoutNode l1 = findNode(lr, "l1");
+        assertThat(lr.maxY)
+                .as("maxY must equal the bottom of the deepest node")
+                .isEqualTo(l1.y + l1.height);
+    }
+}
diff --git 
a/components/camel-diagram/src/test/java/org/apache/camel/diagram/RouteDiagramTest.java
 
b/components/camel-diagram/src/test/java/org/apache/camel/diagram/RouteDiagramTest.java
index 4024de903f33..078d67e9b669 100644
--- 
a/components/camel-diagram/src/test/java/org/apache/camel/diagram/RouteDiagramTest.java
+++ 
b/components/camel-diagram/src/test/java/org/apache/camel/diagram/RouteDiagramTest.java
@@ -1041,14 +1041,14 @@ class RouteDiagramTest {
 
     @Test
     void testAsciiWrapTextShort() {
-        List<String> lines = RouteDiagramAsciiRenderer.wrapText("timer:tick", 
20);
+        List<String> lines = RouteDiagramHelper.wrapText("timer:tick", 20);
         assertEquals(1, lines.size());
         assertEquals("timer:tick", lines.get(0));
     }
 
     @Test
     void testAsciiWrapTextWrap() {
-        List<String> lines = 
RouteDiagramAsciiRenderer.wrapText("kafka:my-topic?brokers=localhost:9092", 20);
+        List<String> lines = 
RouteDiagramHelper.wrapText("kafka:my-topic?brokers=localhost:9092", 20);
         assertTrue(lines.size() > 1, "Long text should wrap");
         String rejoined = String.join("", lines);
         assertTrue(rejoined.contains("kafka:"));
@@ -1058,7 +1058,7 @@ class RouteDiagramTest {
     @Test
     void testAsciiWrapTextTruncate() {
         String veryLong = "a]".repeat(60);
-        List<String> lines = RouteDiagramAsciiRenderer.wrapText(veryLong, 20);
+        List<String> lines = RouteDiagramHelper.wrapText(veryLong, 20);
         assertTrue(lines.size() <= 3, "Should not exceed 3 lines");
         assertTrue(lines.get(lines.size() - 1).endsWith("..."), "Truncated 
text should end with ...");
     }
diff --git 
a/components/camel-diagram/src/test/java/org/apache/camel/diagram/TopologyDiagramTest.java
 
b/components/camel-diagram/src/test/java/org/apache/camel/diagram/TopologyDiagramTest.java
index d94497ef87ea..c9643c1d0cb3 100644
--- 
a/components/camel-diagram/src/test/java/org/apache/camel/diagram/TopologyDiagramTest.java
+++ 
b/components/camel-diagram/src/test/java/org/apache/camel/diagram/TopologyDiagramTest.java
@@ -274,16 +274,6 @@ class TopologyDiagramTest {
         assertEquals("internal", edges.get(0).connectionType);
     }
 
-    @Test
-    void testWrapText() {
-        List<String> lines = TopologyAsciiRenderer.wrapText("short", 20);
-        assertEquals(1, lines.size());
-        assertEquals("short", lines.get(0));
-
-        List<String> wrapped = TopologyAsciiRenderer.wrapText("this is a 
longer text that needs wrapping", 15);
-        assertTrue(wrapped.size() > 1);
-    }
-
     @Test
     void testOrderProcessingTopology() {
         List<TopologyNodeInfo> nodes = List.of(
diff --git 
a/components/camel-diagram/src/test/java/org/apache/camel/diagram/WebComponentBundleTest.java
 
b/components/camel-diagram/src/test/java/org/apache/camel/diagram/WebComponentBundleTest.java
new file mode 100644
index 000000000000..e2c244f5c369
--- /dev/null
+++ 
b/components/camel-diagram/src/test/java/org/apache/camel/diagram/WebComponentBundleTest.java
@@ -0,0 +1,91 @@
+/*
+ * 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.io.IOException;
+import java.io.InputStream;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Verifies that the web component and its third-party notices are bundled on 
the classpath, and that the bundle still
+ * contains the load-bearing markers a custom element needs. These are 
packaging-integrity checks based on text content,
+ * not runtime behaviour tests: the actual rendering, layout and fetch 
lifecycle of the component are exercised in the
+ * browser by {@code src/test/resources/integration-test.html}, which is not 
run as part of the CI build.
+ */
+class WebComponentBundleTest {
+
+    @Test
+    void bundledJsExistsInClasspath() {
+        URL url = getClass().getClassLoader()
+                
.getResource("META-INF/resources/camel/diagram/camel-route-diagram.js");
+        assertThat(url).as("camel-route-diagram.js must be 
bundled").isNotNull();
+    }
+
+    @Test
+    void bundledJsIsNonEmpty() throws IOException {
+        try (InputStream is = getClass().getClassLoader()
+                
.getResourceAsStream("META-INF/resources/camel/diagram/camel-route-diagram.js"))
 {
+            assertThat(is).isNotNull();
+            assertThat(is.readAllBytes().length).isGreaterThan(1000);
+        }
+    }
+
+    @Test
+    void bundledJsContainsCustomElementRegistration() throws IOException {
+        try (InputStream is = getClass().getClassLoader()
+                
.getResourceAsStream("META-INF/resources/camel/diagram/camel-route-diagram.js"))
 {
+            assertThat(is).isNotNull();
+            String content = new String(is.readAllBytes(), 
StandardCharsets.UTF_8);
+            assertThat(content)
+                    .as("bundle must register the camel-route-diagram custom 
element")
+                    .contains("customElements.define")
+                    .contains("camel-route-diagram");
+        }
+    }
+
+    @Test
+    void bundledJsUsesArrowMarkerGeometryThatAnchorsAtTheTip() throws 
IOException {
+        try (InputStream is = getClass().getClassLoader()
+                
.getResourceAsStream("META-INF/resources/camel/diagram/camel-route-diagram.js"))
 {
+            assertThat(is).isNotNull();
+            String content = new String(is.readAllBytes(), 
StandardCharsets.UTF_8);
+            assertThat(content)
+                    .as("branch connectors must render an explicit arrowhead 
at the path endpoint")
+                    .contains("const ARROW_SIZE = 6")
+                    .contains("<polygon")
+                    .contains("stroke-linecap=\"round\"");
+        }
+    }
+
+    @Test
+    void thirdPartyNoticesMentionsLucide() throws IOException {
+        try (InputStream is = getClass().getClassLoader()
+                
.getResourceAsStream("META-INF/resources/camel/diagram/THIRD-PARTY-NOTICES.txt"))
 {
+            assertThat(is).isNotNull();
+            String content = new String(is.readAllBytes(), 
StandardCharsets.UTF_8);
+            assertThat(content)
+                    .as("THIRD-PARTY-NOTICES.txt must attribute Lucide with 
ISC license")
+                    .contains("Lucide")
+                    .contains("ISC");
+        }
+    }
+}
diff --git a/components/camel-diagram/src/test/resources/integration-test.html 
b/components/camel-diagram/src/test/resources/integration-test.html
new file mode 100644
index 000000000000..9e7c80473640
--- /dev/null
+++ b/components/camel-diagram/src/test/resources/integration-test.html
@@ -0,0 +1,862 @@
+<!--
+
+    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.
+
+-->
+<!DOCTYPE html>
+<!--
+  Integration test page for the camel-route-diagram web component.
+
+  All tests are automated — open the page in a browser and every row should
+  be green. No running Camel instance is needed; all data is mocked inline.
+
+  Browsers block ES module imports from file:// URLs (CORS/null-origin).
+  Serve from the src/ directory:
+
+    cd components/camel-diagram/src/
+    python3 -m http.server 8080
+    open http://localhost:8080/test/resources/integration-test.html
+-->
+<html lang="en">
+<head>
+  <meta charset="UTF-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1">
+  <title>camel-route-diagram · Integration Test</title>
+  <style>
+    /* ─── Camel design tokens (mirrored from smoke-test.html) ─── */
+    :root {
+      --camel-orange:      #e97826;
+      --camel-orange-dark: #cf7428;
+      --camel-navy:        #303284;
+      --camel-purple:      #4f51ae;
+      --camel-light:       #f5f5f5;
+      --camel-border:      #e1e1e1;
+      --camel-text:        #333;
+      --camel-muted:       #5d5d5d;
+      --camel-white:       #fff;
+      --radius:            6px;
+    }
+
+    *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
+
+    html { scroll-behavior: smooth; }
+
+    body {
+      font-family: system-ui, 'Open Sans', sans-serif;
+      font-size: 14px;
+      line-height: 1.6;
+      color: var(--camel-text);
+      background: var(--camel-light);
+      min-height: 100vh;
+    }
+
+    /* ─── Top nav bar ─── */
+    .site-nav {
+      position: sticky;
+      top: 0;
+      z-index: 100;
+      background: var(--camel-navy);
+      color: var(--camel-white);
+      height: 52px;
+      display: flex;
+      align-items: center;
+      padding: 0 1.5rem;
+      gap: 1rem;
+      box-shadow: 0 2px 10px rgba(0,0,0,.35);
+    }
+    .site-nav .logo-mark {
+      width: 30px;
+      height: 30px;
+      background: var(--camel-orange);
+      border-radius: 5px;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      font-weight: 800;
+      font-size: 16px;
+      flex-shrink: 0;
+      letter-spacing: -1px;
+    }
+    .site-nav .brand-name  { font-weight: 700; font-size: .95rem; }
+    .site-nav .brand-sub   { font-size: .7rem; opacity: .65; }
+    .site-nav .nav-spacer  { flex: 1; }
+    .site-nav .nav-links   { display: flex; gap: 1.25rem; }
+    .site-nav .nav-links a {
+      color: rgba(255,255,255,.75);
+      text-decoration: none;
+      font-size: .8rem;
+      font-weight: 600;
+      transition: color .15s;
+    }
+    .site-nav .nav-links a:hover { color: var(--camel-orange); }
+    .badge-pill {
+      background: var(--camel-orange);
+      color: var(--camel-white);
+      font-size: .65rem;
+      font-weight: 700;
+      padding: 2px 9px;
+      border-radius: 99px;
+      letter-spacing: .06em;
+      text-transform: uppercase;
+    }
+
+    /* ─── Hero ─── */
+    .hero {
+      background: var(--camel-navy);
+      color: var(--camel-white);
+      padding: 2.5rem 1.5rem 2.75rem;
+      border-bottom: 4px solid var(--camel-orange);
+    }
+    .hero-inner { max-width: 900px; margin: 0 auto; }
+    .hero h1 {
+      font-size: 1.75rem;
+      font-weight: 800;
+      margin-bottom: .4rem;
+      letter-spacing: -.5px;
+    }
+    .hero h1 code {
+      color: var(--camel-orange);
+      font-family: 'Courier New', monospace;
+      font-size: 1.55rem;
+    }
+    .hero p {
+      opacity: .8;
+      max-width: 640px;
+      font-size: .9rem;
+      margin-bottom: .75rem;
+    }
+    .hero-hint {
+      display: inline-block;
+      background: rgba(255,255,255,.07);
+      border: 1px solid rgba(255,255,255,.15);
+      border-radius: var(--radius);
+      padding: .5rem .9rem;
+      font-family: 'Courier New', monospace;
+      font-size: .78rem;
+      color: #9db8d2;
+      line-height: 1.7;
+    }
+
+    /* ─── Layout ─── */
+    .content {
+      max-width: 1140px;
+      margin: 0 auto;
+      padding: 2rem 1.5rem 3rem;
+    }
+
+    /* ─── Summary badge ─── */
+    #summary {
+      display: inline-block;
+      font-size: 1rem;
+      font-weight: 700;
+      padding: .4rem 1.1rem;
+      border-radius: 99px;
+      margin-bottom: 1.75rem;
+      letter-spacing: .02em;
+    }
+    #summary.running  { background: #e8eaf6; color: var(--camel-navy); }
+    #summary.all-pass { background: #e8f5e9; color: #1b5e20; }
+    #summary.has-fail { background: #fce4ec; color: #880e4f; }
+
+    /* ─── Sections ─── */
+    .section { margin-bottom: 2.5rem; }
+
+    .section-header {
+      display: flex;
+      align-items: center;
+      gap: .65rem;
+      margin-bottom: 1rem;
+      padding-bottom: .6rem;
+      border-bottom: 2px solid var(--camel-border);
+    }
+    .section-header h2 {
+      font-size: 1.15rem;
+      font-weight: 700;
+      color: var(--camel-navy);
+    }
+    .section-tag {
+      font-size: .65rem;
+      font-weight: 700;
+      padding: 2px 8px;
+      border-radius: 99px;
+      letter-spacing: .06em;
+      text-transform: uppercase;
+      white-space: nowrap;
+    }
+    .tag-lifecycle { background: #e8eaf6; color: #303284; }
+    .tag-render    { background: #fff3e0; color: #9a4200; }
+    .tag-api       { background: #e3f2fd; color: #0d47a1; }
+    .tag-eip       { background: #fce4ec; color: #880e4f; }
+    .tag-color     { background: #f3e5f5; color: #4a148c; }
+    .tag-polling   { background: #e8f5e9; color: #1b5e20; }
+
+    /* ─── Cards ─── */
+    .card {
+      background: var(--camel-white);
+      border: 1px solid var(--camel-border);
+      border-top: 3px solid var(--camel-orange);
+      border-radius: var(--radius);
+      overflow: hidden;
+    }
+    .card-header {
+      padding: .7rem 1rem .55rem;
+      border-bottom: 1px solid var(--camel-border);
+    }
+    .card-header h3 {
+      font-size: .9rem;
+      font-weight: 700;
+      color: var(--camel-navy);
+      margin-bottom: .15rem;
+    }
+    .card-header p {
+      font-size: .78rem;
+      color: var(--camel-muted);
+    }
+    .card-header p code {
+      font-size: .78rem;
+      background: #f0f0f0;
+      border-radius: 3px;
+      padding: 0 3px;
+    }
+
+    /* ─── Test rows ─── */
+    .test-list { list-style: none; }
+    .test-row {
+      display: flex;
+      align-items: baseline;
+      gap: .6rem;
+      padding: .55rem 1rem;
+      font-size: .84rem;
+      border-bottom: 1px solid var(--camel-border);
+    }
+    .test-row:last-child { border-bottom: none; }
+    .test-row.pending { color: var(--camel-muted); }
+    .test-row.pass    { background: #f0faf3; }
+    .test-row.fail    { background: #fff5f5; }
+
+    .badge {
+      font-size: .62rem;
+      font-weight: 800;
+      padding: 2px 7px;
+      border-radius: 4px;
+      letter-spacing: .07em;
+      text-transform: uppercase;
+      flex-shrink: 0;
+    }
+    .pass-badge { background: #c8e6c9; color: #1b5e20; }
+    .fail-badge { background: #ffcdd2; color: #b71c1c; }
+    .run-badge  { background: #e8eaf6; color: #303284; }
+
+    .test-label { flex: 1; }
+    .err-msg    { font-size: .78rem; color: #b71c1c; font-family: 'Courier 
New', monospace; }
+
+    /* ─── Footer ─── */
+    .site-footer {
+      background: var(--camel-navy);
+      color: rgba(255,255,255,.55);
+      text-align: center;
+      padding: 1.5rem;
+      font-size: .78rem;
+    }
+    .site-footer a { color: var(--camel-orange); text-decoration: none; }
+    .site-footer a:hover { text-decoration: underline; }
+  </style>
+</head>
+<body>
+
+<!-- ─── Navigation ─── -->
+<nav class="site-nav" aria-label="Site navigation">
+  <div class="logo-mark" aria-hidden="true">C</div>
+  <div>
+    <div class="brand-name">Apache Camel</div>
+    <div class="brand-sub">camel-route-diagram</div>
+  </div>
+  <div class="nav-spacer"></div>
+  <nav class="nav-links" aria-label="Jump to section">
+    <a href="#lifecycle">Lifecycle</a>
+    <a href="#rendering">Rendering</a>
+    <a href="#api">Attribute API</a>
+    <a href="#eips">EIP Nodes</a>
+    <a href="#colors">Colors</a>
+    <a href="#polling">Polling</a>
+    <a href="smoke-test.html">Smoke Test</a>
+  </nav>
+  <span class="badge-pill">Integration Tests</span>
+</nav>
+
+<!-- ─── Hero ─── -->
+<header class="hero">
+  <div class="hero-inner">
+    <h1><code>&lt;camel-route-diagram&gt;</code> Integration Tests</h1>
+    <p>
+      Automated browser tests for the route-diagram web component. Every 
assertion drives the public
+      component API (attributes, shadow DOM) with mocked fetch responses. Open 
the page and all rows
+      should turn green — no running Camel instance required.
+    </p>
+    <span class="hero-hint">
+      cd components/camel-diagram/src/<br>
+      python3 -m http.server 8080 &amp;&amp; open 
http://localhost:8080/test/resources/integration-test.html
+    </span>
+  </div>
+</header>
+
+<!-- ─── Main content ─── -->
+<main class="content">
+  <div id="summary" class="running">Running…</div>
+  <div id="results"></div>
+</main>
+
+<!-- Hidden container for component instances created during tests -->
+<div id="mount" hidden aria-hidden="true"></div>
+
+<!-- ─── Footer ─── -->
+<footer class="site-footer">
+  <p>
+    <a href="https://camel.apache.org/"; rel="noopener noreferrer">Apache 
Camel</a>
+    &nbsp;·&nbsp;
+    <code>camel-route-diagram</code> web component
+    &nbsp;·&nbsp;
+    Integration tests — not a production page.
+  </p>
+</footer>
+
+<script type="module">
+import 
'../../main/resources/META-INF/resources/camel/diagram/camel-route-diagram.js';
+
+/* ══════════════════════════════════════════════════════════════════════════
+ * Mini test runner
+ * ══════════════════════════════════════════════════════════════════════════ 
*/
+
+const suites = [];
+let currentSuite = null;
+
+function describe(label, fn) {
+    const suite = { label, tests: [] };
+    suites.push(suite);
+    currentSuite = suite;
+    fn();
+    currentSuite = null;
+}
+
+function it(label, fn) {
+    currentSuite.tests.push({ label, fn });
+}
+
+function expect(value) {
+    const pass = (condition, msg) => { if (!condition) throw new Error(msg); };
+    const str  = () => String(value ?? '');
+    const matchers = {
+        toBe:            (x) => pass(value === x,          `Expected 
${JSON.stringify(x)}, got ${JSON.stringify(value)}`),
+        toBeTruthy:      ()  => pass(!!value,               `Expected truthy, 
got ${JSON.stringify(value)}`),
+        toBeFalsy:       ()  => pass(!value,                `Expected falsy, 
got ${JSON.stringify(value)}`),
+        toContain:       (x) => pass(str().includes(x),    `Expected to 
contain ${JSON.stringify(x)}`),
+        toBeGreaterThan: (n) => pass(value > n,             `Expected > ${n}, 
got ${value}`),
+    };
+    matchers.not = {
+        toContain: (x) => pass(!str().includes(x), `Expected NOT to contain 
${JSON.stringify(x)}`),
+    };
+    return matchers;
+}
+
+/* ══════════════════════════════════════════════════════════════════════════
+ * Helpers
+ * ══════════════════════════════════════════════════════════════════════════ 
*/
+
+let fetchLog = [];
+const mountEl = document.getElementById('mount');
+
+function mockFetch(handler) {
+    window.fetch = (url, _opts) => {
+        fetchLog.push(String(url));
+        return handler(url);
+    };
+}
+
+function mount(attrs = {}) {
+    const el = document.createElement('camel-route-diagram');
+    for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, v);
+    mountEl.appendChild(el);
+    return el;
+}
+
+/**
+ * Polls `predicate` every 30 ms until it returns truthy or `timeout` ms 
elapses.
+ * Rejects with a descriptive error on timeout.
+ */
+function waitFor(predicate, timeout = 2000) {
+    return new Promise((resolve, reject) => {
+        const start = Date.now();
+        const tick = setInterval(() => {
+            try {
+                if (predicate()) { clearInterval(tick); resolve(); return; }
+            } catch (_) { /* predicate may throw transiently */ }
+            if (Date.now() - start > timeout) {
+                clearInterval(tick);
+                reject(new Error(`waitFor timed out after ${timeout} ms`));
+            }
+        }, 30);
+    });
+}
+
+function shadowText(el) { return el.shadowRoot?.textContent ?? ''; }
+function shadowHTML(el) { return el.shadowRoot?.innerHTML  ?? ''; }
+
+// Width of the first rendered route SVG. A branching EIP spreads its branches 
horizontally, so the SVG is much
+// wider than the ~240px of a single linear column; this is the signal the 
branching-layout tests assert on.
+function svgWidth(el) {
+    const svg = el.shadowRoot?.querySelector('svg');
+    return svg ? Number(svg.getAttribute('width')) : 0;
+}
+
+/* ══════════════════════════════════════════════════════════════════════════
+ * Mock route data
+ * ══════════════════════════════════════════════════════════════════════════ 
*/
+
+const MINIMAL = (routeId) => ({
+    routes: [{
+        routeId,
+        code: [
+            { type: 'route', id: 'r0', level: 0, code: 'route' },
+            { type: 'from',  id: 'f1', level: 1, code: 'from[timer:tick]' },
+            { type: 'to',    id: 't1', level: 1, code: 'to[log:out]' },
+        ],
+    }],
+});
+
+const STATS_DATA = {
+    routes: [{
+        routeId: 'stats-route',
+        code: [
+            { type: 'route', id: 'r0', level: 0, code: 'route' },
+            { type: 'from',  id: 'f1', level: 1, code: 'from[timer:tick]',
+              statistics: { exchangesTotal: 42, exchangesFailed: 3 } },
+            { type: 'to',    id: 't1', level: 1, code: 'to[log:out]',
+              statistics: { exchangesTotal: 42, exchangesFailed: 0 } },
+        ],
+    }],
+};
+
+// description is 66 chars, well over the 28-char truncation limit
+const LONG_DESCRIPTION = 
'this-is-a-deliberately-very-long-description-to-trigger-truncation';
+const LONG_DESC_DATA = {
+    routes: [{
+        routeId: 'long-route',
+        code: [
+            { type: 'route', id: 'r0', level: 0, code: 'route' },
+            { type: 'from',  id: 'f1', level: 1, code: 'from[timer]',
+              description: LONG_DESCRIPTION },
+        ],
+    }],
+};
+
+const CHOICE_DATA = {
+    routes: [{
+        routeId: 'choice-route',
+        code: [
+            { type: 'route',     id: 'r0',  level: 0, code: 'route' },
+            { type: 'from',      id: 'f1',  level: 1, code: 'from[timer]' },
+            { type: 'choice',    id: 'ch1', level: 1, code: 'choice' },
+            { type: 'when',      id: 'wh1', level: 2, code: 'when[${body} != 
null]' },
+            { type: 'to',        id: 't1',  level: 3, code: 'to[direct:a]' },
+            { type: 'otherwise', id: 'ow1', level: 2, code: 'otherwise' },
+            { type: 'to',        id: 't2',  level: 3, code: 'to[direct:b]' },
+        ],
+    }],
+};
+
+const CIRCUIT_BREAKER_DATA = {
+    routes: [{
+        routeId: 'cb-route',
+        code: [
+            { type: 'route',          id: 'r0',  level: 0, code: 'route' },
+            { type: 'from',           id: 'f1',  level: 1, code: 'from[timer]' 
},
+            { type: 'circuitBreaker', id: 'cb1', level: 1, code: 
'circuitBreaker' },
+            { type: 'to',             id: 't1',  level: 2, code: 
'to[http:svc/api]' },
+            // onFallback is modeled as a `when` node: the component has no 
dedicated onFallback type styling
+            { type: 'when',           id: 'fb1', level: 2, code: 'onFallback' 
},
+            { type: 'log',            id: 'l1',  level: 3, code: 
'log[fallback]' },
+        ],
+    }],
+};
+
+// Fixtures for the remaining branching EIPs (multicast, doTry, loadBalance, 
recipientList). Each has two or more
+// branches at the same level, which the layout engine must place 
side-by-side. If a type were dropped from the
+// component's BRANCHING_EIPS set, its branches would collapse into a single 
vertical column and the rendered SVG
+// would shrink to the linear width, which the layout tests below assert 
against.
+const MULTICAST_DATA = {
+    routes: [{
+        routeId: 'multicast-route',
+        code: [
+            { type: 'route',     id: 'r0',  level: 0, code: 'route' },
+            { type: 'from',      id: 'f1',  level: 1, code: 'from[timer]' },
+            { type: 'multicast', id: 'mc1', level: 1, code: 'multicast' },
+            { type: 'to',        id: 't1',  level: 2, code: 'to[direct:a]' },
+            { type: 'to',        id: 't2',  level: 2, code: 'to[direct:b]' },
+            { type: 'to',        id: 't3',  level: 2, code: 'to[direct:c]' },
+        ],
+    }],
+};
+
+const DOTRY_DATA = {
+    routes: [{
+        routeId: 'dotry-route',
+        code: [
+            { type: 'route',     id: 'r0',  level: 0, code: 'route' },
+            { type: 'from',      id: 'f1',  level: 1, code: 'from[timer]' },
+            { type: 'doTry',     id: 'dt1', level: 1, code: 'doTry' },
+            { type: 'to',        id: 't1',  level: 2, code: 'to[direct:work]' 
},
+            { type: 'doCatch',   id: 'dc1', level: 2, code: 
'doCatch[Exception]' },
+            { type: 'log',       id: 'l1',  level: 3, code: 'log[caught]' },
+            { type: 'doFinally', id: 'df1', level: 2, code: 'doFinally' },
+            { type: 'log',       id: 'l2',  level: 3, code: 'log[cleanup]' },
+        ],
+    }],
+};
+
+const LOADBALANCE_DATA = {
+    routes: [{
+        routeId: 'loadbalance-route',
+        code: [
+            { type: 'route',       id: 'r0',  level: 0, code: 'route' },
+            { type: 'from',        id: 'f1',  level: 1, code: 'from[timer]' },
+            { type: 'loadBalance', id: 'lb1', level: 1, code: 'loadBalance' },
+            { type: 'to',          id: 't1',  level: 2, code: 'to[direct:a]' },
+            { type: 'to',          id: 't2',  level: 2, code: 'to[direct:b]' },
+        ],
+    }],
+};
+
+const RECIPIENTLIST_DATA = {
+    routes: [{
+        routeId: 'recipientlist-route',
+        code: [
+            { type: 'route',         id: 'r0',  level: 0, code: 'route' },
+            { type: 'from',          id: 'f1',  level: 1, code: 'from[timer]' 
},
+            { type: 'recipientList', id: 'rl1', level: 1, code: 
'recipientList' },
+            { type: 'to',            id: 't1',  level: 2, code: 'to[direct:a]' 
},
+            { type: 'to',            id: 't2',  level: 2, code: 'to[direct:b]' 
},
+        ],
+    }],
+};
+
+const MULTI_ROUTE_DATA = {
+    routes: [
+        MINIMAL('route-alpha').routes[0],
+        MINIMAL('route-beta').routes[0],
+        MINIMAL('route-gamma').routes[0],
+    ],
+};
+
+/* ══════════════════════════════════════════════════════════════════════════
+ * Test suites
+ * ══════════════════════════════════════════════════════════════════════════ 
*/
+
+describe('Component Lifecycle', () => {
+
+    it('shows loading state while fetch is pending', () => {
+        mockFetch(() => new Promise(() => { /* never resolves */ }));
+        const el = mount({ src: '/mock/pending' });
+        // Loading state is rendered synchronously on connectedCallback
+        expect(shadowText(el)).toContain('Loading diagram');
+        el.remove();
+    });
+
+    it('shows error state on HTTP 5xx response', async () => {
+        mockFetch(() => Promise.resolve({ ok: false, status: 500, statusText: 
'Internal Server Error' }));
+        const el = mount({ src: '/mock/broken' });
+        await waitFor(() => shadowHTML(el).includes('class="error"'));
+        expect(shadowText(el)).toContain('HTTP 500');
+        el.remove();
+    });
+
+    it('shows error state when JSON parsing fails', async () => {
+        mockFetch(() => Promise.resolve({
+            ok: true,
+            json: () => Promise.reject(new SyntaxError('Unexpected token')),
+        }));
+        const el = mount({ src: '/mock/bad-json' });
+        await waitFor(() => shadowHTML(el).includes('class="error"'));
+        expect(shadowText(el)).toBeTruthy();
+        el.remove();
+    });
+
+    it('shows error state when response is missing routes array', async () => {
+        mockFetch(() => Promise.resolve({ ok: true, json: () => 
Promise.resolve({ notRoutes: [] }) }));
+        const el = mount({ src: '/mock/no-routes' });
+        await waitFor(() => shadowHTML(el).includes('class="error"'));
+        expect(shadowText(el)).toContain('missing routes array');
+        el.remove();
+    });
+
+});
+
+describe('Rendering', () => {
+
+    it('renders an SVG for a simple two-node route', async () => {
+        mockFetch(() => Promise.resolve({ ok: true, json: () => 
Promise.resolve(MINIMAL('simple')) }));
+        const el = mount({ src: '/mock/simple' });
+        await waitFor(() => shadowHTML(el).includes('<svg'));
+        expect(shadowHTML(el)).toContain('<svg');
+        el.remove();
+    });
+
+    it('renders aria-label for from and to nodes', async () => {
+        mockFetch(() => Promise.resolve({ ok: true, json: () => 
Promise.resolve(MINIMAL('aria-test')) }));
+        const el = mount({ src: '/mock/aria' });
+        await waitFor(() => shadowHTML(el).includes('aria-label'));
+        expect(shadowHTML(el)).toContain('aria-label="from:');
+        expect(shadowHTML(el)).toContain('aria-label="to:');
+        el.remove();
+    });
+
+    it('displays the route label', async () => {
+        mockFetch(() => Promise.resolve({ ok: true, json: () => 
Promise.resolve(MINIMAL('my-test-route')) }));
+        const el = mount({ src: '/mock/label' });
+        await waitFor(() => shadowText(el).includes('my-test-route'));
+        expect(shadowText(el)).toContain('my-test-route');
+        el.remove();
+    });
+
+    it('renders edge connectors between nodes', async () => {
+        mockFetch(() => Promise.resolve({ ok: true, json: () => 
Promise.resolve(MINIMAL('edges')) }));
+        const el = mount({ src: '/mock/edges' });
+        await waitFor(() => shadowHTML(el).includes('<path'));
+        expect(shadowHTML(el)).toContain('<path');
+        el.remove();
+    });
+
+    it('displays exchange statistics when present', async () => {
+        mockFetch(() => Promise.resolve({ ok: true, json: () => 
Promise.resolve(STATS_DATA) }));
+        const el = mount({ src: '/mock/stats' });
+        await waitFor(() => shadowText(el).includes('✓42'));
+        expect(shadowText(el)).toContain('✓42');
+        expect(shadowText(el)).toContain('✗3');
+        el.remove();
+    });
+
+    it('truncates long node descriptions with an ellipsis', async () => {
+        mockFetch(() => Promise.resolve({ ok: true, json: () => 
Promise.resolve(LONG_DESC_DATA) }));
+        const el = mount({ src: '/mock/long' });
+        await waitFor(() => shadowHTML(el).includes('<svg'));
+        expect(shadowText(el)).not.toContain(LONG_DESCRIPTION);
+        expect(shadowText(el)).toContain('…');
+        el.remove();
+    });
+
+});
+
+describe('Attribute API', () => {
+
+    it('appends filter as a query parameter in the fetch URL', async () => {
+        mockFetch(() => Promise.resolve({ ok: true, json: () => 
Promise.resolve(MINIMAL('filter-test')) }));
+        const el = mount({ src: '/mock/filter', filter: 'myRoute' });
+        await waitFor(() => fetchLog.length > 0);
+        expect(fetchLog[0]).toContain('filter=myRoute');
+        el.remove();
+    });
+
+    it('re-fetches and updates content when src attribute changes', async () 
=> {
+        let serveData = MINIMAL('route-alpha');
+        mockFetch(() => Promise.resolve({ ok: true, json: () => 
Promise.resolve(serveData) }));
+        const el = mount({ src: '/mock/src-a' });
+        await waitFor(() => shadowText(el).includes('route-alpha'));
+
+        serveData = MINIMAL('route-beta');
+        el.setAttribute('src', '/mock/src-b');
+        await waitFor(() => shadowText(el).includes('route-beta'));
+        expect(shadowText(el)).not.toContain('route-alpha');
+        el.remove();
+    });
+
+    it('renders all routes when response contains multiple routes', async () 
=> {
+        mockFetch(() => Promise.resolve({ ok: true, json: () => 
Promise.resolve(MULTI_ROUTE_DATA) }));
+        const el = mount({ src: '/mock/multi' });
+        await waitFor(() => shadowText(el).includes('route-gamma'));
+        const cols = el.shadowRoot.querySelectorAll('.route-col');
+        expect(cols.length).toBe(3);
+        el.remove();
+    });
+
+});
+
+describe('EIP Node Types', () => {
+
+    it('renders choice, when, and otherwise nodes', async () => {
+        mockFetch(() => Promise.resolve({ ok: true, json: () => 
Promise.resolve(CHOICE_DATA) }));
+        const el = mount({ src: '/mock/choice' });
+        await waitFor(() => shadowHTML(el).includes('aria-label="choice:'));
+        expect(shadowHTML(el)).toContain('aria-label="choice:');
+        expect(shadowHTML(el)).toContain('aria-label="when:');
+        expect(shadowHTML(el)).toContain('aria-label="otherwise:');
+        el.remove();
+    });
+
+    it('renders circuitBreaker node', async () => {
+        mockFetch(() => Promise.resolve({ ok: true, json: () => 
Promise.resolve(CIRCUIT_BREAKER_DATA) }));
+        const el = mount({ src: '/mock/cb' });
+        await waitFor(() => 
shadowHTML(el).includes('aria-label="circuitBreaker:'));
+        expect(shadowHTML(el)).toContain('aria-label="circuitBreaker:');
+        el.remove();
+    });
+
+});
+
+describe('Branching EIP Layout', () => {
+
+    // Each branching EIP must place its branches side-by-side, producing an 
SVG wider than a single linear column
+    // (~240px). A regression dropping the type from BRANCHING_EIPS would 
stack the branches vertically and the
+    // width would collapse below this threshold.
+    const SIDE_BY_SIDE_MIN_WIDTH = 360;
+
+    it('multicast spreads its branches horizontally', async () => {
+        mockFetch(() => Promise.resolve({ ok: true, json: () => 
Promise.resolve(MULTICAST_DATA) }));
+        const el = mount({ src: '/mock/multicast' });
+        await waitFor(() => shadowHTML(el).includes('aria-label="multicast:'));
+        expect(svgWidth(el)).toBeGreaterThan(SIDE_BY_SIDE_MIN_WIDTH);
+        el.remove();
+    });
+
+    it('doTry spreads its clauses horizontally', async () => {
+        mockFetch(() => Promise.resolve({ ok: true, json: () => 
Promise.resolve(DOTRY_DATA) }));
+        const el = mount({ src: '/mock/dotry' });
+        await waitFor(() => shadowHTML(el).includes('aria-label="doTry:'));
+        expect(svgWidth(el)).toBeGreaterThan(SIDE_BY_SIDE_MIN_WIDTH);
+        el.remove();
+    });
+
+    it('loadBalance spreads its branches horizontally', async () => {
+        mockFetch(() => Promise.resolve({ ok: true, json: () => 
Promise.resolve(LOADBALANCE_DATA) }));
+        const el = mount({ src: '/mock/loadbalance' });
+        await waitFor(() => 
shadowHTML(el).includes('aria-label="loadBalance:'));
+        expect(svgWidth(el)).toBeGreaterThan(SIDE_BY_SIDE_MIN_WIDTH);
+        el.remove();
+    });
+
+    it('recipientList spreads its branches horizontally', async () => {
+        mockFetch(() => Promise.resolve({ ok: true, json: () => 
Promise.resolve(RECIPIENTLIST_DATA) }));
+        const el = mount({ src: '/mock/recipientlist' });
+        await waitFor(() => 
shadowHTML(el).includes('aria-label="recipientList:'));
+        expect(svgWidth(el)).toBeGreaterThan(SIDE_BY_SIDE_MIN_WIDTH);
+        el.remove();
+    });
+
+});
+
+describe('Node Colors', () => {
+
+    it('choice node uses amber fill (#f59e0b)', async () => {
+        mockFetch(() => Promise.resolve({ ok: true, json: () => 
Promise.resolve(CHOICE_DATA) }));
+        const el = mount({ src: '/mock/choice-color' });
+        await waitFor(() => shadowHTML(el).includes('#f59e0b'));
+        expect(shadowHTML(el)).toContain('#f59e0b');
+        el.remove();
+    });
+
+    it('circuitBreaker node uses red fill (#ef4444)', async () => {
+        mockFetch(() => Promise.resolve({ ok: true, json: () => 
Promise.resolve(CIRCUIT_BREAKER_DATA) }));
+        const el = mount({ src: '/mock/cb-color' });
+        await waitFor(() => shadowHTML(el).includes('#ef4444'));
+        expect(shadowHTML(el)).toContain('#ef4444');
+        el.remove();
+    });
+
+});
+
+describe('Polling', () => {
+
+    it('refresh attribute causes repeated fetch calls', async () => {
+        mockFetch(() => Promise.resolve({ ok: true, json: () => 
Promise.resolve(MINIMAL('poll')) }));
+        const el = mount({ src: '/mock/poll', refresh: '50' });
+        await new Promise(r => setTimeout(r, 250));
+        el.remove();
+        expect(fetchLog.length).toBeGreaterThan(2);
+    });
+
+});
+
+/* ══════════════════════════════════════════════════════════════════════════
+ * Runner — renders results into the DOM
+ * ══════════════════════════════════════════════════════════════════════════ 
*/
+
+const SUITE_TAGS = {
+    'Component Lifecycle': 'tag-lifecycle',
+    'Rendering':           'tag-render',
+    'Attribute API':       'tag-api',
+    'EIP Node Types':      'tag-eip',
+    'Branching EIP Layout': 'tag-eip',
+    'Node Colors':         'tag-color',
+    'Polling':             'tag-polling',
+};
+
+async function run() {
+    const resultsEl = document.getElementById('results');
+    const summaryEl = document.getElementById('summary');
+    let passed = 0, failed = 0;
+
+    for (const suite of suites) {
+        const section = document.createElement('section');
+        section.id = suite.label.toLowerCase().replace(/\s+/g, '-');
+        section.className = 'section';
+
+        const tagClass = SUITE_TAGS[suite.label] ?? 'tag-lifecycle';
+        section.innerHTML = `
+          <div class="section-header">
+            <h2>${suite.label}</h2>
+            <span class="section-tag ${tagClass}">${suite.tests.length} 
test${suite.tests.length !== 1 ? 's' : ''}</span>
+          </div>
+          <div class="card"><ul class="test-list"></ul></div>`;
+        resultsEl.appendChild(section);
+        const list = section.querySelector('.test-list');
+
+        for (const test of suite.tests) {
+            const li = document.createElement('li');
+            li.className = 'test-row pending';
+            li.innerHTML = `<span class="badge run-badge">RUN</span><span 
class="test-label">${test.label}</span>`;
+            list.appendChild(li);
+
+            // Reset shared state between tests
+            fetchLog = [];
+            mountEl.innerHTML = '';
+
+            try {
+                await test.fn();
+                li.className = 'test-row pass';
+                li.innerHTML = `<span class="badge 
pass-badge">PASS</span><span class="test-label">${test.label}</span>`;
+                passed++;
+            } catch (err) {
+                li.className = 'test-row fail';
+                li.innerHTML = `<span class="badge 
fail-badge">FAIL</span><span class="test-label">${test.label}</span><span 
class="err-msg">${err.message}</span>`;
+                failed++;
+            }
+
+            // Update summary after each test
+            const total = passed + failed;
+            summaryEl.textContent = `${passed} / ${total} passed`;
+            summaryEl.className = failed > 0 ? 'has-fail' : 'running';
+        }
+    }
+
+    const total = passed + failed;
+    summaryEl.textContent = failed === 0
+        ? `All ${total} tests passed`
+        : `${passed} / ${total} passed — ${failed} failed`;
+    summaryEl.className = failed === 0 ? 'all-pass' : 'has-fail';
+}
+
+run();
+</script>
+</body>
+</html>
diff --git a/components/camel-diagram/src/test/resources/smoke-test.html 
b/components/camel-diagram/src/test/resources/smoke-test.html
new file mode 100644
index 000000000000..2e9a79cce705
--- /dev/null
+++ b/components/camel-diagram/src/test/resources/smoke-test.html
@@ -0,0 +1,720 @@
+<!--
+
+    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.
+
+-->
+<!DOCTYPE html>
+<!--
+  Local smoke test for the camel-route-diagram web component.
+
+  Browsers block ES module imports from file:// URLs (CORS/null-origin).
+  Serve from the src/ directory:
+
+    cd components/camel-diagram/src/
+    python3 -m http.server 8080
+    open http://localhost:8080/test/resources/smoke-test.html
+
+  All data is mocked — no running Camel instance needed.
+-->
+<html lang="en">
+<head>
+  <meta charset="UTF-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1">
+  <title>camel-route-diagram · Smoke Test</title>
+  <script type="module">
+    import 
'../../main/resources/META-INF/resources/camel/diagram/camel-route-diagram.js';
+  </script>
+  <script>
+    /* eslint-disable */
+
+    /* ──────────────────────────────────────────────────────────────────────
+     * Mock data registry — each key is a URL path used as src="…" below.
+     * ────────────────────────────────────────────────────────────────────── 
*/
+    const MOCK = {
+
+      /* 1. Simple route: used in the three theme-comparison cards */
+      '/mock/simple': {
+        routes: [{
+          routeId: 'demo-route',
+          from: 'timer:tick',
+          code: [
+            { type: 'route',     id: 'r0',  level: 0, code: 'route' },
+            { type: 'from',      id: 'f1',  level: 1, code: 
'from[timer:tick]', uri: 'timer:tick' },
+            { type: 'log',       id: 'l1',  level: 1, code: 'log[Hello World]',
+              statistics: { exchangesTotal: 42, exchangesFailed: 1 } },
+            { type: 'choice',    id: 'ch1', level: 1, code: 'choice' },
+            { type: 'when',      id: 'wh1', level: 2, code: 'when[${body} != 
null]' },
+            { type: 'to',        id: 't1',  level: 3, code: 'to[mock:out]' },
+            { type: 'otherwise', id: 'ow1', level: 2, code: 'otherwise' },
+            { type: 'to',        id: 't2',  level: 3, code: 'to[mock:dead]' },
+          ],
+        }],
+      },
+
+      /* 2. Content-Based Router — choice with two when + otherwise */
+      '/mock/content-router': {
+        routes: [{
+          routeId: 'order-router',
+          from: 'platform-http:/api/orders',
+          code: [
+            { type: 'route',     id: 'r0',  level: 0, code: 'route' },
+            { type: 'from',      id: 'f1',  level: 1, code: 
'from[platform-http:/api/orders]' },
+            { type: 'log',       id: 'l1',  level: 1, code: 'log[Order 
received: ${body}]' },
+            { type: 'choice',    id: 'ch1', level: 1, code: 'choice' },
+            { type: 'when',      id: 'wh1', level: 2, code: 
'when[${header.tier} == premium]' },
+            { type: 'to',        id: 't1',  level: 3, code: 
'to[direct:premium-fulfillment]' },
+            { type: 'when',      id: 'wh2', level: 2, code: 
'when[${header.tier} == standard]' },
+            { type: 'to',        id: 't2',  level: 3, code: 
'to[direct:standard-fulfillment]' },
+            { type: 'otherwise', id: 'ow1', level: 2, code: 'otherwise' },
+            { type: 'to',        id: 't3',  level: 3, code: 
'to[direct:default-queue]' },
+            { type: 'log',       id: 'l2',  level: 1, code: 'log[Order 
dispatched]' },
+          ],
+        }],
+      },
+
+      /* 3. Error Handling — doTry + choice inside + doCatch + doFinally */
+      '/mock/error-handling': {
+        routes: [{
+          routeId: 'safe-processor',
+          from: 'direct:process',
+          code: [
+            { type: 'route',     id: 'r0',   level: 0, code: 'route' },
+            { type: 'from',      id: 'f1',   level: 1, code: 
'from[direct:process]' },
+            { type: 'setHeader', id: 'sh1',  level: 1, code: 'setHeader[type]' 
},
+            { type: 'doTry',     id: 'dt1',  level: 1, code: 'doTry' },
+            { type: 'choice',    id: 'ch1',  level: 2, code: 'choice' },
+            { type: 'when',      id: 'wh1',  level: 3, code: 
'when[header(type) == A]' },
+            { type: 'log',       id: 'la',   level: 4, code: 'log[Type A 
processing]' },
+            { type: 'otherwise', id: 'ow1',  level: 3, code: 'otherwise' },
+            { type: 'log',       id: 'lb',   level: 4, code: 'log[Default 
processing]' },
+            { type: 'doCatch',   id: 'dc1',  level: 2, code: 
'doCatch[Exception]' },
+            { type: 'log',       id: 'le',   level: 3, code: 'log[Error: 
${exception.message}]' },
+            { type: 'doFinally', id: 'df1',  level: 2, code: 'doFinally' },
+            { type: 'log',       id: 'lf',   level: 3, code: 'log[Cleanup 
resources]' },
+            { type: 'log',       id: 'lend', level: 1, code: 'log[Processing 
complete]' },
+          ],
+        }],
+      },
+
+      /* 4. Multicast — scatter to three parallel branches */
+      '/mock/multicast': {
+        routes: [{
+          routeId: 'order-fanout',
+          from: 'direct:orders',
+          code: [
+            { type: 'route',     id: 'r0',  level: 0, code: 'route' },
+            { type: 'from',      id: 'f1',  level: 1, code: 
'from[direct:orders]' },
+            { type: 'log',       id: 'l1',  level: 1, code: 'log[Dispatching 
order]' },
+            { type: 'multicast', id: 'mc1', level: 1, code: 'multicast' },
+            { type: 'to',        id: 't1',  level: 2, code: 
'to[direct:billing]' },
+            { type: 'to',        id: 't2',  level: 2, code: 
'to[direct:shipping]' },
+            { type: 'to',        id: 't3',  level: 2, code: 
'to[direct:notifications]' },
+            { type: 'log',       id: 'l2',  level: 1, code: 'log[All branches 
complete]' },
+          ],
+        }],
+      },
+
+      /* 5. Circuit Breaker — resilience4j with onFallback branch */
+      '/mock/circuit-breaker': {
+        routes: [{
+          routeId: 'resilient-http-call',
+          from: 'timer:probe',
+          code: [
+            { type: 'route',          id: 'r0',  level: 0, code: 'route' },
+            { type: 'from',           id: 'f1',  level: 1, code: 
'from[timer:probe?period=5s]' },
+            { type: 'circuitBreaker', id: 'cb1', level: 1, code: 
'circuitBreaker[resilience4j]' },
+            { type: 'to',             id: 't1',  level: 2, code: 
'to[http:payment-svc/api/charge]' },
+            // onFallback is modeled as a `when` node — the component has no 
dedicated onFallback type styling
+            { type: 'when',           id: 'fb1', level: 2, code: 'onFallback' 
},
+            { type: 'log',            id: 'l1',  level: 3, code: 'log[Service 
unavailable]' },
+            { type: 'log',            id: 'l2',  level: 1, code: 'log[Result: 
${body}]' },
+          ],
+        }],
+      },
+
+      /* 6. Exchange statistics — per-node exchangesTotal / exchangesFailed */
+      '/mock/metrics': {
+        routes: [{
+          routeId: 'event-consumer',
+          from: 'kafka:events',
+          code: [
+            { type: 'route',     id: 'r0',  level: 0, code: 'route' },
+            { type: 'from',      id: 'f1',  level: 1, code: 
'from[kafka:events]',
+              statistics: { exchangesTotal: 18420, exchangesFailed: 0 } },
+            { type: 'log',       id: 'l1',  level: 1, code: 'log[Processing 
event]',
+              statistics: { exchangesTotal: 18420, exchangesFailed: 0 } },
+            { type: 'choice',    id: 'ch1', level: 1, code: 'choice' },
+            { type: 'when',      id: 'wh1', level: 2, code: 
'when[${header.priority} == high]',
+              statistics: { exchangesTotal: 4231, exchangesFailed: 0 } },
+            { type: 'to',        id: 't1',  level: 3, code: 
'to[direct:priority-queue]',
+              statistics: { exchangesTotal: 4231, exchangesFailed: 0 } },
+            { type: 'otherwise', id: 'ow1', level: 2, code: 'otherwise',
+              statistics: { exchangesTotal: 14189, exchangesFailed: 32 } },
+            { type: 'to',        id: 't2',  level: 3, code: 
'to[direct:standard-queue]',
+              statistics: { exchangesTotal: 14189, exchangesFailed: 32 } },
+          ],
+        }],
+      },
+
+      /* 7. Long URI — Kafka consumer with many query parameters */
+      '/mock/kafka-long-uri': {
+        routes: [{
+          routeId: 'kafka-consumer',
+          from: 'kafka:my-orders-topic',
+          code: [
+            { type: 'route', id: 'r0', level: 0, code: 'route' },
+            { type: 'from',  id: 'f1', level: 1,
+              code: 
'from[kafka:my-orders-topic?brokers=localhost:9092&groupId=order-consumer-group&autoOffsetReset=earliest]'
 },
+            { type: 'log',   id: 'l1', level: 1, code: 'log[Order received: 
${body}]' },
+            { type: 'to',    id: 't1', level: 1, code: 
'to[direct:process-order]' },
+          ],
+        }],
+      },
+
+      /* 8. Multi-route — three-route order-processing pipeline */
+      '/mock/multi-route': {
+        routes: [
+          {
+            routeId: 'order-generator',
+            from: 'timer:orders',
+            code: [
+              { type: 'route',   id: 'og-r0',  level: 0, code: 'route' },
+              { type: 'from',    id: 'og-f1',  level: 1, code: 
'from[timer:orders?period=5s]',
+                description: 'Generate test orders' },
+              { type: 'setBody', id: 'og-sb1', level: 1, code: 
'setBody[constant(test-order)]',
+                description: 'Set payload' },
+              { type: 'to',      id: 'og-t1',  level: 1, code: 
'to[direct:process-order]',
+                description: 'Hand off for processing' },
+            ],
+          },
+          {
+            routeId: 'order-processor',
+            from: 'direct:process-order',
+            code: [
+              { type: 'route',     id: 'op-r0',  level: 0, code: 'route' },
+              { type: 'from',      id: 'op-f1',  level: 1, code: 
'from[direct:process-order]' },
+              { type: 'log',       id: 'op-l1',  level: 1, code: 
'log[Processing: ${body}]' },
+              { type: 'choice',    id: 'op-ch1', level: 1, code: 'choice' },
+              { type: 'when',      id: 'op-wh1', level: 2, code: 
'when[${header.valid}]' },
+              { type: 'to',        id: 'op-t1',  level: 3, code: 
'to[direct:validate-order]' },
+              { type: 'otherwise', id: 'op-ow1', level: 2, code: 'otherwise' },
+              { type: 'to',        id: 'op-t2',  level: 3, code: 
'to[mock:dead-letter]' },
+            ],
+          },
+          {
+            routeId: 'order-validator',
+            from: 'direct:validate-order',
+            code: [
+              { type: 'route', id: 'ov-r0', level: 0, code: 'route' },
+              { type: 'from',  id: 'ov-f1', level: 1, code: 
'from[direct:validate-order]' },
+              { type: 'log',   id: 'ov-l1', level: 1, code: 'log[Validating 
order]' },
+              { type: 'to',    id: 'ov-t1', level: 1, code: 
'to[kafka:validated-orders]' },
+            ],
+          },
+        ],
+      },
+    };
+
+    window.fetch = url => {
+      const path = new URL(url, location.href).pathname;
+      const data = MOCK[path];
+      if (!data) return Promise.resolve({ ok: false, status: 404, statusText: 
'Not Found' });
+      return Promise.resolve({ ok: true, json: () => Promise.resolve(data) });
+    };
+  </script>
+  <style>
+    /* ─── Camel design tokens (mirrored from camel.apache.org) ─── */
+    :root {
+      --camel-orange:      #e97826;
+      --camel-orange-dark: #cf7428;
+      --camel-navy:        #303284;
+      --camel-purple:      #4f51ae;
+      --camel-light:       #f5f5f5;
+      --camel-border:      #e1e1e1;
+      --camel-text:        #333;
+      --camel-muted:       #5d5d5d;
+      --camel-white:       #fff;
+      --radius:            6px;
+    }
+
+    *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
+
+    html { scroll-behavior: smooth; }
+
+    body {
+      font-family: system-ui, 'Open Sans', sans-serif;
+      font-size: 14px;
+      line-height: 1.6;
+      color: var(--camel-text);
+      background: var(--camel-light);
+      min-height: 100vh;
+    }
+
+    /* ─── Top nav bar ─── */
+    .site-nav {
+      position: sticky;
+      top: 0;
+      z-index: 100;
+      background: var(--camel-navy);
+      color: var(--camel-white);
+      height: 52px;
+      display: flex;
+      align-items: center;
+      padding: 0 1.5rem;
+      gap: 1rem;
+      box-shadow: 0 2px 10px rgba(0,0,0,.35);
+    }
+    .site-nav .logo-mark {
+      width: 30px;
+      height: 30px;
+      background: var(--camel-orange);
+      border-radius: 5px;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      font-weight: 800;
+      font-size: 16px;
+      flex-shrink: 0;
+      letter-spacing: -1px;
+    }
+    .site-nav .brand-name  { font-weight: 700; font-size: .95rem; }
+    .site-nav .brand-sub   { font-size: .7rem; opacity: .65; }
+    .site-nav .nav-spacer  { flex: 1; }
+    .site-nav .nav-links   { display: flex; gap: 1.25rem; }
+    .site-nav .nav-links a {
+      color: rgba(255,255,255,.75);
+      text-decoration: none;
+      font-size: .8rem;
+      font-weight: 600;
+      transition: color .15s;
+    }
+    .site-nav .nav-links a:hover { color: var(--camel-orange); }
+    .badge-pill {
+      background: var(--camel-orange);
+      color: var(--camel-white);
+      font-size: .65rem;
+      font-weight: 700;
+      padding: 2px 9px;
+      border-radius: 99px;
+      letter-spacing: .06em;
+      text-transform: uppercase;
+    }
+
+    /* ─── Hero ─── */
+    .hero {
+      background: var(--camel-navy);
+      color: var(--camel-white);
+      padding: 2.5rem 1.5rem 2.75rem;
+      border-bottom: 4px solid var(--camel-orange);
+    }
+    .hero-inner { max-width: 900px; margin: 0 auto; }
+    .hero h1 {
+      font-size: 1.75rem;
+      font-weight: 800;
+      margin-bottom: .4rem;
+      letter-spacing: -.5px;
+    }
+    .hero h1 code {
+      color: var(--camel-orange);
+      font-family: 'Courier New', monospace;
+      font-size: 1.55rem;
+    }
+    .hero p {
+      opacity: .8;
+      max-width: 640px;
+      font-size: .9rem;
+      margin-bottom: .75rem;
+    }
+    .hero-hint {
+      display: inline-block;
+      background: rgba(255,255,255,.07);
+      border: 1px solid rgba(255,255,255,.15);
+      border-radius: var(--radius);
+      padding: .5rem .9rem;
+      font-family: 'Courier New', monospace;
+      font-size: .78rem;
+      color: #9db8d2;
+      line-height: 1.7;
+    }
+
+    /* ─── Layout ─── */
+    .content {
+      max-width: 1140px;
+      margin: 0 auto;
+      padding: 2rem 1.5rem 3rem;
+    }
+
+    /* ─── Sections ─── */
+    .section { margin-bottom: 3rem; }
+
+    .section-header {
+      display: flex;
+      align-items: center;
+      gap: .65rem;
+      margin-bottom: 1rem;
+      padding-bottom: .6rem;
+      border-bottom: 2px solid var(--camel-border);
+    }
+    .section-header h2 {
+      font-size: 1.15rem;
+      font-weight: 700;
+      color: var(--camel-navy);
+    }
+    .section-tag {
+      font-size: .65rem;
+      font-weight: 700;
+      padding: 2px 8px;
+      border-radius: 99px;
+      letter-spacing: .06em;
+      text-transform: uppercase;
+      white-space: nowrap;
+    }
+    .tag-theme      { background: #e8eaf6; color: #303284; }
+    .tag-eip        { background: #fff3e0; color: #9a4200; }
+    .tag-error      { background: #fce4ec; color: #880e4f; }
+    .tag-resilience { background: #fbe9e7; color: #b71c1c; }
+    .tag-metrics    { background: #e8f5e9; color: #1b5e20; }
+    .tag-kafka      { background: #f3e5f5; color: #4a148c; }
+    .tag-multi      { background: #e3f2fd; color: #0d47a1; }
+
+    /* ─── Cards ─── */
+    .card {
+      background: var(--camel-white);
+      border: 1px solid var(--camel-border);
+      border-top: 3px solid var(--camel-orange);
+      border-radius: var(--radius);
+      overflow: hidden;
+    }
+    .card-header {
+      padding: .7rem 1rem .55rem;
+      border-bottom: 1px solid var(--camel-border);
+    }
+    .card-header h3 {
+      font-size: .9rem;
+      font-weight: 700;
+      color: var(--camel-navy);
+      margin-bottom: .15rem;
+    }
+    .card-header h3 code {
+      font-size: .85rem;
+      background: #f0f0f0;
+      border-radius: 3px;
+      padding: 0 3px;
+    }
+    .card-header p {
+      font-size: .78rem;
+      color: var(--camel-muted);
+    }
+    .card-body         { padding: 1rem; }
+    .card-body.dark-bg { background: #0f172a; padding: .75rem; }
+
+    /* ─── Grids ─── */
+    .grid-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem; 
}
+    .grid-2 { display: grid; grid-template-columns: repeat(2, 1fr); gap: 1rem; 
}
+
+    /* ─── Scrollable diagrams ─── */
+    .diagram-scroll { overflow-x: auto; }
+
+    /* ─── Footer ─── */
+    .site-footer {
+      background: var(--camel-navy);
+      color: rgba(255,255,255,.55);
+      text-align: center;
+      padding: 1.5rem;
+      font-size: .78rem;
+    }
+    .site-footer a { color: var(--camel-orange); text-decoration: none; }
+    .site-footer a:hover { text-decoration: underline; }
+
+    /* ─── Responsive ─── */
+    @media (max-width: 800px) {
+      .grid-3, .grid-2 { grid-template-columns: 1fr; }
+    }
+  </style>
+</head>
+<body>
+
+<!-- ─── Navigation ─── -->
+<nav class="site-nav" aria-label="Site navigation">
+  <div class="logo-mark" aria-hidden="true">C</div>
+  <div>
+    <div class="brand-name">Apache Camel</div>
+    <div class="brand-sub">camel-route-diagram</div>
+  </div>
+  <div class="nav-spacer"></div>
+  <nav class="nav-links" aria-label="Jump to section">
+    <a href="#themes">Themes</a>
+    <a href="#eips">EIPs</a>
+    <a href="#error">Error Handling</a>
+    <a href="#resilience">Resilience</a>
+    <a href="#metrics">Metrics</a>
+    <a href="#kafka">Long URI</a>
+    <a href="#multi">Multi-Route</a>
+  </nav>
+  <span class="badge-pill">Smoke Test</span>
+</nav>
+
+<!-- ─── Hero ─── -->
+<header class="hero">
+  <div class="hero-inner">
+    <h1><code>&lt;camel-route-diagram&gt;</code></h1>
+    <p>
+      A local verification page for the route-diagram web component. Each 
section exercises a different
+      EIP pattern, rendering feature, or edge case. All data is mocked — no 
running Camel instance required.
+    </p>
+    <span class="hero-hint">
+      cd components/camel-diagram/src/<br>
+      python3 -m http.server 8080 &amp;&amp; open 
http://localhost:8080/test/resources/smoke-test.html
+    </span>
+  </div>
+</header>
+
+<!-- ─── Main content ─── -->
+<main class="content">
+
+  <!-- ══════════════════════════════════════════════════════
+       SECTION 1 — Theme variants
+       ══════════════════════════════════════════════════════ -->
+  <section id="themes" class="section">
+    <div class="section-header">
+      <h2>Theme Variants</h2>
+      <span class="section-tag tag-theme">Theming</span>
+    </div>
+    <div class="grid-3">
+
+      <div class="card">
+        <div class="card-header">
+          <h3>Auto (OS preference)</h3>
+          <p>
+            Follows <code>prefers-color-scheme</code>. No <code>--crd-*</code> 
variables
+            are set — the component picks light or dark based on your OS 
setting.
+          </p>
+        </div>
+        <div class="card-body diagram-scroll">
+          <camel-route-diagram src="/mock/simple"></camel-route-diagram>
+        </div>
+      </div>
+
+      <div class="card">
+        <div class="card-header">
+          <h3>Light (forced)</h3>
+          <p>
+            Override via inline <code>style="--crd-bg:#fff; --crd-fg:#1e293b; 
--crd-edge:#94a3b8"</code>
+            to pin the light palette regardless of OS setting.
+          </p>
+        </div>
+        <div class="card-body diagram-scroll">
+          <camel-route-diagram src="/mock/simple"
+            style="--crd-bg:#ffffff; --crd-fg:#1e293b; --crd-edge:#94a3b8;">
+          </camel-route-diagram>
+        </div>
+      </div>
+
+      <div class="card">
+        <div class="card-header">
+          <h3>Dark (forced)</h3>
+          <p>
+            Override via <code>style="--crd-bg:#0f172a; --crd-fg:#e2e8f0; 
--crd-edge:#475569"</code>
+            to pin the dark palette regardless of OS setting.
+          </p>
+        </div>
+        <div class="card-body diagram-scroll dark-bg">
+          <camel-route-diagram src="/mock/simple"
+            style="--crd-bg:#0f172a; --crd-fg:#e2e8f0; --crd-edge:#475569;">
+          </camel-route-diagram>
+        </div>
+      </div>
+
+    </div>
+  </section>
+
+  <!-- ══════════════════════════════════════════════════════
+       SECTION 2 — Content-Based Router
+       ══════════════════════════════════════════════════════ -->
+  <section id="eips" class="section">
+    <div class="section-header">
+      <h2>Content-Based Router</h2>
+      <span class="section-tag tag-eip">EIP · choice</span>
+    </div>
+    <div class="card">
+      <div class="card-header">
+        <h3>Order Router — <code>choice</code> with two <code>when</code> + 
<code>otherwise</code></h3>
+        <p>
+          Routes incoming HTTP orders to premium, standard, or default 
fulfillment channels based on a
+          <code>${header.tier}</code> value. Verifies that three side-by-side 
branches render correctly
+          and that the post-choice <code>log</code> node reconnects from the 
<code>choice</code> merge point.
+        </p>
+      </div>
+      <div class="card-body diagram-scroll">
+        <camel-route-diagram src="/mock/content-router"></camel-route-diagram>
+      </div>
+    </div>
+  </section>
+
+  <!-- ══════════════════════════════════════════════════════
+       SECTION 3 — Error Handling
+       ══════════════════════════════════════════════════════ -->
+  <section id="error" class="section">
+    <div class="section-header">
+      <h2>Error Handling</h2>
+      <span class="section-tag tag-error">EIP · doTry · doCatch · 
doFinally</span>
+    </div>
+    <div class="card">
+      <div class="card-header">
+        <h3>Safe Processor — <code>doTry</code> wrapping a <code>choice</code>,
+            with <code>doCatch</code> and <code>doFinally</code></h3>
+        <p>
+          A <code>choice</code> inside a <code>doTry</code>: the doTry acts as 
a branching EIP so its three
+          clauses (<em>try block</em>, <em>doCatch</em>, <em>doFinally</em>) 
are laid side-by-side.
+          The post-try <code>log</code> node reconnects from the 
<code>doTry</code> merge point.
+          Mirrors the 
<code>testChoiceInsideDoTryNoSpuriousMergeConnection</code> layout-engine test.
+        </p>
+      </div>
+      <div class="card-body diagram-scroll">
+        <camel-route-diagram src="/mock/error-handling"></camel-route-diagram>
+      </div>
+    </div>
+  </section>
+
+  <!-- ══════════════════════════════════════════════════════
+       SECTION 4 — Scatter-Gather & Circuit Breaker
+       ══════════════════════════════════════════════════════ -->
+  <section id="resilience" class="section">
+    <div class="section-header">
+      <h2>Scatter-Gather &amp; Resilience</h2>
+      <span class="section-tag tag-eip">EIP · multicast</span>
+      <span class="section-tag tag-resilience">EIP · circuitBreaker</span>
+    </div>
+    <div class="grid-2">
+
+      <div class="card">
+        <div class="card-header">
+          <h3>Order Fan-out — <code>multicast</code> to three branches</h3>
+          <p>
+            Sends each order simultaneously to billing, shipping, and 
notification routes.
+            Verifies that three <code>to</code> nodes are placed side-by-side 
and that the
+            post-multicast <code>log</code> reconnects from the 
<code>multicast</code> merge point.
+          </p>
+        </div>
+        <div class="card-body diagram-scroll">
+          <camel-route-diagram src="/mock/multicast"></camel-route-diagram>
+        </div>
+      </div>
+
+      <div class="card">
+        <div class="card-header">
+          <h3>Resilient HTTP Call — <code>circuitBreaker</code> with 
fallback</h3>
+          <p>
+            Protects a downstream payment-service call with Resilience4j. The 
<code>onFallback</code>
+            branch is laid side-by-side with the main call. The final 
<code>log</code> reconnects
+            from the <code>circuitBreaker</code> merge point.
+          </p>
+        </div>
+        <div class="card-body diagram-scroll">
+          <camel-route-diagram 
src="/mock/circuit-breaker"></camel-route-diagram>
+        </div>
+      </div>
+
+    </div>
+  </section>
+
+  <!-- ══════════════════════════════════════════════════════
+       SECTION 5 — Exchange Statistics
+       ══════════════════════════════════════════════════════ -->
+  <section id="metrics" class="section">
+    <div class="section-header">
+      <h2>Exchange Statistics</h2>
+      <span class="section-tag tag-metrics">Metrics</span>
+    </div>
+    <div class="card">
+      <div class="card-header">
+        <h3>High-Throughput Event Consumer — per-node 
<code>exchangesTotal</code> / <code>exchangesFailed</code></h3>
+        <p>
+          When a node has a <code>statistics</code> object the component 
renders
+          <em>✓&nbsp;successes&nbsp;/&nbsp;✗&nbsp;failures</em> beneath the 
label.
+          Here the <code>otherwise</code> branch has accumulated 32 failures 
out of 14&thinsp;189
+          exchanges — spot the error rate at a glance.
+        </p>
+      </div>
+      <div class="card-body diagram-scroll">
+        <camel-route-diagram src="/mock/metrics"></camel-route-diagram>
+      </div>
+    </div>
+  </section>
+
+  <!-- ══════════════════════════════════════════════════════
+       SECTION 6 — Long URI wrapping
+       ══════════════════════════════════════════════════════ -->
+  <section id="kafka" class="section">
+    <div class="section-header">
+      <h2>Long URI — Text Wrapping</h2>
+      <span class="section-tag tag-kafka">Kafka</span>
+    </div>
+    <div class="card">
+      <div class="card-header">
+        <h3>Kafka Consumer — multi-option URI wrapped inside the node box</h3>
+        <p>
+          A <code>from</code> node with a long Kafka URI
+          (<code>brokers=…&amp;groupId=…&amp;autoOffsetReset=…</code>) 
verifies that the label
+          wraps gracefully inside the fixed-width box without overflowing or 
truncating silently.
+          Mirrors <code>testTextWrappingLongLabel</code>.
+        </p>
+      </div>
+      <div class="card-body diagram-scroll">
+        <camel-route-diagram src="/mock/kafka-long-uri"></camel-route-diagram>
+      </div>
+    </div>
+  </section>
+
+  <!-- ══════════════════════════════════════════════════════
+       SECTION 7 — Multi-route pipeline
+       ══════════════════════════════════════════════════════ -->
+  <section id="multi" class="section">
+    <div class="section-header">
+      <h2>Multi-Route — Order-Processing Pipeline</h2>
+      <span class="section-tag tag-multi">Multiple Routes</span>
+    </div>
+    <div class="card">
+      <div class="card-header">
+        <h3>Three-route pipeline rendered from a single <code>{ routes: […] 
}</code> response</h3>
+        <p>
+          The <strong>generator</strong> fires a timer and hands off to 
<strong>processor</strong>
+          (which routes valid/invalid orders via <code>choice</code>), which 
in turn delegates valid
+          orders to <strong>validator</strong> for publication to Kafka.
+          Nodes in the generator route use the <code>description</code> field 
for human-readable labels.
+        </p>
+      </div>
+      <div class="card-body diagram-scroll">
+        <camel-route-diagram src="/mock/multi-route"></camel-route-diagram>
+      </div>
+    </div>
+  </section>
+
+</main>
+
+<!-- ─── Footer ─── -->
+<footer class="site-footer">
+  <p>
+    <a href="https://camel.apache.org/"; rel="noopener noreferrer">Apache 
Camel</a>
+    &nbsp;·&nbsp;
+    <code>camel-route-diagram</code> web component
+    &nbsp;·&nbsp;
+    Local smoke test — not a production page.
+  </p>
+</footer>
+
+</body>
+</html>
diff --git 
a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_21.adoc 
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_21.adoc
index d0ca5c065982..762f7f8a4340 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_21.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_21.adoc
@@ -2008,7 +2008,7 @@ As a consequence, the generated Endpoint DSL header 
accessors on
 
 **Header Override Options (Hardening)**
 
-Added new configuration options to `camel-mail` to provide finer control over 
message header overrides for security and hardening purposes. 
+Added new configuration options to `camel-mail` to provide finer control over 
message header overrides for security and hardening purposes.
 
 The following options have been added to the mail endpoint:
 
@@ -2546,3 +2546,18 @@ resolved from the classpath via the component's 
`URIResolver` and are therefore
 If your Schematron rules legitimately reference an external DTD, external 
entity, or external stylesheet,
 those references will no longer be resolved and rule compilation will fail; 
inline the referenced content
 instead.
+
+=== camel-diagram: Embeddable web component
+
+A new `<camel-route-diagram>` web component is now bundled inside 
`camel-diagram.jar`
+at `META-INF/resources/camel/diagram/camel-route-diagram.js`.
+Any Servlet 3 container (including Quarkus and Spring Boot) serving the JAR 
automatically
+exposes the script as a static resource.
+
+The component consumes the existing `route-structure` dev console JSON 
endpoint and renders
+routes as interactive SVG diagrams with optional per-processor metric overlays 
and configurable
+periodic refresh.
+It is theme-agnostic, respects `prefers-color-scheme` for automatic dark/light 
mode, and
+exposes CSS custom properties for full visual control.
+
+See the `camel-diagram` component documentation for usage instructions and 
theming options.

Reply via email to