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 2af9badb96fb CAMEL-23636: camel-diagram - fix diagram text overflow, 
dark-theme contrast, add smoke pages (#24275)
2af9badb96fb is described below

commit 2af9badb96fb949f64a34de299ae041b877e9b77
Author: Adriano Machado <[email protected]>
AuthorDate: Sat Jun 27 14:02:42 2026 -0400

    CAMEL-23636: camel-diagram - fix diagram text overflow, dark-theme 
contrast, add smoke pages (#24275)
    
    * CAMEL-23636: camel-diagram - fix diagram text overflow, dark-theme 
contrast, add smoke pages
    
    - Replace fixed char-count truncation with canvas measureText width-aware
      fitText in both camel-route-diagram and camel-topology-diagram, so long
      labels trim to a clean ellipsis instead of overflowing the node box.
    - Topology: reserve space for the metric badge and add a native SVG <title>
      tooltip showing the full label/URI when the text is trimmed.
    - Fix dark-theme node-box contrast in camel-topology-diagram by introducing
      a --ctd-node-bg token that the dark media query flips together with the
      text color (mirrors the existing --crd-node-bg pattern in route-diagram).
    - Add a camel-topology-diagram smoke-test page and rename smoke-test.html to
      smoke-test-route.html for consistency.
    - Add a Light/Dark/OS header theme switch (default OS) to both smoke pages.
    - Update integration-test.html links to the renamed and new smoke pages.
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
    
    * CAMEL-23636: camel-diagram - use configured fonts in measurements
    
    Measure labels with the active --crd-font and --ctd-font values, let 
topology badges reserve enough space for large counters, and move the font 
smoke cases into the theming sections.
    
    Co-authored-by: OpenAI Codex <[email protected]>
    
    * CAMEL-23636: camel-diagram - document topology component and update smoke 
tests
    
    Document the topology web component, dev-console topology mode, and CSS
    theming. Move the custom-font demo to its own row and use Courier New so
    text measurement matches the rendered SVG font.
    
    Co-authored-by: Cursor <[email protected]>
    
    * CAMEL-23636: camel-diagram - document --crd-node-bg/--ctd-node-bg theming 
properties
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
    
    ---------
    
    Co-authored-by: Claude Opus 4.8 <[email protected]>
    Co-authored-by: OpenAI Codex <[email protected]>
    Co-authored-by: Cursor <[email protected]>
---
 .../camel-diagram/src/main/docs/diagram.adoc       |  98 ++-
 .../resources/camel/diagram/camel-route-diagram.js |  37 +-
 .../camel/diagram/camel-topology-diagram.js        |  73 ++-
 .../camel/diagram/WebComponentBundleTest.java      |  27 +
 .../src/test/resources/integration-test.html       |   5 +-
 .../{smoke-test.html => smoke-test-route.html}     |  96 ++-
 .../src/test/resources/smoke-test-topology.html    | 669 +++++++++++++++++++++
 7 files changed, 963 insertions(+), 42 deletions(-)

diff --git a/components/camel-diagram/src/main/docs/diagram.adoc 
b/components/camel-diagram/src/main/docs/diagram.adoc
index 414231173fd2..71a4bf1d15fd 100644
--- a/components/camel-diagram/src/main/docs/diagram.adoc
+++ b/components/camel-diagram/src/main/docs/diagram.adoc
@@ -113,9 +113,15 @@ camel cmd route-diagram MyRoute.java
 === Developer console
 
 When the developer console is enabled (for example with `camel run 
--console`), the `route-diagram`
-console renders the running routes using the interactive 
`<camel-route-diagram>` web component,
-fed by the `route-structure` console. Request `?format=png` to get the legacy 
inline PNG image
-instead, or use the `ascii` / `unicode` themes for plain-text output.
+console provides two diagram modes:
+
+* **Route mode** (default): Renders individual route diagrams using the 
`<camel-route-diagram>` web component,
+  fed by the `route-structure` console. Shows the step-by-step flow within 
each route.
+* **Topology mode**: Renders inter-route topology using the 
`<camel-topology-diagram>` web component,
+  fed by the `route-topology` console. Shows how routes connect through shared 
endpoints.
+
+Access topology mode with `?mode=topology`. For both modes, request 
`?format=png` to get the legacy
+inline PNG image instead, or use the `ascii` / `unicode` themes for plain-text 
output.
 
 == Color Themes
 
@@ -284,14 +290,24 @@ RouteDiagramRenderer pngRenderer = new 
RouteDiagramRenderer(nodeWidth, fontSize)
 BufferedImage image = pngRenderer.renderDiagram(layoutRoutes, totalHeight, 
colors, highlightedNodes, style);
 ----
 
-== Embeddable Web Component
+== Embeddable Web Components
+
+`camel-diagram` ships two lightweight web components that render interactive 
diagrams
+as SVG directly in the browser. Any application with `camel-diagram` on the 
classpath
+automatically serves the components as static resources — no extra server 
configuration needed.
+
+=== Route Diagram 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.
+The `<camel-route-diagram>` web component renders individual route diagrams 
showing
+the step-by-step flow within a single route, including EIP patterns, 
processors, and endpoints.
 
-=== Usage
+=== Topology Diagram Component
+
+The `<camel-topology-diagram>` web component renders inter-route topology 
diagrams showing
+how multiple routes connect to each other through shared endpoints, providing 
a high-level
+view of message flow across the entire application.
+
+=== Route Diagram 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):
@@ -312,7 +328,28 @@ The `src` attribute must point to an endpoint returning 
the `route-structure` de
 The component automatically appends `?metric=true` so that per-processor 
exchange statistics
 are included in the diagram.
 
-=== Attributes
+=== Topology Diagram Usage
+
+Include the bundled script served from 
`META-INF/resources/camel/diagram/camel-topology-diagram.js`:
+
+[source,html]
+----
+<script type="module" src="/camel/diagram/camel-topology-diagram.js"></script>
+
+<camel-topology-diagram
+  src="/q/dev/route-topology"
+  refresh="5000"
+  external="true"
+  interlink="true"
+  metric="true">
+</camel-topology-diagram>
+----
+
+The `src` attribute must point to an endpoint returning the `route-topology` 
dev console JSON
+(for example the Quarkus Dev UI endpoint `/q/dev/route-topology`).
+This provides a high-level view showing how routes connect through shared 
endpoints.
+
+=== Route Diagram Attributes
 
 [width="100%",cols="2,5,2",options="header"]
 |===
@@ -322,16 +359,31 @@ are included in the diagram.
 | `filter` | Route ID filter, forwarded as `?filter=` query parameter | (all 
routes)
 |===
 
+=== Topology Diagram Attributes
+
+[width="100%",cols="2,5,2",options="header"]
+|===
+| Attribute | Description | Default
+| `src` | URL to fetch the route-topology JSON from (required) | —
+| `refresh` | Polling interval in milliseconds; `0` disables polling | `0`
+| `external` | Include external endpoints as boundary nodes | `false`
+| `interlink` | Collapse shared endpoints into intermediate nodes | `false`
+| `metric` | Include exchange counters from route metrics | `false`
+|===
+
 === 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:
+Both components are theme-agnostic.
+They respect `prefers-color-scheme` automatically for dark/light mode,
+and expose CSS custom properties so the host application can override every 
visual aspect.
+
+==== Route Diagram Theming
 
 [source,css]
 ----
 camel-route-diagram {
   --crd-bg:                   #ffffff;   /* canvas background */
+  --crd-node-bg:              #ffffff;   /* node fill colour (defaults to 
--crd-bg) */
   --crd-fg:                   #1e293b;   /* text colour */
   --crd-edge:                 #94a3b8;   /* edge/arrow colour */
   --crd-stat:                 #64748b;   /* metric overlay text */
@@ -352,3 +404,23 @@ camel-route-diagram {
   --crd-color-default:        #6366f1;   /* all other EIP nodes */
 }
 ----
+
+==== Topology Diagram Theming
+
+[source,css]
+----
+camel-topology-diagram {
+  --ctd-bg:                   #ffffff;   /* canvas background */
+  --ctd-node-bg:              #ffffff;   /* node fill colour (defaults to 
--ctd-bg) */
+  --ctd-fg:                   #1e293b;   /* text colour */
+  --ctd-edge:                 #94a3b8;   /* edge/arrow colour */
+  --ctd-stat:                 #64748b;   /* metric overlay text */
+  --ctd-font:                 system-ui; /* font family */
+  --ctd-font-size:            12px;      /* base font size */
+  --ctd-color-route:          #6366f1;   /* route nodes */
+  --ctd-color-trigger:        #f59e0b;   /* trigger/timer routes */
+  --ctd-color-external-in:    #6b7280;   /* external input endpoints */
+  --ctd-color-external-out:   #6b7280;   /* external output endpoints */
+  --ctd-color-external-shared: #8b5cf6;  /* shared external endpoints */
+}
+----
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
index 48724cae91cd..8515bd37d771 100644
--- 
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
@@ -219,10 +219,29 @@ function nodeColor(type) {
     return TYPE_COLORS[type] ?? 'var(--crd-color-default, #6366f1)';
 }
 
-function truncate(text, maxLen = 28) {
+// Trim a label so it fits a pixel width. SVG <text> is proportional, so a 
fixed character budget cannot
+// guarantee a label stays inside its node; measure candidate strings with an 
offscreen 2D canvas (same font
+// the SVG inherits) and drop trailing characters until the text plus an 
ellipsis fits. Falls back to a coarse
+// character budget when a 2D canvas context is unavailable.
+let measureCtx = null;
+function fitText(text, maxWidth, fontSize = 11, fontFamily = 'system-ui, 
sans-serif') {
     if (!text) return '';
-    const clean = text.replace(/^\.+/, '');
-    return clean.length > maxLen ? clean.slice(0, maxLen - 1) + '\u2026' : 
clean;
+    let s = String(text).replace(/^\.+/, '');
+    if (measureCtx === null) {
+        measureCtx = document.createElement('canvas').getContext('2d') || 
false;
+    }
+    if (!measureCtx) {
+        const maxLen = Math.max(1, Math.floor(maxWidth / (fontSize * 0.6)));
+        return s.length > maxLen ? s.slice(0, maxLen - 1) + '\u2026' : s;
+    }
+    measureCtx.font = `${fontSize}px ${fontFamily}`;
+    if (measureCtx.measureText(s).width <= maxWidth) {
+        return s;
+    }
+    while (s.length > 0 && measureCtx.measureText(s + '\u2026').width > 
maxWidth) {
+        s = s.slice(0, -1);
+    }
+    return s.replace(/\s+$/, '') + '\u2026';
 }
 
 function formatStat(stats) {
@@ -423,6 +442,7 @@ class CamelRouteDiagram extends HTMLElement {
 
     #routeHTML(route, routeIdx) {
         const { positions, width, height } = layoutRoute(route);
+        const fontFamily = 
getComputedStyle(this).getPropertyValue('--crd-font').trim() || 'system-ui, 
sans-serif';
         const ids = Object.keys(positions);
         const pfx  = `t${this.#uid}r${routeIdx}`;
         const defs = ids.map(id => {
@@ -436,7 +456,7 @@ class CamelRouteDiagram extends HTMLElement {
            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('')}
+        ${ids.map(id => this.#nodeHTML(positions[id], `${pfx}${safeId(id)}`, 
fontFamily)).join('')}
       </svg>
     </div>`;
     }
@@ -469,15 +489,20 @@ class CamelRouteDiagram extends HTMLElement {
         fill="var(--crd-edge, #94a3b8)"/>`;
     }
 
-    #nodeHTML(pos, clipId) {
-        const label  = truncate(pos.description ?? pos.code);
+    #nodeHTML(pos, clipId, fontFamily) {
+        // Label starts 30px in (icon zone); keep an 8px margin before the 
right border.
+        const full   = pos.description ?? pos.code;
+        const label  = fitText(full, NODE_W - 38, 11, fontFamily);
         const stat   = formatStat(pos.statistics);
         const fill   = nodeColor(pos.type);
         const textX  = pos.x + 30;
         const textY  = pos.y + NODE_H / 2 + 4;
+        // Only attach a hover tooltip when the label was actually trimmed 
(ends with the ellipsis).
+        const title  = label.endsWith('…') ? `<title>${esc(full)}</title>` : 
'';
 
         return `
       <g role="img" aria-label="${esc(pos.type)}: ${esc(label)}">
+        ${title}
         <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}"
diff --git 
a/components/camel-diagram/src/main/resources/META-INF/resources/camel/diagram/camel-topology-diagram.js
 
b/components/camel-diagram/src/main/resources/META-INF/resources/camel/diagram/camel-topology-diagram.js
index 393e6443a6b4..f45c55b0bdd5 100644
--- 
a/components/camel-diagram/src/main/resources/META-INF/resources/camel/diagram/camel-topology-diagram.js
+++ 
b/components/camel-diagram/src/main/resources/META-INF/resources/camel/diagram/camel-topology-diagram.js
@@ -275,10 +275,35 @@ function isExternal(nodeType) {
     return nodeType === 'external-in' || nodeType === 'external-out' || 
nodeType === 'external';
 }
 
-function truncate(text, maxLen = 28) {
+// Measure text with an offscreen 2D canvas using the same font the SVG 
inherits. Returns null when no canvas
+// context is available so callers can fall back to a character-count estimate.
+let measureCtx = null;
+function measureWidth(text, fontSize, fontFamily = 'system-ui, sans-serif') {
+    if (measureCtx === null) {
+        measureCtx = document.createElement('canvas').getContext('2d') || 
false;
+    }
+    if (!measureCtx) return null;
+    measureCtx.font = `${fontSize}px ${fontFamily}`;
+    return measureCtx.measureText(String(text)).width;
+}
+
+// Trim a label so it fits a pixel width. SVG <text> is proportional, so a 
fixed character budget cannot
+// guarantee a label stays inside its node; drop trailing characters until the 
text plus an ellipsis fits.
+// When `paren` is set the surrounding parentheses are part of the measurement 
so the ellipsis lands inside them.
+function fitText(text, maxWidth, fontSize = 11, paren = false, fontFamily = 
'system-ui, sans-serif') {
     if (!text) return '';
-    const clean = text.replace(/^\.+/, '');
-    return clean.length > maxLen ? clean.slice(0, maxLen - 1) + '…' : clean;
+    const wrap = paren ? (v) => `(${v})` : (v) => v;
+    let s = String(text).replace(/^\.+/, '');
+    let w = measureWidth(wrap(s), fontSize, fontFamily);
+    if (w === null) {
+        const maxLen = Math.max(1, Math.floor(maxWidth / (fontSize * 0.6)) - 
(paren ? 2 : 0));
+        return s.length > maxLen ? wrap(s.slice(0, maxLen - 1) + '…') : 
wrap(s);
+    }
+    if (w <= maxWidth) return wrap(s);
+    while (s.length > 0 && measureWidth(wrap(s + '…'), fontSize, fontFamily) > 
maxWidth) {
+        s = s.slice(0, -1);
+    }
+    return wrap(s.replace(/\s+$/, '') + '…');
 }
 
 function esc(s) {
@@ -306,10 +331,15 @@ const COMPONENT_STYLE = `
     align-items: flex-start;
     justify-content: center;
     background: var(--ctd-bg, transparent);
+    /* Node-box fill tracks the theme so light text never lands on a light box 
(and vice versa). */
+    --ctd-node-bg: var(--ctd-bg, #ffffff);
     padding: 12px;
   }
   @media (prefers-color-scheme: dark) {
-    .wrap { background: var(--ctd-bg, #0f172a); }
+    .wrap {
+      background: var(--ctd-bg, #0f172a);
+      --ctd-node-bg: var(--ctd-bg, #0f172a);
+    }
   }
   .error   { color: #ef4444; padding: 8px; }
   .loading { opacity: .6; padding: 8px; }
@@ -348,7 +378,7 @@ function nodeColor(nodeType) {
  *   interlink - show intermediary nodes for routes connected via shared 
externals (default: true)
  *
  * CSS custom properties (all optional):
- *   --ctd-bg, --ctd-fg, --ctd-edge, --ctd-font, --ctd-font-size
+ *   --ctd-bg, --ctd-node-bg, --ctd-fg, --ctd-edge, --ctd-font, --ctd-font-size
  *   --ctd-color-route, --ctd-color-trigger, --ctd-color-external
  *
  * @since 4.21
@@ -471,6 +501,7 @@ class CamelTopologyDiagram extends HTMLElement {
 
     #topologyHTML() {
         const data = this.#data;
+        const fontFamily = 
getComputedStyle(this).getPropertyValue('--ctd-font').trim() || 'system-ui, 
sans-serif';
 
         // Parse nodes and edges
         const nodes = (data.nodes ?? []).map(n => ({
@@ -500,7 +531,7 @@ class CamelTopologyDiagram extends HTMLElement {
         if (!layoutNodes.length) return '<p class="loading">No routes</p>';
 
         const edgeSvg = layoutEdges.map(e => this.#edgeHTML(e)).join('');
-        const nodeSvg = layoutNodes.map(n => this.#nodeHTML(n)).join('');
+        const nodeSvg = layoutNodes.map(n => this.#nodeHTML(n, 
fontFamily)).join('');
 
         return `<svg width="${totalWidth}" height="${totalHeight}" viewBox="0 
0 ${totalWidth} ${totalHeight}"
                      aria-label="Route topology diagram">
@@ -532,17 +563,36 @@ class CamelTopologyDiagram extends HTMLElement {
         fill="var(--ctd-edge, #94a3b8)"/>`;
     }
 
-    #nodeHTML(node) {
+    #nodeHTML(node, fontFamily) {
         const ext = isExternal(node.nodeType);
         const fill = nodeColor(node.nodeType);
         const dashAttr = ext ? ' stroke-dasharray="4 3"' : '';
 
-        const label = ext ? truncate(node.from, 28) : 
truncate(node.description ?? node.routeId, 28);
-        const subLabel = ext ? null : `(${truncate(node.from, 24)})`;
-
         const stat = this.#metric && node.exchangesTotal > 0
             ? { total: node.exchangesTotal, failed: node.exchangesFailed } : 
null;
 
+        // Labels start 30px in (icon zone); keep an 8px margin before the 
right border. When a metric badge is
+        // shown in the top-right corner, reserve its measured width on the 
first line so the label cannot run
+        // under it. The sub-label sits lower and never collides with the 
badge, so it uses the full width.
+        let labelMax = NODE_W - 38;
+        if (stat) {
+            const metricText = stat.failed > 0 ? `${stat.total - stat.failed}  
${stat.failed}` : `${stat.total - stat.failed}`;
+            const metricWidth = measureWidth(metricText, 9, fontFamily) ?? 36;
+            labelMax = NODE_W - 8 - metricWidth - 6 - 30;
+        }
+        const fullLabel = ext ? node.from : (node.description ?? node.routeId);
+        const label = fitText(fullLabel, labelMax, 11, false, fontFamily);
+        const subLabel = ext ? null : fitText(node.from, NODE_W - 38, 9, true, 
fontFamily);
+
+        // Only attach a hover tooltip when text was actually trimmed (the 
label ends with the ellipsis, or the
+        // parenthesised sub-label ends with one). Show the full description 
and the full from-uri.
+        const trimmed = label.endsWith('…') || (!!subLabel && 
subLabel.endsWith('…)'));
+        let titleText = trimmed ? (fullLabel ?? '') : null;
+        if (titleText != null && !ext && node.from) {
+            titleText += `\n(${node.from})`;
+        }
+        const title = titleText != null ? `<title>${esc(titleText)}</title>` : 
'';
+
         const textX = node.x + 30;
         const baseY = node.y + NODE_H / 2;
 
@@ -557,8 +607,9 @@ class CamelTopologyDiagram extends HTMLElement {
 
         return `
       <g role="img" aria-label="${esc(node.routeId)}: ${esc(label)}">
+        ${title}
         <rect x="${node.x}" y="${node.y}" width="${NODE_W}" height="${NODE_H}"
-              rx="6" ry="6" fill="var(--ctd-bg, #ffffff)"/>
+              rx="6" ry="6" fill="var(--ctd-node-bg, #ffffff)"/>
         <rect x="${node.x}" y="${node.y}" width="${NODE_W}" height="${NODE_H}"
               rx="6" ry="6"
               fill="${fill}" fill-opacity="${ext ? '0.08' : '0.15'}"
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
index 3af5a6cb81d1..395506d9d799 100644
--- 
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
@@ -76,6 +76,19 @@ class WebComponentBundleTest {
         }
     }
 
+    @Test
+    void bundledRouteJsMeasuresTextWithTheConfiguredFont() 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("route labels must be measured with the host font 
configuration")
+                    
.contains("getComputedStyle(this).getPropertyValue('--crd-font')")
+                    .contains("fitText(full, NODE_W - 38, 11, fontFamily)");
+        }
+    }
+
     @Test
     void topologyBundledJsExistsInClasspath() {
         URL url = getClass().getClassLoader()
@@ -119,6 +132,20 @@ class WebComponentBundleTest {
         }
     }
 
+    @Test
+    void 
topologyBundledJsMeasuresTextWithTheConfiguredFontAndAllowsBadgeShrinking() 
throws IOException {
+        try (InputStream is = getClass().getClassLoader()
+                
.getResourceAsStream("META-INF/resources/camel/diagram/camel-topology-diagram.js"))
 {
+            assertThat(is).isNotNull();
+            String content = new String(is.readAllBytes(), 
StandardCharsets.UTF_8);
+            assertThat(content)
+                    .as("topology labels and metric badges must use the 
configured font and reserve enough space")
+                    
.contains("getComputedStyle(this).getPropertyValue('--ctd-font')")
+                    .contains("measureWidth(metricText, 9, fontFamily)")
+                    .contains("labelMax = NODE_W - 8 - metricWidth - 6 - 30;");
+        }
+    }
+
     @Test
     void thirdPartyNoticesMentionsLucide() throws IOException {
         try (InputStream is = getClass().getClassLoader()
diff --git a/components/camel-diagram/src/test/resources/integration-test.html 
b/components/camel-diagram/src/test/resources/integration-test.html
index 9e7c80473640..c6fc302af74e 100644
--- a/components/camel-diagram/src/test/resources/integration-test.html
+++ b/components/camel-diagram/src/test/resources/integration-test.html
@@ -36,7 +36,7 @@
   <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) ─── */
+    /* ─── Camel design tokens (mirrored from smoke-test-route.html) ─── */
     :root {
       --camel-orange:      #e97826;
       --camel-orange-dark: #cf7428;
@@ -292,7 +292,8 @@
     <a href="#eips">EIP Nodes</a>
     <a href="#colors">Colors</a>
     <a href="#polling">Polling</a>
-    <a href="smoke-test.html">Smoke Test</a>
+    <a href="smoke-test-route.html">Smoke · Route</a>
+    <a href="smoke-test-topology.html">Smoke · Topology</a>
   </nav>
   <span class="badge-pill">Integration Tests</span>
 </nav>
diff --git a/components/camel-diagram/src/test/resources/smoke-test.html 
b/components/camel-diagram/src/test/resources/smoke-test-route.html
similarity index 90%
rename from components/camel-diagram/src/test/resources/smoke-test.html
rename to components/camel-diagram/src/test/resources/smoke-test-route.html
index 2e9a79cce705..4c2a6691abd0 100644
--- a/components/camel-diagram/src/test/resources/smoke-test.html
+++ b/components/camel-diagram/src/test/resources/smoke-test-route.html
@@ -25,7 +25,7 @@
 
     cd components/camel-diagram/src/
     python3 -m http.server 8080
-    open http://localhost:8080/test/resources/smoke-test.html
+    open http://localhost:8080/test/resources/smoke-test-route.html
 
   All data is mocked — no running Camel instance needed.
 -->
@@ -313,6 +313,34 @@
       text-transform: uppercase;
     }
 
+    /* ─── Theme switch (segmented control) ─── */
+    .theme-switch {
+      display: inline-flex;
+      gap: 2px;
+      padding: 2px;
+      background: rgba(255,255,255,.1);
+      border: 1px solid rgba(255,255,255,.18);
+      border-radius: 99px;
+    }
+    .theme-switch button {
+      appearance: none;
+      border: 0;
+      background: transparent;
+      color: rgba(255,255,255,.75);
+      font: inherit;
+      font-size: .72rem;
+      font-weight: 600;
+      padding: 3px 11px;
+      border-radius: 99px;
+      cursor: pointer;
+      transition: background .15s, color .15s;
+    }
+    .theme-switch button:hover { color: var(--camel-white); }
+    .theme-switch button.active {
+      background: var(--camel-orange);
+      color: var(--camel-white);
+    }
+
     /* ─── Hero ─── */
     .hero {
       background: var(--camel-navy);
@@ -421,6 +449,17 @@
     .card-body         { padding: 1rem; }
     .card-body.dark-bg { background: #0f172a; padding: .75rem; }
 
+    /* ─── Header-driven theme override ───
+       Drives the diagram sections that are not the self-documenting demo 
cards.
+       "os" sets nothing, so the component falls back to prefers-color-scheme. 
*/
+    body[data-theme="light"] .themed-diagram {
+      --crd-bg: #ffffff; --crd-fg: #1e293b; --crd-edge: #94a3b8;
+    }
+    body[data-theme="dark"] .themed-diagram {
+      --crd-bg: #0f172a; --crd-fg: #e2e8f0; --crd-edge: #475569;
+    }
+    body[data-theme="dark"] .card-body.themed-diagram { background: #0f172a; }
+
     /* ─── 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; 
}
@@ -445,7 +484,7 @@
     }
   </style>
 </head>
-<body>
+<body data-theme="os">
 
 <!-- ─── Navigation ─── -->
 <nav class="site-nav" aria-label="Site navigation">
@@ -464,6 +503,11 @@
     <a href="#kafka">Long URI</a>
     <a href="#multi">Multi-Route</a>
   </nav>
+  <div class="theme-switch" role="group" aria-label="Diagram theme">
+    <button type="button" data-theme="light" 
aria-pressed="false">Light</button>
+    <button type="button" data-theme="dark" aria-pressed="false">Dark</button>
+    <button type="button" data-theme="os" class="active" 
aria-pressed="true">OS</button>
+  </div>
   <span class="badge-pill">Smoke Test</span>
 </nav>
 
@@ -477,7 +521,7 @@
     </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
+      python3 -m http.server 8080 &amp;&amp; open 
http://localhost:8080/test/resources/smoke-test-route.html
     </span>
   </div>
 </header>
@@ -539,6 +583,19 @@
       </div>
 
     </div>
+
+    <div class="card" style="margin-top: 1rem;">
+      <div class="card-header">
+        <h3>Custom font</h3>
+        <p>
+          Override via <code>style="--crd-font: 'Courier New', 
monospace"</code> to verify that text measurement
+          matches the font actually used by the SVG.
+        </p>
+      </div>
+      <div class="card-body diagram-scroll">
+        <camel-route-diagram src="/mock/simple" style="--crd-font: 'Courier 
New', monospace;"></camel-route-diagram>
+      </div>
+    </div>
   </section>
 
   <!-- ══════════════════════════════════════════════════════
@@ -558,7 +615,7 @@
           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">
+      <div class="card-body diagram-scroll themed-diagram">
         <camel-route-diagram src="/mock/content-router"></camel-route-diagram>
       </div>
     </div>
@@ -583,7 +640,7 @@
           Mirrors the 
<code>testChoiceInsideDoTryNoSpuriousMergeConnection</code> layout-engine test.
         </p>
       </div>
-      <div class="card-body diagram-scroll">
+      <div class="card-body diagram-scroll themed-diagram">
         <camel-route-diagram src="/mock/error-handling"></camel-route-diagram>
       </div>
     </div>
@@ -609,7 +666,7 @@
             post-multicast <code>log</code> reconnects from the 
<code>multicast</code> merge point.
           </p>
         </div>
-        <div class="card-body diagram-scroll">
+        <div class="card-body diagram-scroll themed-diagram">
           <camel-route-diagram src="/mock/multicast"></camel-route-diagram>
         </div>
       </div>
@@ -623,7 +680,7 @@
             from the <code>circuitBreaker</code> merge point.
           </p>
         </div>
-        <div class="card-body diagram-scroll">
+        <div class="card-body diagram-scroll themed-diagram">
           <camel-route-diagram 
src="/mock/circuit-breaker"></camel-route-diagram>
         </div>
       </div>
@@ -649,7 +706,7 @@
           exchanges — spot the error rate at a glance.
         </p>
       </div>
-      <div class="card-body diagram-scroll">
+      <div class="card-body diagram-scroll themed-diagram">
         <camel-route-diagram src="/mock/metrics"></camel-route-diagram>
       </div>
     </div>
@@ -673,7 +730,7 @@
           Mirrors <code>testTextWrappingLongLabel</code>.
         </p>
       </div>
-      <div class="card-body diagram-scroll">
+      <div class="card-body diagram-scroll themed-diagram">
         <camel-route-diagram src="/mock/kafka-long-uri"></camel-route-diagram>
       </div>
     </div>
@@ -697,7 +754,7 @@
           Nodes in the generator route use the <code>description</code> field 
for human-readable labels.
         </p>
       </div>
-      <div class="card-body diagram-scroll">
+      <div class="card-body diagram-scroll themed-diagram">
         <camel-route-diagram src="/mock/multi-route"></camel-route-diagram>
       </div>
     </div>
@@ -716,5 +773,24 @@
   </p>
 </footer>
 
+<script>
+  /* eslint-disable */
+  /* Header theme switch: light/dark force --crd-* on the body; "os" clears 
them
+   * so the component falls back to prefers-color-scheme. */
+  (function () {
+    const sw = document.querySelector('.theme-switch');
+    sw.addEventListener('click', function (e) {
+      const btn = e.target.closest('button[data-theme]');
+      if (!btn) return;
+      document.body.dataset.theme = btn.dataset.theme;
+      sw.querySelectorAll('button').forEach(function (b) {
+        const on = b === btn;
+        b.classList.toggle('active', on);
+        b.setAttribute('aria-pressed', String(on));
+      });
+    });
+  })();
+</script>
+
 </body>
 </html>
diff --git 
a/components/camel-diagram/src/test/resources/smoke-test-topology.html 
b/components/camel-diagram/src/test/resources/smoke-test-topology.html
new file mode 100644
index 000000000000..03d4a0679f7f
--- /dev/null
+++ b/components/camel-diagram/src/test/resources/smoke-test-topology.html
@@ -0,0 +1,669 @@
+<!--
+
+    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-topology-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-topology.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-topology-diagram · Smoke Test</title>
+  <script type="module">
+    import 
'../../main/resources/META-INF/resources/camel/diagram/camel-topology-diagram.js';
+  </script>
+  <script>
+    /* eslint-disable */
+
+    /* ──────────────────────────────────────────────────────────────────────
+     * Mock data registry — each key is a URL path used as src="…" below.
+     *
+     * Topology payload shape (what the dev console serves at /route-topology):
+     *   nodes: [{ routeId, description?, from, fromScheme?, nodeType, 
exchangesTotal?, exchangesFailed? }]
+     *   edges: [{ fromRouteId, toRouteId, endpoint?, connectionType? }]
+     *   externalEndpoints: [{ id, routeId, uri, scheme, direction:'in'|'out', 
exchangesTotal?, exchangesFailed? }]
+     *
+     * nodeType drives the node color: 'trigger' → orange, 'external*' → gray, 
else → purple.
+     * ────────────────────────────────────────────────────────────────────── 
*/
+    const MOCK = {
+
+      /* 1. Minimal linear pipeline — used in the three theme-comparison cards.
+       *    Short labels keep the focus on color, not truncation. */
+      '/mock/topology/simple': {
+        nodes: [
+          { routeId: 'generator', from: 'timer:tick',       fromScheme: 
'timer',  nodeType: 'trigger' },
+          { routeId: 'processor', from: 'direct:process',   fromScheme: 
'direct',  nodeType: 'route' },
+          { routeId: 'validator', from: 'direct:validate',  fromScheme: 
'direct',  nodeType: 'route' },
+        ],
+        edges: [
+          { fromRouteId: 'generator', toRouteId: 'processor', endpoint: 
'direct:process' },
+          { fromRouteId: 'processor', toRouteId: 'validator', endpoint: 
'direct:validate' },
+        ],
+      },
+
+      /* 2. Route pipeline with a disconnected route.
+       *    Mirrors TopologyDumperTest: generator → processor → validator, 
plus an isolated route. */
+      '/mock/topology/pipeline': {
+        nodes: [
+          { routeId: 'generator', from: 'timer:tick?period=5000', fromScheme: 
'timer',  nodeType: 'trigger' },
+          { routeId: 'processor', from: 'direct:process',         fromScheme: 
'direct',  nodeType: 'route' },
+          { routeId: 'validator', from: 'direct:validate',        fromScheme: 
'direct',  nodeType: 'route' },
+          { routeId: 'isolated',  from: 'direct:isolated',        fromScheme: 
'direct',  nodeType: 'route' },
+        ],
+        edges: [
+          { fromRouteId: 'generator', toRouteId: 'processor', endpoint: 
'direct:process' },
+          { fromRouteId: 'processor', toRouteId: 'validator', endpoint: 
'direct:validate' },
+        ],
+      },
+
+      /* 3. External endpoints — in/out boundary nodes (dashed, gray) added 
when external="true". */
+      '/mock/topology/external': {
+        nodes: [
+          { routeId: 'orders-api',  from: 'platform-http:/api/orders', 
fromScheme: 'platform-http',
+            description: 'Receives orders from external systems', nodeType: 
'route' },
+          { routeId: 'fulfilment',  from: 'direct:fulfil',            
fromScheme: 'direct',
+            description: 'Fans out orders to fulfilment services',  nodeType: 
'route' },
+        ],
+        edges: [
+          { fromRouteId: 'orders-api', toRouteId: 'fulfilment', endpoint: 
'direct:fulfil' },
+        ],
+        externalEndpoints: [
+          { id: 'ext-http-in',   routeId: 'orders-api', uri: 
'platform-http:/api/orders', scheme: 'platform-http', direction: 'in' },
+          { id: 'ext-kafka-out', routeId: 'fulfilment', uri: 
'kafka:shipped-orders',      scheme: 'kafka',         direction: 'out' },
+        ],
+      },
+
+      /* 4. Interlinked shared endpoint — two producers publishing to the same 
broker topic.
+       *    With external="true" interlink="true" the shared kafka:shared 
endpoint collapses to one node. */
+      '/mock/topology/interlink': {
+        nodes: [
+          { routeId: 'producer-a', from: 'timer:a',      fromScheme: 'timer', 
nodeType: 'trigger' },
+          { routeId: 'producer-b', from: 'timer:b',      fromScheme: 'timer', 
nodeType: 'trigger' },
+          { routeId: 'consumer',   from: 'kafka:shared', fromScheme: 'kafka', 
nodeType: 'route' },
+        ],
+        edges: [
+          { fromRouteId: 'producer-a', toRouteId: 'consumer', endpoint: 
'kafka:shared', connectionType: 'external' },
+          { fromRouteId: 'producer-b', toRouteId: 'consumer', endpoint: 
'kafka:shared', connectionType: 'external' },
+        ],
+      },
+
+      /* 5. Live metrics — per-route exchange counters rendered top-right of 
each box (metric="true").
+       *    Long descriptions exercise the ellipsis truncation and the hover 
tooltip. */
+      '/mock/topology/metrics': {
+        nodes: [
+          { routeId: 'orders-api',      from: 'platform-http:/api/orders', 
fromScheme: 'platform-http',
+            description: 'Receives orders from external systems', nodeType: 
'route',
+            exchangesTotal: 1234, exchangesFailed: 7 },
+          { routeId: 'order-generator', from: 'timer:orders?period=5s',    
fromScheme: 'timer',
+            description: 'Generate a new order every 5 seconds',  nodeType: 
'trigger',
+            exchangesTotal: 96, exchangesFailed: 0 },
+          { routeId: 'processor',       from: 'direct:process-order',      
fromScheme: 'direct',
+            description: 'Validates and publishes orders to the broker', 
nodeType: 'route',
+            exchangesTotal: 1227, exchangesFailed: 7 },
+          { routeId: 'fulfilment',      from: 'kafka:orders',             
fromScheme: 'kafka',
+            description: 'Fans out orders to fulfilment services', nodeType: 
'route',
+            exchangesTotal: 1220, exchangesFailed: 0 },
+        ],
+        edges: [
+          { fromRouteId: 'orders-api',      toRouteId: 'processor',  endpoint: 
'direct:process-order' },
+          { fromRouteId: 'order-generator', toRouteId: 'processor',  endpoint: 
'direct:process-order' },
+          { fromRouteId: 'processor',       toRouteId: 'fulfilment', endpoint: 
'kafka:orders' },
+        ],
+      },
+
+      /* 6. Badge stress — large counters reserve width for the metric text 
and leave only a sliver for the label. */
+      '/mock/topology/metrics-heavy': {
+        nodes: [
+          { routeId: 'ingress', from: 'platform-http:/api/orders', fromScheme: 
'platform-http',
+            description: 'Accepts incoming orders from external systems and 
validates them', nodeType: 'route',
+            exchangesTotal: 1234567890, exchangesFailed: 987654321 },
+          { routeId: 'fanout', from: 'direct:fanout', fromScheme: 'direct',
+            description: 'Distributes the order to fulfillment, notification, 
and archive routes', nodeType: 'route',
+            exchangesTotal: 987654321, exchangesFailed: 12345678 },
+        ],
+        edges: [
+          { fromRouteId: 'ingress', toRouteId: 'fanout', endpoint: 
'direct:fanout' },
+        ],
+      },
+    };
+
+    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;
+    }
+
+    /* ─── Theme switch (segmented control) ─── */
+    .theme-switch {
+      display: inline-flex;
+      gap: 2px;
+      padding: 2px;
+      background: rgba(255,255,255,.1);
+      border: 1px solid rgba(255,255,255,.18);
+      border-radius: 99px;
+    }
+    .theme-switch button {
+      appearance: none;
+      border: 0;
+      background: transparent;
+      color: rgba(255,255,255,.75);
+      font: inherit;
+      font-size: .72rem;
+      font-weight: 600;
+      padding: 3px 11px;
+      border-radius: 99px;
+      cursor: pointer;
+      transition: background .15s, color .15s;
+    }
+    .theme-switch button:hover { color: var(--camel-white); }
+    .theme-switch button.active {
+      background: var(--camel-orange);
+      color: var(--camel-white);
+    }
+
+    /* ─── 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-pipeline { background: #e3f2fd; color: #0d47a1; }
+    .tag-external { background: #eceff1; color: #37474f; }
+    .tag-interlink{ background: #f3e5f5; color: #4a148c; }
+    .tag-metrics  { 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 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; }
+
+    /* ─── Header-driven theme override ───
+       Drives the diagram sections that are not the self-documenting demo 
cards.
+       "os" sets nothing, so the component falls back to prefers-color-scheme. 
*/
+    body[data-theme="light"] .themed-diagram {
+      --ctd-bg: #ffffff; --ctd-fg: #1e293b; --ctd-edge: #94a3b8;
+    }
+    body[data-theme="dark"] .themed-diagram {
+      --ctd-bg: #0f172a; --ctd-fg: #e2e8f0; --ctd-edge: #475569;
+    }
+    body[data-theme="dark"] .card-body.themed-diagram { background: #0f172a; }
+
+    /* ─── 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 data-theme="os">
+
+<!-- ─── 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-topology-diagram</div>
+  </div>
+  <div class="nav-spacer"></div>
+  <nav class="nav-links" aria-label="Jump to section">
+    <a href="#themes">Themes</a>
+    <a href="#pipeline">Pipeline</a>
+    <a href="#external">External</a>
+    <a href="#interlink">Interlink</a>
+    <a href="#metrics">Metrics</a>
+  </nav>
+  <div class="theme-switch" role="group" aria-label="Diagram theme">
+    <button type="button" data-theme="light" 
aria-pressed="false">Light</button>
+    <button type="button" data-theme="dark" aria-pressed="false">Dark</button>
+    <button type="button" data-theme="os" class="active" 
aria-pressed="true">OS</button>
+  </div>
+  <span class="badge-pill">Smoke Test</span>
+</nav>
+
+<!-- ─── Hero ─── -->
+<header class="hero">
+  <div class="hero-inner">
+    <h1><code>&lt;camel-topology-diagram&gt;</code></h1>
+    <p>
+      A local verification page for the topology-diagram web component — the 
inter-route view served by the
+      dev console at <code>?mode=topology</code>. Each section exercises a 
different 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-topology.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>--ctd-*</code> 
variables
+            are set — the component picks light or dark based on your OS 
setting.
+          </p>
+        </div>
+        <div class="card-body diagram-scroll">
+          <camel-topology-diagram src="/mock/topology/simple" external="false" 
metric="false"></camel-topology-diagram>
+        </div>
+      </div>
+
+      <div class="card">
+        <div class="card-header">
+          <h3>Light (forced)</h3>
+          <p>
+            Override via inline <code>style="--ctd-bg:#fff; --ctd-fg:#1e293b; 
--ctd-edge:#94a3b8"</code>
+            to pin the light palette regardless of OS setting.
+          </p>
+        </div>
+        <div class="card-body diagram-scroll">
+          <camel-topology-diagram src="/mock/topology/simple" external="false" 
metric="false"
+            style="--ctd-bg:#ffffff; --ctd-fg:#1e293b; --ctd-edge:#94a3b8;">
+          </camel-topology-diagram>
+        </div>
+      </div>
+
+      <div class="card">
+        <div class="card-header">
+          <h3>Dark (forced)</h3>
+          <p>
+            Override via <code>style="--ctd-bg:#0f172a; --ctd-fg:#e2e8f0; 
--ctd-edge:#475569"</code>
+            to pin the dark palette regardless of OS setting. The node-box 
fill tracks
+            <code>--ctd-bg</code> so labels never land light-on-light.
+          </p>
+        </div>
+        <div class="card-body diagram-scroll dark-bg">
+          <camel-topology-diagram src="/mock/topology/simple" external="false" 
metric="false"
+            style="--ctd-bg:#0f172a; --ctd-fg:#e2e8f0; --ctd-edge:#475569;">
+          </camel-topology-diagram>
+        </div>
+      </div>
+
+    </div>
+
+    <div class="card" style="margin-top: 1rem;">
+      <div class="card-header">
+        <h3>Custom font</h3>
+        <p>
+          Override via <code>style="--ctd-font: 'Courier New', 
monospace"</code> to verify that text measurement
+          matches the font actually used by the SVG.
+        </p>
+      </div>
+      <div class="card-body diagram-scroll">
+        <camel-topology-diagram src="/mock/topology/simple" external="false" 
metric="false"
+          style="--ctd-font: 'Courier New', monospace;">
+        </camel-topology-diagram>
+      </div>
+    </div>
+  </section>
+
+  <!-- ══════════════════════════════════════════════════════
+       SECTION 2 — Route pipeline
+       ══════════════════════════════════════════════════════ -->
+  <section id="pipeline" class="section">
+    <div class="section-header">
+      <h2>Route Pipeline</h2>
+      <span class="section-tag tag-pipeline">Multiple Routes</span>
+    </div>
+    <div class="card">
+      <div class="card-header">
+        <h3>Generator → Processor → Validator, plus a disconnected route</h3>
+        <p>
+          A timer <strong>generator</strong> (orange <code>trigger</code> 
node) hands off to
+          <strong>processor</strong> via <code>direct:process</code>, which 
forwards to
+          <strong>validator</strong> via <code>direct:validate</code>. The 
<strong>isolated</strong> route
+          has no inbound or outbound links, verifying that disconnected routes 
still render standalone.
+          Mirrors the topology in <code>TopologyDumperTest</code>.
+        </p>
+      </div>
+      <div class="card-body diagram-scroll themed-diagram">
+        <camel-topology-diagram src="/mock/topology/pipeline" external="false" 
metric="false"></camel-topology-diagram>
+      </div>
+    </div>
+  </section>
+
+  <!-- ══════════════════════════════════════════════════════
+       SECTION 3 — External endpoints
+       ══════════════════════════════════════════════════════ -->
+  <section id="external" class="section">
+    <div class="section-header">
+      <h2>External Endpoints</h2>
+      <span class="section-tag tag-external">external="true"</span>
+    </div>
+    <div class="card">
+      <div class="card-header">
+        <h3>In/out boundary nodes around the route graph</h3>
+        <p>
+          With <code>external="true"</code> the component reads 
<code>externalEndpoints</code> and adds
+          dashed gray boundary nodes: an inbound <code>platform-http</code> 
endpoint feeding
+          <strong>orders-api</strong>, and an outbound 
<code>kafka:shipped-orders</code> endpoint that
+          <strong>fulfilment</strong> publishes to. Verifies the three-band 
layout (external-in / routes /
+          external-out) and the dashed external edges.
+        </p>
+      </div>
+      <div class="card-body diagram-scroll themed-diagram">
+        <camel-topology-diagram src="/mock/topology/external" external="true" 
metric="false"></camel-topology-diagram>
+      </div>
+    </div>
+  </section>
+
+  <!-- ══════════════════════════════════════════════════════
+       SECTION 4 — Interlinked shared endpoint
+       ══════════════════════════════════════════════════════ -->
+  <section id="interlink" class="section">
+    <div class="section-header">
+      <h2>Interlinked Shared Endpoint</h2>
+      <span class="section-tag tag-interlink">interlink="true"</span>
+    </div>
+    <div class="card">
+      <div class="card-header">
+        <h3>Two producers collapsing onto one shared broker node</h3>
+        <p>
+          <strong>producer-a</strong> and <strong>producer-b</strong> both 
publish to
+          <code>kafka:shared</code>. With <code>external="true" 
interlink="true"</code> the shared endpoint
+          is hoisted into a single intermediate node both producers connect 
through, rather than drawing two
+          independent edges. Verifies <code>expandExternalEdges</code> 
shared-endpoint collapsing.
+        </p>
+      </div>
+      <div class="card-body diagram-scroll themed-diagram">
+        <camel-topology-diagram src="/mock/topology/interlink" external="true" 
interlink="true" metric="false"></camel-topology-diagram>
+      </div>
+    </div>
+  </section>
+
+  <!-- ══════════════════════════════════════════════════════
+       SECTION 5 — Live metrics
+       ══════════════════════════════════════════════════════ -->
+  <section id="metrics" class="section">
+    <div class="section-header">
+      <h2>Live Metrics</h2>
+      <span class="section-tag tag-metrics">metric="true"</span>
+    </div>
+    <div class="card">
+      <div class="card-header">
+        <h3>Per-route exchange counters with long, truncated labels</h3>
+        <p>
+          With <code>metric="true"</code> each route box shows
+          <em>✓&nbsp;successes&nbsp;/&nbsp;✗&nbsp;failures</em> in the 
top-right corner
+          (green successes, red failures). The descriptions are deliberately 
long: they are trimmed to a
+          clean ellipsis that reserves room for the metric badge, and hovering 
a trimmed box reveals the
+          full text and underlying URI via a native tooltip. Reproduces the 
route-topology blog screenshot.
+        </p>
+      </div>
+      <div class="card-body diagram-scroll themed-diagram">
+        <camel-topology-diagram src="/mock/topology/metrics" external="false" 
metric="true"></camel-topology-diagram>
+      </div>
+    </div>
+    <div class="card">
+      <div class="card-header">
+        <h3>Large counters reserve room for the badge</h3>
+        <p>
+          This case keeps the default theme but pushes the exchange counters 
into multiple digits, so the
+          first-line label must shrink enough to stay out from under the 
metric badge.
+        </p>
+      </div>
+      <div class="card-body diagram-scroll themed-diagram">
+        <camel-topology-diagram src="/mock/topology/metrics-heavy" 
external="false" metric="true"></camel-topology-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-topology-diagram</code> web component
+    &nbsp;·&nbsp;
+    Local smoke test — not a production page.
+  </p>
+</footer>
+
+<script>
+  /* eslint-disable */
+  /* Header theme switch: light/dark force --ctd-* on the body; "os" clears 
them
+   * so the component falls back to prefers-color-scheme. */
+  (function () {
+    const sw = document.querySelector('.theme-switch');
+    sw.addEventListener('click', function (e) {
+      const btn = e.target.closest('button[data-theme]');
+      if (!btn) return;
+      document.body.dataset.theme = btn.dataset.theme;
+      sw.querySelectorAll('button').forEach(function (b) {
+        const on = b === btn;
+        b.classList.toggle('active', on);
+        b.setAttribute('aria-pressed', String(on));
+      });
+    });
+  })();
+</script>
+
+</body>
+</html>

Reply via email to