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

wu-sheng pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/skywalking-horizon-ui.git


The following commit(s) were added to refs/heads/main by this push:
     new 6fded53  feat(traces): Zipkin trace view reaches native parity + 
unified ?traceId= links (#70)
6fded53 is described below

commit 6fded53d2f1a4ef5445077e04ece9bb268449350
Author: 吴晟 Wu Sheng <[email protected]>
AuthorDate: Tue Jun 23 09:28:37 2026 +0800

    feat(traces): Zipkin trace view reaches native parity + unified ?traceId= 
links (#70)
    
    ## Why
    
    The Zipkin trace view (mesh / k8s / Envoy-ALS layers) looked and behaved 
like a second-class citizen next to the SkyWalking-native trace view — a 
different waterfall, a side-panel span detail, browser `<datalist>` filters 
that wouldn't reopen, and no duration distribution. It also used a separate 
`?openZipkinTraceId=` share param even though `/layer/<layer>/trace` already 
implies the source.
    
    ## What
    
    **Zipkin trace detail + popout now mirror the native trace experience** 
(copy-and-adapted, since the wire fields differ):
    - KPI strip (started / duration / spans / services) + service legend.
    - **Duration-distribution scatter** — drag to filter the list, click a dot 
to open the trace (ported from native, adapted to Zipkin's µs units).
    - Time-positioned **waterfall bars** with the service · operation + 
kind/status affordances *inside* the bar, a time axis, and the duration flipped 
inside near-full-width bars (no stray x-scrollbar).
    - **Centered span-detail modal** (was a cramped side panel).
    - Zipkin annotation codes (`cr` / `cs` / `sr` / `ss` …) get an inline 
plain-language hint; the raw OAP value is kept verbatim.
    
    **Unified shareable links** — both native and Zipkin open from a single 
`?traceId=` under the layer's trace tab. The two globally-mounted popouts 
self-select by trace-ID shape (native is dotted `x.y.z`, Zipkin is bare 
16/32-hex), so `/layer/<layer>/trace?traceId=…` always opens the right viewer. 
The separate `?openZipkinTraceId=` param is gone.
    
    **Searchable, on-theme filter dropdowns** — native Instance/Endpoint and 
Zipkin Service/Remote/Span name now use the shared `TypeaheadSelect` (dark, 
type-to-filter, reopens after a pick); small enums (status/order/limit) stay 
plain `<select>`.
---
 CHANGELOG.md                                       |   6 +
 .../src/components/primitives/TypeaheadSelect.vue  |  15 +-
 apps/ui/src/i18n/locales/en.json                   |   9 +-
 apps/ui/src/layer/logs/LayerLogsView.vue           |  52 +-
 apps/ui/src/layer/traces/LayerTracesView.vue       |  55 +-
 apps/ui/src/layer/traces/LayerZipkinTracesView.vue | 957 +++++++++++++++------
 apps/ui/src/layer/traces/ZipkinTracePopout.vue     | 534 +++++++-----
 apps/ui/src/layer/traces/useTracePopout.ts         |   4 +-
 apps/ui/src/layer/traces/useZipkinTracePopout.ts   |  31 +-
 apps/ui/src/shell/AppShell.vue                     |   6 +-
 10 files changed, 1160 insertions(+), 509 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 80a13e6..9e43cb5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,6 +13,12 @@ The version line is shared by every package in the monorepo 
(apps + shared packa
 - **Unified page-size pickers across the event lists.** Traces, Logs, and 
Browser Logs share a `20 / 30 / 50 / 100` page-size dropdown — and Browser Logs 
gains a picker it never had (it had a fixed 100). Each picker's max matches the 
server-side fetch cap in `performance.limits.maxPageSize`.
 - **Node memory sizing guidance.** The container image now sets a default 
`NODE_OPTIONS=--max-old-space-size`, and the docs cover sizing the Node heap to 
your container memory limit and the in-memory source-map budget.
 
+### Trace explorer
+
+- **Zipkin traces now render with the full native trace experience.** The 
Zipkin trace detail and popout gained the KPI strip, service legend, the 
duration-distribution scatter (drag to filter, click to open), time-positioned 
waterfall bars (service · operation inside the bar, kind/status affordances), 
and a centered span-detail modal — matching the SkyWalking-native trace view. 
Zipkin annotation codes (`cr` / `cs` / `sr` / `ss` …) show an inline 
plain-language hint.
+- **Shareable trace links are unified.** Native and Zipkin traces both open 
from a single `?traceId=` link under the layer's trace tab; the viewer 
auto-selects native vs Zipkin by the trace-ID shape, so 
`/layer/<layer>/trace?traceId=…` always opens the right one.
+- **Trace filters are searchable, on-theme dropdowns.** The native Service / 
Instance / Endpoint pickers and the Zipkin Service / Remote service / Span name 
pickers use a dark type-to-filter dropdown that reopens correctly after a pick.
+
 ## 0.7.0
 
 ### Browser errors & source maps
diff --git a/apps/ui/src/components/primitives/TypeaheadSelect.vue 
b/apps/ui/src/components/primitives/TypeaheadSelect.vue
index 7c69ebf..edf53d7 100644
--- a/apps/ui/src/components/primitives/TypeaheadSelect.vue
+++ b/apps/ui/src/components/primitives/TypeaheadSelect.vue
@@ -42,6 +42,7 @@ const props = defineProps<{
   options: Opt[];
   placeholder?: string;
   disabled?: boolean;
+  ariaLabel?: string;
   /** Minimum width of the dropdown panel. The trigger sizes to its own
    *  content; the dropdown is `max(triggerWidth, minPanelWidth)`. */
   minPanelWidth?: number;
@@ -119,7 +120,15 @@ onBeforeUnmount(() => 
document.removeEventListener('click', onDocClick));
 
 <template>
   <div ref="root" class="tas" :class="{ 'is-open': open, 'is-disabled': 
disabled }">
-    <button type="button" class="tas__trigger" :disabled="disabled" 
@click="toggle">
+    <button
+      type="button"
+      class="tas__trigger"
+      :disabled="disabled"
+      :aria-label="ariaLabel"
+      aria-haspopup="listbox"
+      :aria-expanded="open"
+      @click="toggle"
+    >
       <span class="tas__label" :class="{ 'tas__label--ph': !selectedLabel }">
         {{ selectedLabel || placeholder || t('Select…') }}
       </span>
@@ -139,12 +148,14 @@ onBeforeUnmount(() => 
document.removeEventListener('click', onDocClick));
         :placeholder="t('Filter…')"
         @keydown="onKey"
       />
-      <ul class="tas__list">
+      <ul class="tas__list" role="listbox">
         <li v-if="filtered.length === 0" class="tas__empty">{{ t('No matches') 
}}</li>
         <li
           v-for="(o, i) in filtered"
           :key="String(o.value)"
           class="tas__row"
+          role="option"
+          :aria-selected="o.value === modelValue"
           :class="{ 'is-active': i === activeIdx, 'is-selected': o.value === 
modelValue }"
           @mouseenter="activeIdx = i"
           @click="pick(o)"
diff --git a/apps/ui/src/i18n/locales/en.json b/apps/ui/src/i18n/locales/en.json
index 13d3f90..8d77c6c 100644
--- a/apps/ui/src/i18n/locales/en.json
+++ b/apps/ui/src/i18n/locales/en.json
@@ -1453,5 +1453,12 @@
   "Comparing {n} endpoints across services": "Comparing {n} endpoints across 
services",
   "Failed to load": "Failed to load",
   "{n} locked · lock 1 more to compare": "{n} locked · lock 1 more to compare",
-  "All": "All"
+  "All": "All",
+  "client send": "client send",
+  "client receive": "client receive",
+  "server receive": "server receive",
+  "server send": "server send",
+  "wire send": "wire send",
+  "wire receive": "wire receive",
+  "error": "error"
 }
diff --git a/apps/ui/src/layer/logs/LayerLogsView.vue 
b/apps/ui/src/layer/logs/LayerLogsView.vue
index 38bc826..6d21655 100644
--- a/apps/ui/src/layer/logs/LayerLogsView.vue
+++ b/apps/ui/src/layer/logs/LayerLogsView.vue
@@ -947,25 +947,27 @@ function jumpToTrace(traceId: string, ts?: number): void {
           <span v-if="popoutRow.endpointName" class="lg-meta">endpoint 
<code>{{ popoutRow.endpointName }}</code></span>
           <span v-if="popoutRow.traceId" class="lg-meta">trace <code 
class="mono">{{ popoutRow.traceId }}</code></span>
         </div>
-        <pre
-          v-if="detectFormat(popoutRow) === 'json'"
-          class="lg-popout-body json"
-        >{{ prettyForFormat(popoutRow, 'json') }}</pre>
-        <pre
-          v-else-if="detectFormat(popoutRow) === 'yaml'"
-          class="lg-popout-body yaml"
-        >{{ popoutRow.content }}</pre>
-        <pre v-else class="lg-popout-body text">{{ popoutRow.content }}</pre>
-        <div v-if="popoutRow.tags.length > 0" class="lg-popout-tags">
-          <table class="lg-popout-tag-tbl">
-            <thead><tr><th>Key</th><th>Value</th></tr></thead>
-            <tbody>
-              <tr v-for="t in popoutRow.tags" :key="`${t.key}=${t.value}`">
-                <td class="mono">{{ t.key }}</td>
-                <td class="mono">{{ t.value }}</td>
-              </tr>
-            </tbody>
-          </table>
+        <div class="lg-popout-split">
+          <pre
+            v-if="detectFormat(popoutRow) === 'json'"
+            class="lg-popout-body json"
+          >{{ prettyForFormat(popoutRow, 'json') }}</pre>
+          <pre
+            v-else-if="detectFormat(popoutRow) === 'yaml'"
+            class="lg-popout-body yaml"
+          >{{ popoutRow.content }}</pre>
+          <pre v-else class="lg-popout-body text">{{ popoutRow.content }}</pre>
+          <aside v-if="popoutRow.tags.length > 0" class="lg-popout-tags">
+            <table class="lg-popout-tag-tbl">
+              <thead><tr><th>Key</th><th>Value</th></tr></thead>
+              <tbody>
+                <tr v-for="t in popoutRow.tags" :key="`${t.key}=${t.value}`">
+                  <td class="mono">{{ t.key }}</td>
+                  <td class="mono">{{ t.value }}</td>
+                </tr>
+              </tbody>
+            </table>
+          </aside>
         </div>
       </article>
     </div>
@@ -1585,8 +1587,11 @@ function jumpToTrace(traceId: string, ts?: number): void 
{
   background: var(--sw-bg-2);
   border-radius: 3px;
 }
+.lg-popout-split { flex: 1; min-height: 0; display: flex; }
 .lg-popout-body {
   flex: 1;
+  min-height: 0;
+  min-width: 0;
   margin: 0;
   padding: 12px 14px;
   background: var(--sw-bg-0);
@@ -1601,16 +1606,19 @@ function jumpToTrace(traceId: string, ts?: number): 
void {
 .lg-popout-body.json { color: var(--sw-cyan); }
 .lg-popout-body.yaml { color: #fbbf24; }
 .lg-popout-tags {
-  border-top: 1px solid var(--sw-line);
-  padding: 8px 14px;
-  max-height: 200px;
+  flex: 0 0 340px;
+  border-left: 1px solid var(--sw-line);
+  padding: 8px 14px 12px;
   overflow: auto;
+  background: var(--sw-bg-1);
 }
 .lg-popout-tag-tbl {
   width: 100%;
+  table-layout: fixed;
   border-collapse: collapse;
   font-size: 11px;
 }
+.lg-popout-tag-tbl td { word-break: break-all; vertical-align: top; }
 .lg-popout-tag-tbl th, .lg-popout-tag-tbl td {
   padding: 4px 10px;
   text-align: left;
diff --git a/apps/ui/src/layer/traces/LayerTracesView.vue 
b/apps/ui/src/layer/traces/LayerTracesView.vue
index 9865aa9..35cad5c 100644
--- a/apps/ui/src/layer/traces/LayerTracesView.vue
+++ b/apps/ui/src/layer/traces/LayerTracesView.vue
@@ -52,6 +52,7 @@ import { useLayers } from '@/shell/useLayers';
 import { useLayerTraces, useTraceDetail } from '@/layer/traces/useLayerTraces';
 import { useLayerInstances } from '@/layer/useLayerInstances';
 import { useLayerEndpoints } from '@/layer/useLayerEndpoints';
+import TypeaheadSelect from '@/components/primitives/TypeaheadSelect.vue';
 import { useSelectedService } from '@/layer/useSelectedService';
 import { useLayerServiceName } from '@/layer/useLayerServiceName';
 import { useSetupStore } from '@/state/setup';
@@ -169,6 +170,23 @@ const sourceRef = computed<'native'>(() => 'native');
 
 const { instances } = useLayerInstances(layerKey, serviceName);
 const { endpoints } = useLayerEndpoints(layerKey, serviceName, endpointQuery, 
ref(50));
+// '' ↔ null bridges the All sentinel (TypeaheadSelect value is a string).
+const instanceSelectOptions = computed(() => [
+  { value: '', label: t('All') },
+  ...instances.value.map((i) => ({ value: i.id, label: i.name })),
+]);
+const endpointSelectOptions = computed(() => [
+  { value: '', label: t('All') },
+  ...endpoints.value.map((e) => ({ value: e.id, label: e.name })),
+]);
+const instanceIdSel = computed<string>({
+  get: () => instanceId.value ?? '',
+  set: (v) => { instanceId.value = v || null; },
+});
+const endpointIdSel = computed<string>({
+  get: () => endpointId.value ?? '',
+  set: (v) => { endpointId.value = v || null; },
+});
 
 watch([serviceName], () => {
   instanceId.value = null;
@@ -990,17 +1008,25 @@ onBeforeUnmount(() => 
window.removeEventListener('keydown', onPageKeyDown, true)
         <div class="tr-conditions">
           <label class="cf">
             <span>{{ t('Instance') }}</span>
-            <select v-model="instanceId" :disabled="!serviceName" 
class="cf-input">
-              <option :value="null">{{ t('All') }}</option>
-              <option v-for="i in instances" :key="i.id" :value="i.id">{{ 
i.name }}</option>
-            </select>
+            <TypeaheadSelect
+              v-model="instanceIdSel"
+              :aria-label="t('Instance')"
+              :options="instanceSelectOptions"
+              :placeholder="t('All')"
+              :disabled="!serviceName"
+              class="cf-tas"
+            />
           </label>
           <label class="cf cf-wide">
             <span>{{ t('Endpoint') }}</span>
-            <select v-model="endpointId" :disabled="!serviceName" 
class="cf-input">
-              <option :value="null">{{ t('All') }}</option>
-              <option v-for="e in endpoints" :key="e.id" :value="e.id">{{ 
e.name }}</option>
-            </select>
+            <TypeaheadSelect
+              v-model="endpointIdSel"
+              :aria-label="t('Endpoint')"
+              :options="endpointSelectOptions"
+              :placeholder="t('All')"
+              :disabled="!serviceName"
+              class="cf-tas"
+            />
           </label>
           <label class="cf">
             <span>{{ t('Status') }}</span>
@@ -1489,7 +1515,7 @@ onBeforeUnmount(() => 
window.removeEventListener('keydown', onPageKeyDown, true)
                 :d="lnk.d"
                 class="tree-link"
                 fill="none"
-                stroke="var(--sw-line-2)"
+                stroke="var(--sw-fg-3)"
                 stroke-width="1.4"
               />
               <g
@@ -1799,6 +1825,17 @@ onBeforeUnmount(() => 
window.removeEventListener('keydown', onPageKeyDown, true)
   box-sizing: border-box;
 }
 .cf-input:disabled { opacity: 0.5; cursor: not-allowed; }
+.cf-tas { display: block; width: 100%; }
+.cf-tas :deep(.tas__trigger) {
+  width: 100%;
+  max-width: none;
+  min-width: 0;
+  height: 28px;
+  padding: 0 8px;
+  font-size: 11px;
+  background: var(--sw-bg-2);
+  border-radius: 4px;
+}
 .cf-range { display: flex; align-items: center; gap: 4px; }
 .cf-range-num { flex: 1; min-width: 0; }
 .cf-range-sep { color: var(--sw-fg-3); font-size: 12px; flex: 0 0 auto; }
diff --git a/apps/ui/src/layer/traces/LayerZipkinTracesView.vue 
b/apps/ui/src/layer/traces/LayerZipkinTracesView.vue
index eb4792a..d027a9b 100644
--- a/apps/ui/src/layer/traces/LayerZipkinTracesView.vue
+++ b/apps/ui/src/layer/traces/LayerZipkinTracesView.vue
@@ -30,8 +30,8 @@ import type { ZipkinTraceListRow } from 
'@skywalking-horizon-ui/api-client';
 const { t } = useI18n({ useScope: 'global' });
 import { useLayerZipkinTraces, useZipkinTrace } from 
'@/layer/traces/useZipkinTraces';
 import { useZipkinTracePopout } from '@/layer/traces/useZipkinTracePopout';
-import { readAccent } from '@/utils/cssVar';
 import { bffClient } from '@/api/client';
+import TypeaheadSelect from '@/components/primitives/TypeaheadSelect.vue';
 
 // Zipkin trace data is keyed by its own service universe (the names
 // reported in span `localEndpoint.serviceName`), not by SkyWalking's
@@ -124,6 +124,9 @@ const { traces, isFetching, error, refetch } = 
useLayerZipkinTraces({
 });
 const { openTrace } = useZipkinTracePopout();
 
+// hasQueried gate: a layer switch leaves cached `traces` stale until refetch.
+const shownTraces = computed<ZipkinTraceListRow[]>(() => (hasQueried.value ? 
traces.value : []));
+
 function runQuery(): void {
   // Service filter is now operator-driven (free-text + datalist), not
   // bound to the URL service picker. Empty = "All services" in
@@ -157,30 +160,16 @@ function runQuery(): void {
   void refetch();
 }
 
-// URL `?traceId=<id>` opens the popout directly (e.g. log-row link
-// → trace jump). We just route those through the Zipkin popout state.
-watch(
-  () => route.query.traceId,
-  (raw) => {
-    const id = typeof raw === 'string' ? raw : null;
-    if (id) openTrace(id);
-  },
-  { immediate: true },
-);
+// Zipkin's service universe is independent of SkyWalking's per-page
+// picker; the input defaults to All-services and narrows via the
+// /api/v2/services dropdown.
 
-// Zipkin's service universe is INDEPENDENT of SkyWalking's per-page
-// service picker — different name index (no `<group>::` prefix, no
-// `normal` flag, different list endpoint). We don't seed from the URL
-// SkyWalking service; the input defaults to empty (= All services
-// known to Zipkin) and the operator narrows by picking from the
-// `/api/v2/services` dropdown.
-//
-// Auto-fire one query on landing so the table isn't empty on first
-// arrival — runs with "All" so every layer (mesh / k8s / etc.) shows
-// something immediately.
+// No auto-fire (traces are expensive); a layer switch resets to the prompt.
 watch(layerKey, () => {
-  if (!hasQueried.value) runQuery();
-}, { immediate: true });
+  hasQueried.value = false;
+  selectedTraceId.value = null;
+  pickedTraceIds.value = new Set();
+});
 
 // Load the full Zipkin service list once for the filter dropdown.
 // Best-effort: a failed fetch leaves the input as plain text.
@@ -203,6 +192,18 @@ watch(layerKey, () => { void loadServiceOptions(); }, { 
immediate: true });
 // "all remotes" endpoint.
 const spanNameOptions = ref<string[]>([]);
 const remoteSvcOptions = ref<string[]>([]);
+const serviceSelectOptions = computed(() => [
+  { value: '', label: t('All') },
+  ...zipkinServiceOptions.value.map((s) => ({ value: s, label: s })),
+]);
+const remoteSelectOptions = computed(() => [
+  { value: '', label: t('any') },
+  ...remoteSvcOptions.value.map((s) => ({ value: s, label: s })),
+]);
+const spanNameSelectOptions = computed(() => [
+  { value: '', label: t('any') },
+  ...spanNameOptions.value.map((s) => ({ value: s, label: s })),
+]);
 async function loadAutocomplete(svc: string): Promise<void> {
   if (!svc) {
     spanNameOptions.value = [];
@@ -281,8 +282,8 @@ watch(layerKey, (k) => { if (k) void loadAnnotationKeys(); 
}, { immediate: true
 // Mirrors `LayerTracesView`'s native pattern: a row click commits a
 // selection to local state instead of opening the global popout.
 // The selected trace's full spans render in the side rail. The
-// trace-id input + URL-driven `?openZipkinTraceId=` still fall back
-// to the popout for the "paste an id" / "share a link" flows.
+// trace-id input + URL-driven `?traceId=` still fall back to the
+// popout for the "paste an id" / "share a link" flows.
 const selectedTraceId = ref<string | null>(null);
 function selectRow(row: ZipkinTraceListRow): void {
   selectedTraceId.value = row.traceId;
@@ -290,14 +291,20 @@ function selectRow(row: ZipkinTraceListRow): void {
 function closeDetail(): void {
   selectedTraceId.value = null;
 }
+function copyDetailTraceId(): void {
+  if (!selectedTraceId.value) return;
+  navigator.clipboard?.writeText(selectedTraceId.value).catch(() => {});
+}
+function copyDetailUrl(): void {
+  if (!selectedTraceId.value || typeof window === 'undefined') return;
+  const url = new URL(window.location.href);
+  url.searchParams.set('traceId', selectedTraceId.value);
+  navigator.clipboard?.writeText(url.toString()).catch(() => {});
+}
 /** Rail toggle — when a trace is selected, the result list collapses
  *  to a narrow rail with progress-bar-only entries (folder style).
  *  Click any bar to swap the inline detail without expanding back. */
 const railOpen = ref<boolean>(true);
-const selectedRow = computed<ZipkinTraceListRow | null>(() => {
-  if (!selectedTraceId.value) return null;
-  return traces.value.find((t) => t.traceId === selectedTraceId.value) ?? null;
-});
 // Reset when the query parameters change so a stale selection
 // doesn't outlive its result set.
 watch([cService, cLookback, cLimit, cSpan, cRemote, cAnno], () => {
@@ -309,8 +316,7 @@ watch([cService, cLookback, cLimit, cSpan, cRemote, cAnno], 
() => {
 // waterfall row list. Reused-but-simplified from `ZipkinTracePopout`:
 // same parent-id walk, fewer per-row affordances since the rail is
 // narrower than the popout.
-const selectedTraceIdRef = computed<string | null>(() => 
selectedTraceId.value);
-const { spans: selectedSpans, isLoading: selectedLoading } = 
useZipkinTrace(selectedTraceIdRef);
+const { spans: selectedSpans, isLoading: selectedLoading } = 
useZipkinTrace(selectedTraceId);
 interface DetailRow { span: 
import('@skywalking-horizon-ui/api-client').ZipkinSpan; depth: number; 
offsetUs: number; durUs: number; }
 const detailBounds = computed(() => {
   let t0 = Infinity;
@@ -337,8 +343,11 @@ const detailRows = computed<DetailRow[]>(() => {
   for (const arr of byParent.values()) arr.sort((a, b) => (a.timestamp ?? 0) - 
(b.timestamp ?? 0));
   const out: DetailRow[] = [];
   const { t0 } = detailBounds.value;
+  const visited = new Set<string>();
   function walk(parentId: string, depth: number): void {
     for (const s of (byParent.get(parentId) ?? [])) {
+      if (visited.has(s.id)) continue; // guard against cyclic parentId chains
+      visited.add(s.id);
       const start = s.timestamp ?? t0;
       out.push({ span: s, depth, offsetUs: start - t0, durUs: s.duration ?? 0 
});
       walk(s.id, depth + 1);
@@ -356,27 +365,63 @@ const detailRows = computed<DetailRow[]>(() => {
   }
   return out;
 });
-/* Per-service color palette. First entry tracks `--sw-accent` so the
- * trace waterfall's brand color follows the active theme; the rest
- * stay constant because their job is "be distinct from each other"
- * across services, not "match brand". Rebuilt per call so theme
- * swaps land immediately on next render. */
+// Palette + hash match the native trace detail (TracePopout).
+const SERVICE_PALETTE = [
+  'var(--sw-accent)', 'var(--sw-info)', 'var(--sw-cyan)', 'var(--sw-purple)',
+  'var(--sw-ok)', 'var(--sw-warn)', 'var(--sw-pink)',
+  '#a78bfa', '#fb7185', '#34d399', '#fbbf24', '#60a5fa',
+];
+function hashString(s: string): number {
+  let h = 2166136261;
+  for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 
16777619); }
+  return h >>> 0;
+}
 function detailColor(name: string | null | undefined): string {
-  if (!name) return 'var(--sw-fg-3)';
-  const palette = [readAccent('#f97316'), '#60a5fa', '#a78bfa', '#22d3ee', 
'#f472b6', '#34d399', '#fbbf24', '#fb7185'];
-  let h = 0;
-  for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) | 0;
-  return palette[Math.abs(h) % palette.length]!;
+  if (!name) return 'var(--sw-fg-2)';
+  return SERVICE_PALETTE[hashString(name) % SERVICE_PALETTE.length]!;
+}
+const detailServiceColors = computed<Map<string, string>>(() => {
+  const m = new Map<string, string>();
+  for (const s of selectedSpans.value) {
+    const n = s.localEndpoint?.serviceName ?? '—';
+    if (!m.has(n)) m.set(n, detailColor(n));
+  }
+  return m;
+});
+const detailRootStart = computed<number | null>(() => {
+  const ts = selectedSpans.value.map((s) => s.timestamp ?? 0).filter(Boolean);
+  return ts.length ? Math.min(...ts) : null;
+});
+function kindColor(k: string | null | undefined): string {
+  const t = (k ?? '').toUpperCase();
+  if (t.includes('SERVER')) return 'var(--sw-accent)';
+  if (t.includes('CLIENT')) return 'var(--sw-info)';
+  if (t.includes('PRODUCER') || t.includes('CONSUMER')) return 
'var(--sw-purple)';
+  return 'var(--sw-fg-2)';
+}
+// Zipkin/B3 timing codes → human label; unknown values render raw.
+const ANNOTATION_LABELS: Record<string, string> = {
+  cs: 'client send', cr: 'client receive',
+  sr: 'server receive', ss: 'server send',
+  ws: 'wire send', wr: 'wire receive',
+  error: 'error',
+};
+function annotationHint(value: string): string | null {
+  const label = ANNOTATION_LABELS[value.trim().toLowerCase()];
+  return label ? t(label) : null;
+}
+function fmtDateTime(usSinceEpoch: number | null | undefined): string {
+  if (!usSinceEpoch || !Number.isFinite(usSinceEpoch)) return '—';
+  const d = new Date(usSinceEpoch / 1000);
+  const pad = (n: number) => String(n).padStart(2, '0');
+  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} 
${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
 }
 function detailLeftPct(us: number): number {
   return Math.max(0, Math.min(100, (us / (detailBounds.value.totalUs || 1)) * 
100));
 }
 function detailWidthPct(us: number): number {
-  if (us <= 0) return 0.6;
-  return Math.max(0.6, Math.min(100, (us / (detailBounds.value.totalUs || 1)) 
* 100));
-}
-function expandInPopout(): void {
-  if (selectedTraceId.value) openTrace(selectedTraceId.value);
+  if (us <= 0) return 0.8;
+  return Math.max(0.8, Math.min(100, (us / (detailBounds.value.totalUs || 1)) 
* 100));
 }
 
 // ── Span detail (Lens-style inline panel) ───────────────────────
@@ -399,15 +444,12 @@ function clearSpan(): void {
 watch(selectedTraceId, () => { selectedSpanId.value = null; });
 
 // Dismiss the floating span-detail overlay on Escape or click-outside.
-// A click on another `.ztr-wf-row` is ignored here — the row's own
-// handler swaps to that span; if we closed first we'd lose the new pin.
 const spanDetailRef = ref<HTMLElement | null>(null);
 function onSpanDetailDocClick(e: MouseEvent): void {
   if (!selectedSpan.value) return;
   const t = e.target as Element | null;
   if (!t) return;
   if (spanDetailRef.value?.contains(t)) return;
-  if (t.closest?.('.ztr-wf-row')) return;
   clearSpan();
 }
 function onSpanDetailKey(e: KeyboardEvent): void {
@@ -423,14 +465,9 @@ onMounted(() => {
 onBeforeUnmount(() => {
   document.removeEventListener('mousedown', onSpanDetailDocClick);
   document.removeEventListener('keydown', onSpanDetailKey);
+  if (autocompleteTimer) clearTimeout(autocompleteTimer);
 });
 
-function fmtAbsTime(usSinceEpoch: number): string {
-  if (!usSinceEpoch) return '—';
-  const d = new Date(usSinceEpoch / 1000);
-  return `${d.toLocaleTimeString('en-US', { hour12: false 
})}.${String(d.getMilliseconds()).padStart(3, '0')}`;
-}
-
 // ── Rendering helpers ───────────────────────────────────────────
 function fmtMs(us: number | null): string {
   if (us == null) return '—';
@@ -441,14 +478,17 @@ function fmtMs(us: number | null): string {
 // `fmtTime` removed — absolute start moved to `fmtRelativeAgo` in
 // the row meta strip; the card layout drops the wall-clock column.
 const sortedTraces = computed<ZipkinTraceListRow[]>(() => {
-  return [...traces.value].sort((a, b) => (b.timestamp ?? 0) - (a.timestamp ?? 
0));
+  const rows = pickedTraceIds.value.size > 0
+    ? shownTraces.value.filter((r) => pickedTraceIds.value.has(r.traceId))
+    : shownTraces.value;
+  return [...rows].sort((a, b) => (b.timestamp ?? 0) - (a.timestamp ?? 0));
 });
 /** Largest duration in the visible set — drives the proportional
  *  fill of each row's duration bar. Matches the native trace tab's
  *  "% of slowest" presentation. */
 const maxTraceDurationUs = computed<number>(() => {
   let m = 0;
-  for (const t of traces.value) {
+  for (const t of shownTraces.value) {
     if ((t.duration ?? 0) > m) m = t.duration ?? 0;
   }
   return m || 1;
@@ -475,10 +515,131 @@ function openByInput(): void {
   const v = traceIdInput.value.trim();
   if (v) openTrace(v);
 }
+
+// ── Duration scatter ──────────────────────────────────────────────
+// x = trace start (µs since epoch), y = duration (µs). Click a dot to
+// open the inline detail; drag a box to pick a set and filter the list.
+interface ScatterPoint { id: string; x: number; y: number; isError: boolean; 
label: string; row: ZipkinTraceListRow; }
+const scatterPoints = computed<ScatterPoint[]>(() =>
+  shownTraces.value
+    .filter((r): r is ZipkinTraceListRow & { timestamp: number; duration: 
number } =>
+      r.timestamp != null && r.timestamp > 0 && r.duration != null && 
r.duration >= 0)
+    .map((r) => ({
+      id: r.traceId,
+      x: r.timestamp,
+      y: r.duration,
+      isError: r.errorCount > 0,
+      label: r.rootName ?? r.rootService ?? '—',
+      row: r,
+    })),
+);
+const scatterBounds = computed(() => {
+  const pts = scatterPoints.value.filter((p) => p.x > 0 && 
Number.isFinite(p.y));
+  if (pts.length === 0) return null;
+  const xs = pts.map((p) => p.x);
+  const ys = pts.map((p) => p.y);
+  return { xMin: Math.min(...xs), xMax: Math.max(...xs), yMin: 0, yMax: 
Math.max(...ys, 1) };
+});
+const scatterXTicks = computed(() => {
+  const b = scatterBounds.value;
+  if (!b) return [];
+  const xCount = 3;
+  const span = Math.max(1, b.xMax - b.xMin);
+  return Array.from({ length: xCount }, (_, i) => {
+    // x is µs since epoch — divide by 1000 for the JS Date.
+    const d = new Date((b.xMin + (span * i) / (xCount - 1)) / 1000);
+    const pad = (n: number) => String(n).padStart(2, '0');
+    return { frac: i / (xCount - 1), label: 
`${pad(d.getHours())}:${pad(d.getMinutes())}` };
+  });
+});
+
+// Clicking a dot opens the inline detail; dragging a box picks every
+// dot inside it and the list narrows to the picked set — no extra
+// query fires. Reset clears it.
+const pickedTraceIds = ref<Set<string>>(new Set());
+const isPicking = computed(() => pickedTraceIds.value.size > 0);
+function resetPick(): void {
+  pickedTraceIds.value = new Set();
+}
+// Drop a stale pick when the result set changes.
+watch(traces, () => { pickedTraceIds.value = new Set(); });
+
+const scatterSvgRef = ref<SVGSVGElement | null>(null);
+const dragState = ref<{
+  active: boolean;
+  startVx: number; startVy: number; curVx: number; curVy: number;
+}>({ active: false, startVx: 0, startVy: 0, curVx: 0, curVy: 0 });
+function clientToViewbox(ev: PointerEvent): { vx: number; vy: number } | null {
+  const svg = scatterSvgRef.value;
+  if (!svg) return null;
+  const rect = svg.getBoundingClientRect();
+  if (rect.width === 0 || rect.height === 0) return null;
+  return {
+    vx: ((ev.clientX - rect.left) / rect.width) * 1000,
+    vy: ((ev.clientY - rect.top) / rect.height) * 1000,
+  };
+}
+function onScatterDown(ev: PointerEvent): void {
+  const pt = clientToViewbox(ev);
+  if (!pt) return;
+  const target = ev.target as Element | null;
+  // Dot clicks are handled by the circle's own handler; drag is for the
+  // empty plot area.
+  if (target?.classList.contains('scatter-dot')) return;
+  ev.preventDefault();
+  dragState.value = { active: true, startVx: pt.vx, startVy: pt.vy, curVx: 
pt.vx, curVy: pt.vy };
+  (ev.currentTarget as SVGSVGElement).setPointerCapture(ev.pointerId);
+}
+function onScatterMove(ev: PointerEvent): void {
+  if (!dragState.value.active) return;
+  const pt = clientToViewbox(ev);
+  if (!pt) return;
+  dragState.value = { ...dragState.value, curVx: pt.vx, curVy: pt.vy };
+}
+function onScatterUp(ev: PointerEvent): void {
+  if (!dragState.value.active) return;
+  const { startVx, startVy, curVx, curVy } = dragState.value;
+  dragState.value = { active: false, startVx: 0, startVy: 0, curVx: 0, curVy: 
0 };
+  try {
+    (ev.currentTarget as SVGSVGElement).releasePointerCapture(ev.pointerId);
+  } catch { /* noop */ }
+  // A click without movement leaves the selection untouched.
+  if (Math.abs(curVx - startVx) < 4 && Math.abs(curVy - startVy) < 4) return;
+  const b = scatterBounds.value;
+  if (!b) return;
+  const vxMin = Math.min(startVx, curVx);
+  const vxMax = Math.max(startVx, curVx);
+  const vyMin = Math.min(startVy, curVy);
+  const vyMax = Math.max(startVy, curVy);
+  const next = new Set(pickedTraceIds.value);
+  for (const p of scatterPoints.value) {
+    const cx = b.xMax === b.xMin ? 500 : ((p.x - b.xMin) / (b.xMax - b.xMin)) 
* 1000;
+    const cy = 1000 - ((p.y - b.yMin) / (b.yMax - b.yMin || 1)) * 990;
+    if (cx >= vxMin && cx <= vxMax && cy >= vyMin && cy <= vyMax) 
next.add(p.id);
+  }
+  pickedTraceIds.value = next;
+}
+const dragRect = computed(() => {
+  const s = dragState.value;
+  if (!s.active) return null;
+  return {
+    x: Math.min(s.startVx, s.curVx),
+    y: Math.min(s.startVy, s.curVy),
+    w: Math.abs(s.curVx - s.startVx),
+    h: Math.abs(s.curVy - s.startVy),
+  };
+});
+// Dot click opens the inline detail for that trace.
+function pickScatterDot(p: ScatterPoint, ev: MouseEvent): void {
+  ev.stopPropagation();
+  selectRow(p.row);
+}
 </script>
 
 <template>
   <div class="ztr-tab">
+    <!-- Top strip: filter (80%) + duration distribution (20%). -->
+    <div class="ztr-top-strip">
     <header class="ztr-toolbar sw-card">
       <div class="ztr-head">
         <span class="kicker">{{ t('Traces · Zipkin') }}</span>
@@ -493,51 +654,39 @@ function openByInput(): void {
         <button class="sw-btn primary ztr-run-btn" type="button" 
@click="runQuery">{{ t('Run query') }}</button>
       </div>
       <div class="ztr-conditions">
-        <!-- Service condition — free-text + datalist with All. Empty
+        <!-- Service condition — on-theme TypeaheadSelect. Empty value
              commits as `null` (= every service) to the Zipkin query. -->
         <label class="cf">
           <span>{{ t('Service') }}</span>
-          <input
+          <TypeaheadSelect
             v-model="zipkinServiceFilter"
-            class="cf-input"
-            type="text"
+            :aria-label="t('Service')"
+            :options="serviceSelectOptions"
             :placeholder="t('All')"
-            list="ztr-svc-suggest"
-            @keyup.enter="runQuery"
+            class="cf-tas"
           />
-          <datalist id="ztr-svc-suggest">
-            <option v-for="s in zipkinServiceOptions" :key="s" :value="s" />
-          </datalist>
         </label>
         <label class="cf" :class="{ disabled: !hasService }">
           <span>{{ t('Remote service') }} <small v-if="!hasService" 
class="dim">— {{ t('pick a service') }}</small></span>
-          <input
+          <TypeaheadSelect
             v-model="remoteServiceName"
-            class="cf-input"
-            type="text"
+            :aria-label="t('Remote service')"
+            :options="remoteSelectOptions"
             :placeholder="hasService ? t('any') : t('select a service first')"
             :disabled="!hasService"
-            list="ztr-remote-svc-suggest"
-            @keyup.enter="runQuery"
+            class="cf-tas"
           />
-          <datalist id="ztr-remote-svc-suggest">
-            <option v-for="rs in remoteSvcOptions" :key="rs" :value="rs" />
-          </datalist>
         </label>
         <label class="cf cf-wide" :class="{ disabled: !hasService }">
           <span>{{ t('Span name') }} <small v-if="!hasService" class="dim">— 
{{ t('pick a service') }}</small></span>
-          <input
+          <TypeaheadSelect
             v-model="spanName"
-            class="cf-input"
-            type="text"
+            :aria-label="t('Span name')"
+            :options="spanNameSelectOptions"
             :placeholder="hasService ? t('any') : t('select a service first')"
             :disabled="!hasService"
-            list="ztr-span-suggest"
-            @keyup.enter="runQuery"
+            class="cf-tas"
           />
-          <datalist id="ztr-span-suggest">
-            <option v-for="sp in spanNameOptions" :key="sp" :value="sp" />
-          </datalist>
         </label>
         <label class="cf">
           <span>{{ t('Min duration (ms)') }}</span>
@@ -607,6 +756,80 @@ function openByInput(): void {
       </div>
     </header>
 
+      <!-- Distribution: dots only. Click a dot to open its trace; drag
+           a box to narrow the list. -->
+      <section class="ztr-scatter sw-card">
+        <header class="ztr-scatter-head">
+          <span v-if="!isPicking" class="kicker">{{ t('Distribution') }}</span>
+          <span v-else class="kicker pick-kicker">{{ t('{n} picked', { n: 
pickedTraceIds.size }) }}</span>
+          <span class="legend">
+            <span class="lg ok" /> {{ t('ok') }}
+            <span class="lg err" /> {{ t('err') }}
+          </span>
+          <button
+            v-if="isPicking"
+            class="sw-btn small ghost reset-btn"
+            type="button"
+            :title="t('Clear in-page filter')"
+            @click="resetPick"
+          >{{ t('Reset') }}</button>
+        </header>
+        <div v-if="scatterPoints.length > 0 && scatterBounds" 
class="scatter-wrap">
+          <svg
+            ref="scatterSvgRef"
+            class="scatter-svg"
+            viewBox="0 0 1000 1000"
+            preserveAspectRatio="none"
+            @pointerdown="onScatterDown"
+            @pointermove="onScatterMove"
+            @pointerup="onScatterUp"
+            @pointercancel="onScatterUp"
+          >
+            <line x1="0" y1="998" x2="1000" y2="998" stroke="var(--sw-line-2)" 
stroke-width="1" vector-effect="non-scaling-stroke" />
+            <circle
+              v-for="p in scatterPoints"
+              :key="p.id"
+              :cx="scatterBounds.xMax === scatterBounds.xMin ? 500 : ((p.x - 
scatterBounds.xMin) / (scatterBounds.xMax - scatterBounds.xMin)) * 1000"
+              :cy="1000 - ((p.y - scatterBounds.yMin) / (scatterBounds.yMax - 
scatterBounds.yMin || 1)) * 990"
+              :r="pickedTraceIds.has(p.id) ? 6 : 3.2"
+              :fill="p.isError ? 'var(--sw-err)' : 'var(--sw-accent)'"
+              :fill-opacity="pickedTraceIds.has(p.id) ? 1 : (isPicking ? 0.35 
: 0.9)"
+              :stroke="pickedTraceIds.has(p.id) ? 'var(--sw-fg-0)' : 
(p.isError ? 'var(--sw-err)' : 'var(--sw-accent-2)')"
+              :stroke-width="pickedTraceIds.has(p.id) ? 1.8 : 0.8"
+              vector-effect="non-scaling-stroke"
+              class="scatter-dot"
+              @click="pickScatterDot(p, $event)"
+            >
+              <title>{{ p.label }} · {{ fmtMs(p.y) }}{{ 
pickedTraceIds.has(p.id) ? ` · ${t('picked')}` : '' }}</title>
+            </circle>
+            <rect
+              v-if="dragRect"
+              :x="dragRect.x"
+              :y="dragRect.y"
+              :width="dragRect.w"
+              :height="dragRect.h"
+              fill="var(--sw-accent)"
+              fill-opacity="0.12"
+              stroke="var(--sw-accent)"
+              stroke-width="1"
+              stroke-dasharray="4 3"
+              vector-effect="non-scaling-stroke"
+              pointer-events="none"
+            />
+          </svg>
+          <div class="scatter-x-axis">
+            <span
+              v-for="(tick, i) in scatterXTicks"
+              :key="`x${i}`"
+              class="x-tick"
+              :class="{ first: i === 0, last: i === scatterXTicks.length - 1 }"
+            >{{ tick.label }}</span>
+          </div>
+        </div>
+        <div v-else class="scatter-empty">{{ t('no traces') }}</div>
+      </section>
+    </div>
+
     <!-- No selection — full-width list. Once an operator clicks a
          row, the section flips below to the rail+detail split. -->
     <section v-if="!selectedTraceId" class="ztr-split">
@@ -740,108 +963,135 @@ function openByInput(): void {
       <article class="ztr-detail sw-card">
         <header class="ztr-detail-head">
           <span class="kicker">{{ t('Trace') }}</span>
-          <code class="ztr-tid mono">{{ selectedTraceId.slice(0, 18) }}…</code>
-          <span v-if="selectedRow" class="dim small">{{ 
fmtMs(selectedRow.duration) }} · {{ t('{n} spans', { n: selectedRow.spanCount 
}) }}</span>
-          <button class="sw-btn small" type="button" 
@click="expandInPopout">{{ t('Expand') }}</button>
-          <button class="sw-btn small ghost" type="button" 
@click="closeDetail">×</button>
+          <code class="ztr-tid mono" :title="selectedTraceId ?? ''">{{ 
selectedTraceId }}</code>
+          <button class="sw-btn small copy-btn" type="button" :title="t('Copy 
trace id')" @click="copyDetailTraceId">⧉ {{ t('id') }}</button>
+          <button class="sw-btn small copy-btn" type="button" :title="t('Copy 
shareable URL')" @click="copyDetailUrl">⧉ {{ t('url') }}</button>
+          <span v-if="selectedLoading" class="hint">{{ t('loading…') }}</span>
+          <button class="sw-btn small ghost ztr-detail-close" type="button" 
:title="t('Close')" @click="closeDetail">×</button>
         </header>
         <div v-if="selectedLoading" class="ztr-empty hint">{{ t('loading 
spans…') }}</div>
         <div v-else-if="detailRows.length === 0" class="ztr-empty">{{ t('No 
spans for this trace.') }}</div>
-        <div v-else class="ztr-detail-body">
-          <div class="ztr-waterfall">
-            <div
-              v-for="row in detailRows"
-              :key="row.span.id"
-              class="ztr-wf-row"
-              :class="{
-                err: row.span.tags?.error != null,
-                on: selectedSpanId === row.span.id,
-              }"
-              :title="row.span.name ?? ''"
-              @click="selectSpan(row.span)"
-            >
-              <span class="ztr-wf-label mono" :style="{ paddingLeft: row.depth 
* 12 + 'px' }">
-                <span class="ztr-wf-svc" :style="{ color: 
detailColor(row.span.localEndpoint?.serviceName) }">
-                  {{ row.span.localEndpoint?.serviceName ?? '—' }}
-                </span>
-                <span class="ztr-wf-name">{{ row.span.name || t('(unnamed)') 
}}</span>
-              </span>
-              <div class="ztr-wf-track">
-                <div
-                  class="ztr-wf-bar"
-                  :style="{
-                    left: detailLeftPct(row.offsetUs) + '%',
-                    width: detailWidthPct(row.durUs) + '%',
-                    background: 
detailColor(row.span.localEndpoint?.serviceName),
-                    borderColor: row.span.tags?.error != null ? 
'var(--sw-err)' : 'transparent',
-                  }"
-                />
+        <template v-else>
+          <div class="ztr-kpis">
+            <div><div class="kpi-label">{{ t('started') }}</div><div 
class="kpi-val">{{ fmtDateTime(detailRootStart) }}</div></div>
+            <div><div class="kpi-label">{{ t('duration') }}</div><div 
class="kpi-val">{{ fmtMs(detailBounds.totalUs) }}</div></div>
+            <div><div class="kpi-label">{{ t('spans') }}</div><div 
class="kpi-val">{{ detailRows.length }}</div></div>
+            <div><div class="kpi-label">{{ t('services') }}</div><div 
class="kpi-val">{{ detailServiceColors.size }}</div></div>
+          </div>
+          <div v-if="detailServiceColors.size > 0" class="ztr-svc-legend">
+            <span v-for="[code, color] in detailServiceColors" :key="code" 
class="svc-chip">
+              <span class="svc-swatch" :style="{ background: color }" />
+              <span class="mono">{{ code }}</span>
+            </span>
+          </div>
+          <div class="ztr-detail-body">
+            <div class="ztr-waterfall">
+              <div class="tp-time-axis">
+                <span class="t-tick first">0</span>
+                <span class="t-tick">{{ fmtMs(detailBounds.totalUs * 0.25) 
}}</span>
+                <span class="t-tick">{{ fmtMs(detailBounds.totalUs * 0.5) 
}}</span>
+                <span class="t-tick">{{ fmtMs(detailBounds.totalUs * 0.75) 
}}</span>
+                <span class="t-tick last">{{ fmtMs(detailBounds.totalUs) 
}}</span>
+              </div>
+              <div
+                v-for="row in detailRows"
+                :key="row.span.id"
+                class="tp-row"
+                :class="{ err: row.span.tags?.error != null, on: 
selectedSpanId === row.span.id }"
+                @click="selectSpan(row.span)"
+              >
+                <div class="tp-track">
+                  <span class="t-grid q1" /><span class="t-grid q2" /><span 
class="t-grid q3" />
+                  <div
+                    class="tp-bar"
+                    :style="{
+                      left: detailLeftPct(row.offsetUs) + '%',
+                      width: detailWidthPct(row.durUs) + '%',
+                      background: 
detailColor(row.span.localEndpoint?.serviceName),
+                      borderColor: row.span.tags?.error != null ? 
'var(--sw-err)' : 'transparent',
+                    }"
+                  >
+                    <span class="bar-inner">
+                      <span
+                        class="status-flag sm"
+                        :class="row.span.tags?.error != null ? 'flag-err' : 
'flag-ok'"
+                        :title="row.span.tags?.error != null ? t('Span 
errored') : t('Span OK')"
+                      ><span class="flag-dot" /></span>
+                      <svg class="comp-icon comp-icon-generic" viewBox="0 0 18 
18" :aria-label="t('generic span')">
+                        <rect x="3" y="4.5" width="12" height="3" rx="1.5" 
fill="currentColor" opacity="0.45" />
+                        <rect x="5" y="10.5" width="10" height="3" rx="1.5" 
fill="currentColor" opacity="0.85" />
+                      </svg>
+                      <span class="bar-text mono">
+                        <span class="bar-svc" 
:title="row.span.localEndpoint?.serviceName ?? ''">{{ 
row.span.localEndpoint?.serviceName ?? '—' }}</span>
+                        <span class="bar-name" :title="row.span.name || 
row.span.remoteEndpoint?.serviceName || '—'">{{ row.span.name || 
row.span.remoteEndpoint?.serviceName || '—' }}</span>
+                      </span>
+                      <span v-if="detailWidthPct(row.durUs) > 12" 
class="bar-dur-inside mono">{{ fmtMs(row.durUs) }}</span>
+                    </span>
+                  </div>
+                  <span
+                    v-if="detailWidthPct(row.durUs) <= 12"
+                    class="bar-dur-outside mono"
+                    :style="{ left: `calc(${detailLeftPct(row.offsetUs + 
row.durUs)}% + 6px)` }"
+                  >{{ fmtMs(row.durUs) }}</span>
+                </div>
               </div>
-              <span class="ztr-wf-dur mono">{{ fmtMs(row.durUs) }}</span>
             </div>
-          </div>
 
-          <!-- Span detail floats as a right-edge overlay (width capped
-               at min(640px, 60%)) so it can render long tag values
-               without compressing the waterfall bars. Click ×, click
-               the same span, or pick another span to dismiss / swap. -->
-          <aside v-if="selectedSpan" ref="spanDetailRef" 
class="ztr-span-detail">
-            <header class="ztr-span-detail-head">
-              <h5>{{ t('Span detail') }}</h5>
+          <!-- Span detail opens as a centered modal (max-width 920px) so
+               it can render long tag values without compressing the
+               waterfall bars. Click ×, click the same span, or pick
+               another span to dismiss / swap. -->
+          <div v-if="selectedSpan" class="span-modal-backdrop">
+            <article ref="spanDetailRef" class="span-modal sw-card">
+            <header class="span-modal-head">
+              <h4><span class="dim">{{ t('Span detail') }}</span> <span 
class="mono">{{ selectedSpan.name || '—' }}</span></h4>
               <button class="sw-btn small ghost" type="button" 
:title="t('Close')" @click="clearSpan">×</button>
             </header>
-            <dl class="zk-kv">
-              <dt>{{ t('Service') }}</dt>
-              <dd
-                class="mono"
-                :style="{ color: 
detailColor(selectedSpan.localEndpoint?.serviceName) }"
-              >
-                {{ selectedSpan.localEndpoint?.serviceName ?? '—' }}
-              </dd>
-              <dt>{{ t('Name') }}</dt><dd class="mono wba">{{ 
selectedSpan.name || t('(unnamed)') }}</dd>
-              <dt v-if="selectedSpan.kind">{{ t('Kind') }}</dt>
-              <dd v-if="selectedSpan.kind">{{ selectedSpan.kind }}</dd>
-              <dt>{{ t('Span id') }}</dt><dd class="mono wba">{{ 
selectedSpan.id }}</dd>
-              <dt v-if="selectedSpan.parentId">{{ t('Parent id') }}</dt>
-              <dd v-if="selectedSpan.parentId" class="mono wba">{{ 
selectedSpan.parentId }}</dd>
-              <dt>{{ t('Start') }}</dt><dd class="mono">{{ 
fmtAbsTime(selectedSpan.timestamp ?? 0) }}</dd>
-              <dt>{{ t('Duration') }}</dt><dd class="mono">{{ 
fmtMs(selectedSpan.duration ?? 0) }}</dd>
-              <dt v-if="selectedSpan.remoteEndpoint?.serviceName">{{ t('Peer') 
}}</dt>
-              <dd v-if="selectedSpan.remoteEndpoint?.serviceName" class="mono 
wba">
-                {{ selectedSpan.remoteEndpoint.serviceName }}
-              </dd>
-              <dt v-if="selectedSpan.remoteEndpoint?.ipv4 || 
selectedSpan.remoteEndpoint?.ipv6">{{ t('Peer addr') }}</dt>
-              <dd v-if="selectedSpan.remoteEndpoint?.ipv4 || 
selectedSpan.remoteEndpoint?.ipv6" class="mono wba">
-                {{ selectedSpan.remoteEndpoint.ipv4 ?? 
selectedSpan.remoteEndpoint.ipv6
-                  }}<template v-if="selectedSpan.remoteEndpoint.port">:{{ 
selectedSpan.remoteEndpoint.port }}</template>
-              </dd>
-            </dl>
-            <section
-              v-if="selectedSpan.tags && Object.keys(selectedSpan.tags).length 
> 0"
-              class="zk-tags"
-            >
+            <div class="span-modal-body">
+            <section class="sd-section">
+              <h6>{{ t('Meta') }}</h6>
+              <dl class="kv">
+                <dt>{{ t('Service') }}</dt>
+                <dd class="mono" :style="{ color: 
detailColor(selectedSpan.localEndpoint?.serviceName) }">
+                  <span class="svc-swatch inline" :style="{ background: 
detailColor(selectedSpan.localEndpoint?.serviceName) }" />
+                  {{ selectedSpan.localEndpoint?.serviceName ?? '—' }}
+                </dd>
+                <dt>{{ t('Name') }}</dt><dd class="mono wba">{{ 
selectedSpan.name || '—' }}</dd>
+                <dt>{{ t('Kind') }}</dt><dd><span class="tp-kind" :style="{ 
color: kindColor(selectedSpan.kind) }">{{ selectedSpan.kind ?? '—' 
}}</span></dd>
+                <dt>{{ t('Peer') }}</dt><dd class="mono wba">{{ 
selectedSpan.remoteEndpoint?.serviceName || '—' }}</dd>
+                <dt v-if="selectedSpan.remoteEndpoint?.ipv4 || 
selectedSpan.remoteEndpoint?.ipv6">{{ t('Peer addr') }}</dt>
+                <dd v-if="selectedSpan.remoteEndpoint?.ipv4 || 
selectedSpan.remoteEndpoint?.ipv6" class="mono wba">{{ 
selectedSpan.remoteEndpoint?.ipv4 ?? selectedSpan.remoteEndpoint?.ipv6 
}}<template v-if="selectedSpan.remoteEndpoint?.port">:{{ 
selectedSpan.remoteEndpoint.port }}</template></dd>
+                <dt>{{ t('Span id') }}</dt><dd class="mono wba">{{ 
selectedSpan.id }}</dd>
+                <dt v-if="selectedSpan.parentId">{{ t('Parent id') }}</dt>
+                <dd v-if="selectedSpan.parentId" class="mono wba">{{ 
selectedSpan.parentId }}</dd>
+                <dt>{{ t('Start') }}</dt><dd class="mono">{{ 
fmtDateTime(selectedSpan.timestamp) }}</dd>
+                <dt>{{ t('Duration') }}</dt><dd class="mono">{{ 
fmtMs(selectedSpan.duration ?? 0) }}</dd>
+                <dt>{{ t('Error') }}</dt><dd><span class="status-flag" 
:class="selectedSpan.tags?.error != null ? 'flag-err' : 'flag-ok'"><span 
class="flag-dot" />{{ selectedSpan.tags?.error != null ? t('true') : t('false') 
}}</span></dd>
+              </dl>
+            </section>
+            <section v-if="selectedSpan.tags && 
Object.keys(selectedSpan.tags).length > 0" class="sd-section">
               <h6>{{ t('Tags') }}</h6>
-              <dl class="zk-kv">
+              <dl class="kv">
                 <template v-for="(v, k) in selectedSpan.tags" :key="k">
                   <dt class="mono">{{ k }}</dt>
                   <dd class="mono wba" :class="{ err: k === 'error' }">{{ v 
}}</dd>
                 </template>
               </dl>
             </section>
-            <section
-              v-if="selectedSpan.annotations && 
selectedSpan.annotations.length > 0"
-              class="zk-annotations"
-            >
+            <section v-if="selectedSpan.annotations && 
selectedSpan.annotations.length > 0" class="sd-section">
               <h6>{{ t('Annotations') }}</h6>
-              <ul>
-                <li v-for="(a, i) in selectedSpan.annotations" :key="i">
-                  <span class="mono dim">{{ fmtAbsTime(a.timestamp) }}</span>
-                  <span class="zk-ann-val mono">{{ a.value }}</span>
-                </li>
-              </ul>
+              <div v-for="(a, i) in selectedSpan.annotations" :key="i" 
class="span-event">
+                <div class="span-event-head">
+                  <span class="mono">{{ a.value }}<span 
v-if="annotationHint(a.value)" class="ann-hint" :title="annotationHint(a.value) 
?? ''"> ({{ annotationHint(a.value) }})</span></span>
+                  <span class="dim mono">{{ fmtDateTime(a.timestamp) }}</span>
+                </div>
+              </div>
             </section>
-          </aside>
-        </div>
+            </div>
+            </article>
+          </div>
+          </div>
+        </template>
       </article>
     </section>
   </div>
@@ -849,6 +1099,15 @@ function openByInput(): void {
 
 <style scoped>
 .ztr-tab { display: flex; flex-direction: column; gap: 12px; padding: 4px 0 0; 
}
+.ztr-top-strip {
+  display: grid;
+  grid-template-columns: 4fr 1fr;
+  gap: 12px;
+  align-items: stretch;
+}
+@media (max-width: 1100px) { .ztr-top-strip { grid-template-columns: 1fr; } }
+.ztr-top-strip .ztr-toolbar,
+.ztr-top-strip .ztr-scatter { margin: 0; }
 .ztr-toolbar { padding: 10px 12px; display: flex; flex-direction: column; gap: 
10px; overflow: visible; }
 .ztr-head { display: flex; align-items: baseline; gap: 10px; }
 .ztr-run-btn { margin-left: auto; }
@@ -901,6 +1160,17 @@ function openByInput(): void {
   cursor: not-allowed;
   background: var(--sw-bg-1);
 }
+.cf-tas { display: block; width: 100%; }
+.cf-tas :deep(.tas__trigger) {
+  width: 100%;
+  max-width: none;
+  min-width: 0;
+  height: 28px;
+  padding: 0 8px;
+  font-size: 11px;
+  background: var(--sw-bg-2);
+  border-radius: 4px;
+}
 .cf.disabled > span { color: var(--sw-fg-3); opacity: 0.7; }
 .cf small { font-weight: 400; font-size: 9.5px; margin-left: 4px; font-style: 
italic; }
 .cf .dim { color: var(--sw-fg-3); }
@@ -918,6 +1188,68 @@ function openByInput(): void {
 }
 .sw-btn.primary:disabled { opacity: 0.5; cursor: not-allowed; }
 .sw-btn.primary:hover:not(:disabled) { background: var(--sw-accent-2); }
+
+/* ── Duration distribution scatter ───────────────────────────────
+   Ported from `LayerTracesView`'s `.tr-scatter` so the native +
+   Zipkin trace tabs read identically. */
+.ztr-scatter {
+  padding: 6px 10px 8px;
+  display: flex;
+  flex-direction: column;
+  min-height: 0;
+}
+.ztr-scatter-head { display: flex; align-items: baseline; gap: 10px; 
margin-bottom: 2px; flex: 0 0 auto; }
+.ztr-scatter-head .legend { margin-left: auto; font-size: 10.5px; color: 
var(--sw-fg-3); display: inline-flex; gap: 10px; align-items: center; }
+.ztr-scatter-head .lg {
+  display: inline-block;
+  width: 7px;
+  height: 7px;
+  border-radius: 50%;
+  margin-right: 3px;
+  vertical-align: middle;
+}
+.ztr-scatter-head .lg.ok { background: var(--sw-accent); }
+.ztr-scatter-head .lg.err { background: var(--sw-err); }
+.pick-kicker { color: var(--sw-accent-2); font-weight: 700; }
+.reset-btn { margin-left: 6px; }
+.scatter-wrap {
+  padding: 0;
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  min-height: 0;
+}
+.scatter-svg {
+  cursor: crosshair;
+  width: 100%;
+  flex: 1;
+  min-height: 140px;
+  display: block;
+}
+.scatter-dot { cursor: pointer; transition: r 0.12s ease; }
+.scatter-dot:hover { stroke: var(--sw-fg-0); stroke-width: 1.6; }
+.scatter-x-axis {
+  display: flex;
+  justify-content: space-between;
+  padding: 4px 0 0;
+  font-size: 11px;
+  color: var(--sw-fg-2);
+  font-family: var(--sw-mono);
+  flex: 0 0 auto;
+}
+.x-tick { white-space: nowrap; }
+.x-tick.first { text-align: left; }
+.x-tick.last { text-align: right; }
+.scatter-empty {
+  flex: 1;
+  min-height: 140px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 11px;
+  color: var(--sw-fg-3);
+}
+
 .ztr-list { display: flex; flex-direction: column; min-height: 240px; }
 .ztr-list-head {
   display: flex;
@@ -1042,9 +1374,6 @@ function openByInput(): void {
   grid-template-columns: 1fr;
   gap: 12px;
 }
-.ztr-split.has-detail {
-  grid-template-columns: minmax(0, 1.4fr) minmax(360px, 1fr);
-}
 .ztr-detail {
   display: flex;
   flex-direction: column;
@@ -1065,13 +1394,9 @@ function openByInput(): void {
   padding: 10px 14px;
   border-bottom: 1px solid var(--sw-line);
 }
-.ztr-tid { flex: 1; color: var(--sw-fg-2); font-size: 11px; }
+.ztr-tid { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: 
ellipsis; white-space: nowrap; color: var(--sw-fg-2); font-size: 11px; }
+.ztr-detail-close { margin-left: auto; }
 
-/* When a span is pinned, the detail panel floats over the right edge
-   of the waterfall as an overlay so it can be wide enough for long tag
-   values without compressing the waterfall bars. min(640px, 60%) caps
-   the panel at 640px on wide layouts but yields gracefully on narrow
-   ones. Pop-out has the same pattern (.zk-detail). */
 .ztr-detail-body {
   flex: 1;
   position: relative;
@@ -1079,69 +1404,88 @@ function openByInput(): void {
   min-height: 0;
   overflow: hidden;
 }
-.ztr-span-detail {
-  position: absolute;
-  top: 0;
-  right: 0;
-  bottom: 0;
-  width: min(640px, 60%);
+.span-modal-backdrop {
+  position: fixed;
+  inset: 0;
+  background: rgba(0, 0, 0, 0.6);
+  z-index: 1000;
+  display: flex;
+  align-items: flex-start;
+  justify-content: center;
+  padding: 40px 20px;
   overflow-y: auto;
-  padding: 12px 14px;
-  background: var(--sw-bg-1);
-  border-left: 1px solid var(--sw-line);
-  box-shadow: -8px 0 24px rgba(0, 0, 0, 0.45);
-  z-index: 5;
 }
-.ztr-span-detail-head {
+.span-modal {
+  width: 100%;
+  max-width: 920px;
+  max-height: calc(100vh - 80px);
+  display: flex;
+  flex-direction: column;
+}
+.span-modal-head {
   display: flex;
   align-items: center;
-  justify-content: space-between;
-  margin-bottom: 8px;
+  gap: 12px;
+  padding: 10px 14px;
+  border-bottom: 1px solid var(--sw-line);
+  flex: 0 0 auto;
 }
-.ztr-span-detail-head h5 {
+.span-modal-head h4 {
   margin: 0;
   font-size: 12px;
-  color: var(--sw-fg-0);
+  font-weight: 600;
+  display: inline-flex;
+  gap: 10px;
+  align-items: baseline;
+  flex: 1;
+  min-width: 0;
+}
+.span-modal-head h4 .dim { color: var(--sw-fg-3); font-weight: 500; }
+.span-modal-head h4 .mono {
+  font-family: var(--sw-mono);
+  color: var(--sw-fg-1);
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
 }
-.zk-tags, .zk-annotations { margin-top: 12px; }
-.zk-tags h6, .zk-annotations h6 {
+.span-modal-body { padding: 12px 14px 16px; overflow-y: auto; }
+.sd-section { margin-bottom: 14px; }
+.sd-section h6 {
   margin: 0 0 6px;
-  font-size: 10px;
+  font-size: 9.5px;
   text-transform: uppercase;
   letter-spacing: 0.08em;
-  color: var(--sw-fg-3);
-  font-weight: 600;
+  color: var(--sw-accent);
+  font-weight: 700;
 }
-.zk-kv {
+.kv {
   display: grid;
-  grid-template-columns: max-content 1fr;
-  gap: 4px 12px;
-  font-size: 11px;
+  grid-template-columns: 100px 1fr;
+  gap: 4px 10px;
   margin: 0;
+  font-size: 11px;
 }
-.zk-kv dt { color: var(--sw-fg-3); }
-.zk-kv dd { margin: 0; color: var(--sw-fg-1); }
-.zk-kv dd.wba { word-break: break-all; }
-.zk-kv dd.err { color: var(--sw-err); }
-.zk-annotations ul {
-  list-style: none;
-  margin: 0;
-  padding: 0;
-  display: flex;
-  flex-direction: column;
-  gap: 4px;
+.kv dt { color: var(--sw-fg-3); font-size: 10.5px; min-width: 0; 
overflow-wrap: anywhere; }
+.kv dd { margin: 0; color: var(--sw-fg-1); min-width: 0; }
+.kv dd.wba { word-break: break-all; }
+.kv dd.err { color: var(--sw-err); }
+.tp-kind { font-family: var(--sw-mono); font-size: 11px; font-weight: 600; }
+.span-event {
+  border: 1px solid var(--sw-line);
+  border-radius: 4px;
+  padding: 6px 10px;
+  margin-bottom: 6px;
+  background: var(--sw-bg-0);
 }
-.zk-annotations li {
-  display: grid;
-  grid-template-columns: 100px 1fr;
-  gap: 8px;
-  font-size: 10.5px;
+.span-event-head {
+  display: flex;
+  justify-content: space-between;
+  gap: 10px;
+  font-size: 11px;
 }
-.zk-annotations .dim { color: var(--sw-fg-3); }
-.zk-ann-val { color: var(--sw-fg-1); word-break: break-all; }
-
-/* Floating overlay scales with the container via min(640px, 60%) — no
-   width breakpoints needed. */
+.ann-hint { color: var(--sw-fg-3); }
+.dim { color: var(--sw-fg-3); }
+.wba { word-break: break-all; }
 
 /* Inline waterfall — each span is a row with `<label> <track> <dur>`.
    Track width is dynamic; the bar inside left/width % positions the
@@ -1150,72 +1494,166 @@ function openByInput(): void {
 .ztr-waterfall {
   flex: 1 1 0;
   min-width: 0;
+  /* overflow-y:auto forces x:auto; clip x (full-width bar's duration renders 
inside). */
+  overflow-x: hidden;
   overflow-y: auto;
+  padding: 4px 0;
 }
-.ztr-wf-row {
-  display: grid;
-  grid-template-columns: minmax(120px, 1fr) minmax(0, 2fr) 64px;
-  gap: 8px;
-  padding: 3px 12px;
+.ztr-kpis {
+  display: flex;
+  gap: 24px;
+  padding: 8px 14px;
+  border-bottom: 1px solid var(--sw-line);
+  flex: 0 0 auto;
+}
+.kpi-label {
+  font-size: 9.5px;
+  text-transform: uppercase;
+  letter-spacing: 0.06em;
+  color: var(--sw-fg-3);
+}
+.kpi-val {
+  font-family: var(--sw-mono);
+  font-size: 13px;
+  color: var(--sw-fg-0);
+  font-weight: 700;
+}
+.ztr-svc-legend {
+  display: flex;
+  gap: 6px;
+  flex-wrap: wrap;
+  padding: 6px 14px;
+  border-bottom: 1px solid var(--sw-line);
+  background: var(--sw-bg-1);
+  flex: 0 0 auto;
+}
+.svc-chip {
+  display: inline-flex;
+  align-items: center;
+  gap: 4px;
+  font-size: 10.5px;
+  color: var(--sw-fg-2);
+  padding: 1px 6px 1px 4px;
+  border: 1px solid var(--sw-line-2);
+  border-radius: 10px;
+  background: var(--sw-bg-2);
+}
+.svc-swatch { width: 8px; height: 8px; border-radius: 2px; flex: 0 0 auto; }
+.svc-swatch.inline { display: inline-block; margin-right: 4px; vertical-align: 
middle; }
+.tp-time-axis {
+  position: sticky;
+  top: 0;
+  z-index: 10;
+  display: flex;
+  justify-content: space-between;
+  padding: 6px 12px;
+  background-color: var(--sw-bg-1);
   border-bottom: 1px solid var(--sw-line);
+  box-shadow: 0 2px 6px rgba(0, 0, 0, 0.35);
+  font-family: var(--sw-mono);
+  font-size: 10px;
+  color: var(--sw-fg-3);
+}
+.t-tick { white-space: nowrap; }
+.t-tick.first { text-align: left; }
+.t-tick.last { text-align: right; }
+.tp-row { padding: 3px 12px; cursor: pointer; }
+.tp-row:hover { background: var(--sw-bg-2); }
+.tp-row.err { background: rgba(239, 68, 68, 0.06); }
+.tp-row.on { background: var(--sw-accent-soft); }
+.tp-track { position: relative; height: 22px; background: transparent; }
+.t-grid {
+  position: absolute;
+  top: 0;
+  bottom: 0;
+  width: 1px;
+  background: var(--sw-line);
+  opacity: 0.55;
+  pointer-events: none;
+}
+.t-grid.q1 { left: 25%; }
+.t-grid.q2 { left: 50%; }
+.t-grid.q3 { left: 75%; }
+.tp-bar {
+  position: absolute;
+  top: 2px;
+  bottom: 2px;
+  border-radius: 3px;
+  border: 1px solid transparent;
+  display: flex;
   align-items: center;
+  overflow: hidden;
   cursor: pointer;
+  transition: filter 0.12s ease;
 }
-.ztr-wf-row:hover { background: var(--sw-bg-2); }
-.ztr-wf-row.on {
-  background: var(--sw-accent-soft);
-  box-shadow: inset 2px 0 0 var(--sw-accent);
+.tp-bar:hover { filter: brightness(1.12); }
+.bar-inner {
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
+  padding: 0 6px;
+  height: 100%;
+  width: 100%;
+  min-width: 0;
 }
-.ztr-wf-row.err { box-shadow: inset 3px 0 0 var(--sw-err); }
-.ztr-wf-row.on.err { box-shadow: inset 3px 0 0 var(--sw-err), inset 0 0 0 1px 
var(--sw-accent); }
-.ztr-wf-label {
+.bar-text {
   display: inline-flex;
   align-items: baseline;
   gap: 6px;
-  font-size: 10.5px;
-  white-space: nowrap;
-  overflow: hidden;
-  text-overflow: ellipsis;
   min-width: 0;
+  flex: 1 1 auto;
+  overflow: hidden;
+  color: var(--sw-bg-0);
+  text-shadow: 0 0 1px rgba(0, 0, 0, 0.25);
 }
-.ztr-wf-svc {
-  flex: 0 0 auto;
+.bar-svc {
   font-weight: 700;
-  max-width: 90px;
+  font-size: 10.5px;
   overflow: hidden;
   text-overflow: ellipsis;
+  white-space: nowrap;
+  flex: 0 1 auto;
+  max-width: 40%;
+  opacity: 0.9;
 }
-.ztr-wf-name {
+.bar-name {
   flex: 1 1 auto;
   min-width: 0;
   overflow: hidden;
   text-overflow: ellipsis;
-  color: var(--sw-fg-1);
-}
-.ztr-wf-track {
-  position: relative;
-  height: 14px;
-  background: var(--sw-bg-1);
-  border-radius: 2px;
+  white-space: nowrap;
+  font-size: 11px;
+  font-weight: 500;
 }
-.ztr-wf-bar {
+.comp-icon { width: 18px; height: 18px; flex: 0 0 auto; object-fit: contain; 
background: transparent; }
+.comp-icon-generic { color: var(--sw-fg-2); }
+.bar-inner .comp-icon-generic { color: rgba(0, 0, 0, 0.72); }
+.bar-inner .status-flag.sm { background: rgba(255, 255, 255, 0.35); color: 
var(--sw-bg-0); }
+.bar-inner .status-flag.flag-err { background: var(--sw-err); color: #fff; }
+.bar-dur-outside {
   position: absolute;
-  top: 1px;
-  bottom: 1px;
-  border-radius: 2px;
-  border: 1px solid transparent;
-}
-.ztr-wf-dur {
+  top: 50%;
+  transform: translateY(-50%);
   font-size: 10px;
-  text-align: right;
   color: var(--sw-fg-2);
-  font-variant-numeric: tabular-nums;
+  white-space: nowrap;
+  pointer-events: none;
+}
+.bar-dur-inside {
+  flex: 0 0 auto;
+  margin-left: auto;
+  padding-left: 8px;
+  font-size: 10px;
+  color: var(--sw-bg-0);
+  opacity: 0.82;
+  white-space: nowrap;
 }
+.status-flag.sm { height: 12px; width: 12px; padding: 0; border-radius: 50%; 
justify-content: center; }
+.status-flag.sm .flag-dot { margin: 0; }
 
 @media (max-width: 1100px) {
   /* On narrow screens, fall back to stacked layout — the detail rail
      would crowd the table otherwise. */
-  .ztr-split.has-detail { grid-template-columns: 1fr; }
   .ztr-detail-split { grid-template-columns: 1fr !important; }
 }
 
@@ -1354,4 +1792,5 @@ function openByInput(): void {
   font-size: 14px;
 }
 .sw-btn.small.ghost:hover { color: var(--sw-err); background: var(--sw-bg-2); }
+.sw-btn.small.copy-btn { background: transparent; color: var(--sw-fg-2); 
white-space: nowrap; }
 </style>
diff --git a/apps/ui/src/layer/traces/ZipkinTracePopout.vue 
b/apps/ui/src/layer/traces/ZipkinTracePopout.vue
index 71ca440..01f1726 100644
--- a/apps/ui/src/layer/traces/ZipkinTracePopout.vue
+++ b/apps/ui/src/layer/traces/ZipkinTracePopout.vue
@@ -36,7 +36,6 @@ import type { ZipkinSpan } from 
'@skywalking-horizon-ui/api-client';
 const { t } = useI18n({ useScope: 'global' });
 import { useZipkinTracePopout } from '@/layer/traces/useZipkinTracePopout';
 import { useZipkinTrace } from '@/layer/traces/useZipkinTraces';
-import { readAccent } from '@/utils/cssVar';
 
 const { openTraceId, closeTrace } = useZipkinTracePopout();
 const traceIdRef = computed(() => openTraceId.value);
@@ -132,33 +131,68 @@ function clearSpan(): void {
 // Drop the selection when the trace changes.
 watch(traceIdRef, () => { selectedSpanId.value = null; });
 
-// Floating span-detail dismiss — click outside the .zk-detail panel
-// (but not on another row, which has its own swap handler) clears the
-// pin. Backdrop clicks still close the whole popout via the existing
-// `@click.self` on .zk-popout-backdrop.
+// Click outside the span-detail modal card dismisses it.
 const spanDetailRef = ref<HTMLElement | null>(null);
 function onSpanDetailDocClick(e: MouseEvent): void {
   if (!selectedSpan.value) return;
   const t = e.target as Element | null;
   if (!t) return;
   if (spanDetailRef.value?.contains(t)) return;
-  if (t.closest?.('.zk-row')) return;
   clearSpan();
 }
 
-// ── Color per service so each row reads as a band ────────────────
-// First entry tracks `--sw-accent` so the brand color in the trace
-// waterfall follows the active theme. Rest stay constant for service
-// differentiation.
+// Palette + hash match the native trace detail (TracePopout).
+const SERVICE_PALETTE = [
+  'var(--sw-accent)', 'var(--sw-info)', 'var(--sw-cyan)', 'var(--sw-purple)',
+  'var(--sw-ok)', 'var(--sw-warn)', 'var(--sw-pink)',
+  '#a78bfa', '#fb7185', '#34d399', '#fbbf24', '#60a5fa',
+];
+function hashString(s: string): number {
+  let h = 2166136261;
+  for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 
16777619); }
+  return h >>> 0;
+}
 function serviceColor(name: string | null | undefined): string {
-  if (!name) return 'var(--sw-fg-3)';
-  const palette = [
-    readAccent('#f97316'), '#60a5fa', '#a78bfa', '#22d3ee',
-    '#f472b6', '#34d399', '#fbbf24', '#fb7185',
-  ];
-  let h = 0;
-  for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) | 0;
-  return palette[Math.abs(h) % palette.length]!;
+  if (!name) return 'var(--sw-fg-2)';
+  return SERVICE_PALETTE[hashString(name) % SERVICE_PALETTE.length]!;
+}
+const serviceColors = computed<Map<string, string>>(() => {
+  const m = new Map<string, string>();
+  for (const s of spans.value) {
+    const n = s.localEndpoint?.serviceName ?? '—';
+    if (!m.has(n)) m.set(n, serviceColor(n));
+  }
+  return m;
+});
+const rootStart = computed<number | null>(() => {
+  const ts = spans.value.map((s) => s.timestamp ?? 0).filter(Boolean);
+  return ts.length ? Math.min(...ts) : null;
+});
+function kindColor(k: string | null | undefined): string {
+  const t = (k ?? '').toUpperCase();
+  if (t.includes('SERVER')) return 'var(--sw-accent)';
+  if (t.includes('CLIENT')) return 'var(--sw-info)';
+  if (t.includes('PRODUCER') || t.includes('CONSUMER')) return 
'var(--sw-purple)';
+  return 'var(--sw-fg-2)';
+}
+function fmtDateTime(usSinceEpoch: number | null | undefined): string {
+  if (!usSinceEpoch || !Number.isFinite(usSinceEpoch)) return '—';
+  const d = new Date(usSinceEpoch / 1000);
+  const pad = (n: number) => String(n).padStart(2, '0');
+  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} 
${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
+}
+const ANNOTATION_LABELS: Record<string, string> = {
+  cs: 'client send', cr: 'client receive',
+  sr: 'server receive', ss: 'server send',
+  ws: 'wire send', wr: 'wire receive',
+  error: 'error',
+};
+function annotationHint(value: string): string | null {
+  return ANNOTATION_LABELS[value.trim().toLowerCase()] ?? null;
+}
+function copyShareableUrl(): void {
+  if (typeof window === 'undefined') return;
+  navigator.clipboard?.writeText(window.location.href).catch(() => {});
 }
 
 // ── Formatting ────────────────────────────────────────────────────
@@ -167,18 +201,14 @@ function fmtMs(us: number): string {
   if (us < 1_000_000) return `${(us / 1000).toFixed(2)}ms`;
   return `${(us / 1_000_000).toFixed(2)}s`;
 }
-function fmtAbsTime(usSinceEpoch: number): string {
-  if (!usSinceEpoch) return '';
-  return new Date(usSinceEpoch / 1000).toLocaleString();
-}
 function offsetPct(us: number): number {
   const total = bounds.value.totalUs || 1;
   return Math.max(0, Math.min(100, (us / total) * 100));
 }
 function widthPct(us: number): number {
-  if (us <= 0) return 0.4; // give zero-duration spans a sliver so they render
+  if (us <= 0) return 0.8; // give zero-duration spans a sliver so they render
   const total = bounds.value.totalUs || 1;
-  return Math.max(0.4, Math.min(100, (us / total) * 100));
+  return Math.max(0.8, Math.min(100, (us / total) * 100));
 }
 
 // ── ESC + backdrop close ─────────────────────────────────────────
@@ -233,8 +263,9 @@ function copyTraceId(): void {
     <article class="zk-popout sw-card">
       <header class="zk-head">
         <span class="kicker">{{ t('Zipkin trace') }}</span>
-        <code class="zk-tid mono">{{ openTraceId }}</code>
-        <button class="sw-btn small" type="button" @click="copyTraceId">{{ 
t('Copy id') }}</button>
+        <code class="zk-tid mono" :title="openTraceId ?? ''">{{ openTraceId 
}}</code>
+        <button class="sw-btn small ghost" type="button" :title="t('Copy id')" 
@click="copyTraceId">⧉ {{ t('id') }}</button>
+        <button class="sw-btn small ghost" type="button" :title="t('Copy 
shareable URL')" @click="copyShareableUrl">⧉ {{ t('url') }}</button>
         <span v-if="isLoading" class="hint">{{ t('loading…') }}</span>
         <span v-if="error" class="hint err">{{ String(error) }}</span>
         <button class="sw-btn small ghost zk-close" type="button" 
@click="closeTrace">×</button>
@@ -244,10 +275,21 @@ function copyTraceId(): void {
         {{ t('No spans returned for this trace.') }}
       </div>
 
-      <div v-else class="zk-split">
-        <!-- Waterfall column -->
+      <template v-else>
+        <div class="tp-kpis">
+          <div><div class="kpi-label">{{ t('started') }}</div><div 
class="kpi-val">{{ fmtDateTime(rootStart) }}</div></div>
+          <div><div class="kpi-label">{{ t('duration') }}</div><div 
class="kpi-val">{{ fmtMs(bounds.totalUs) }}</div></div>
+          <div><div class="kpi-label">{{ t('spans') }}</div><div 
class="kpi-val">{{ waterfall.length }}</div></div>
+          <div><div class="kpi-label">{{ t('services') }}</div><div 
class="kpi-val">{{ serviceColors.size }}</div></div>
+        </div>
+        <div v-if="serviceColors.size > 0" class="tp-svc-legend">
+          <span v-for="[code, color] in serviceColors" :key="code" 
class="svc-chip">
+            <span class="svc-swatch" :style="{ background: color }" />
+            <span class="mono">{{ code }}</span>
+          </span>
+        </div>
         <div class="zk-waterfall">
-          <div class="zk-time-axis">
+          <div class="tp-time-axis">
             <span class="t-tick first">0</span>
             <span class="t-tick">{{ fmtMs(bounds.totalUs * 0.25) }}</span>
             <span class="t-tick">{{ fmtMs(bounds.totalUs * 0.5) }}</span>
@@ -257,81 +299,96 @@ function copyTraceId(): void {
           <div
             v-for="row in waterfall"
             :key="row.span.id"
-            class="zk-row"
+            class="tp-row"
             :class="{ on: selectedSpanId === row.span.id, err: 
row.span.tags?.error != null }"
             @click="selectSpan(row.span)"
           >
-            <span class="zk-row-label mono" :style="{ paddingLeft: row.depth * 
14 + 'px' }">
-              <span class="zk-row-svc" :style="{ color: 
serviceColor(row.span.localEndpoint?.serviceName) }">
-                {{ row.span.localEndpoint?.serviceName ?? '—' }}
-              </span>
-              <span class="zk-row-name">{{ row.span.name || t('(unnamed)') 
}}</span>
-            </span>
-            <div class="zk-track">
+            <div class="tp-track">
               <span class="t-grid q1" /><span class="t-grid q2" /><span 
class="t-grid q3" />
               <div
-                class="zk-bar"
+                class="tp-bar"
                 :style="{
                   left: offsetPct(row.startOffsetUs) + '%',
                   width: widthPct(row.durationUs) + '%',
                   background: 
serviceColor(row.span.localEndpoint?.serviceName),
                   borderColor: row.span.tags?.error != null ? 'var(--sw-err)' 
: 'transparent',
                 }"
-              />
-              <span class="zk-dur mono" :style="{ left: 
`calc(${offsetPct(row.startOffsetUs + row.durationUs)}% + 6px)` }">
-                {{ fmtMs(row.durationUs) }}
-              </span>
+              >
+                <span class="bar-inner">
+                  <span
+                    class="status-flag sm"
+                    :class="row.span.tags?.error != null ? 'flag-err' : 
'flag-ok'"
+                    :title="row.span.tags?.error != null ? t('Span errored') : 
t('Span OK')"
+                  ><span class="flag-dot" /></span>
+                  <svg class="comp-icon comp-icon-generic" viewBox="0 0 18 18" 
:aria-label="t('generic span')">
+                    <rect x="3" y="4.5" width="12" height="3" rx="1.5" 
fill="currentColor" opacity="0.45" />
+                    <rect x="5" y="10.5" width="10" height="3" rx="1.5" 
fill="currentColor" opacity="0.85" />
+                  </svg>
+                  <span class="bar-text mono">
+                    <span class="bar-svc" 
:title="row.span.localEndpoint?.serviceName ?? ''">{{ 
row.span.localEndpoint?.serviceName ?? '—' }}</span>
+                    <span class="bar-name" :title="row.span.name || 
row.span.remoteEndpoint?.serviceName || '—'">{{ row.span.name || 
row.span.remoteEndpoint?.serviceName || '—' }}</span>
+                  </span>
+                  <span v-if="widthPct(row.durationUs) > 12" 
class="bar-dur-inside mono">{{ fmtMs(row.durationUs) }}</span>
+                </span>
+              </div>
+              <span
+                v-if="widthPct(row.durationUs) <= 12"
+                class="bar-dur-outside mono"
+                :style="{ left: `calc(${offsetPct(row.startOffsetUs + 
row.durationUs)}% + 6px)` }"
+              >{{ fmtMs(row.durationUs) }}</span>
             </div>
           </div>
         </div>
+      </template>
 
-        <!-- Span detail floats as a right-edge overlay; width caps at
-             min(640px, 60%) so long tag values render without
-             compressing the waterfall. Mirrors LayerZipkinTracesView. -->
-        <aside v-if="selectedSpan" ref="spanDetailRef" class="zk-detail">
-          <header class="zk-detail-head">
-            <h5>{{ t('Span detail') }}</h5>
-            <button class="sw-btn small ghost" type="button" 
@click="clearSpan">×</button>
+      <div v-if="selectedSpan" class="span-modal-backdrop">
+        <article ref="spanDetailRef" class="span-modal sw-card">
+          <header class="span-modal-head">
+            <h4><span class="dim">{{ t('Span detail') }}</span> <span 
class="mono">{{ selectedSpan.name || '—' }}</span></h4>
+            <button class="sw-btn small ghost" type="button" 
:title="t('Close')" @click="clearSpan">×</button>
           </header>
-          <dl class="zk-kv">
-            <dt>{{ t('Service') }}</dt>
-            <dd class="mono" :style="{ color: 
serviceColor(selectedSpan.localEndpoint?.serviceName) }">
-              {{ selectedSpan.localEndpoint?.serviceName ?? '—' }}
-            </dd>
-            <dt>{{ t('Name') }}</dt><dd class="mono wba">{{ selectedSpan.name 
?? '—' }}</dd>
-            <dt>{{ t('Kind') }}</dt><dd>{{ selectedSpan.kind ?? '—' }}</dd>
-            <dt>{{ t('Span id') }}</dt><dd class="mono wba">{{ selectedSpan.id 
}}</dd>
-            <dt v-if="selectedSpan.parentId">{{ t('Parent id') }}</dt>
-            <dd v-if="selectedSpan.parentId" class="mono wba">{{ 
selectedSpan.parentId }}</dd>
-            <dt>{{ t('Start') }}</dt><dd class="mono">{{ 
fmtAbsTime(selectedSpan.timestamp ?? 0) }}</dd>
-            <dt>{{ t('Duration') }}</dt><dd class="mono">{{ 
fmtMs(selectedSpan.duration ?? 0) }}</dd>
-            <dt v-if="selectedSpan.remoteEndpoint?.serviceName">{{ t('Peer') 
}}</dt>
-            <dd v-if="selectedSpan.remoteEndpoint?.serviceName" class="mono 
wba">
-              {{ selectedSpan.remoteEndpoint.serviceName }}
-            </dd>
-          </dl>
-          <section v-if="selectedSpan.tags && 
Object.keys(selectedSpan.tags).length > 0" class="zk-tags">
-            <h6>{{ t('Tags') }}</h6>
-            <dl class="zk-kv">
-              <template v-for="(v, k) in selectedSpan.tags" :key="k">
-                <dt class="mono">{{ k }}</dt>
-                <dd class="mono wba">{{ v }}</dd>
-              </template>
-            </dl>
-          </section>
-          <section
-            v-if="selectedSpan.annotations && selectedSpan.annotations.length 
> 0"
-            class="zk-annotations"
-          >
-            <h6>{{ t('Annotations') }}</h6>
-            <ul>
-              <li v-for="(a, i) in selectedSpan.annotations" :key="i" 
class="mono">
-                <span class="dim">{{ fmtAbsTime(a.timestamp) }}</span>
-                <span class="zk-ann-val">{{ a.value }}</span>
-              </li>
-            </ul>
-          </section>
-        </aside>
+          <div class="span-modal-body">
+            <section class="sd-section">
+              <h6>{{ t('Meta') }}</h6>
+              <dl class="kv">
+                <dt>{{ t('Service') }}</dt>
+                <dd class="mono" :style="{ color: 
serviceColor(selectedSpan.localEndpoint?.serviceName) }">
+                  <span class="svc-swatch inline" :style="{ background: 
serviceColor(selectedSpan.localEndpoint?.serviceName) }" />
+                  {{ selectedSpan.localEndpoint?.serviceName ?? '—' }}
+                </dd>
+                <dt>{{ t('Name') }}</dt><dd class="mono wba">{{ 
selectedSpan.name || '—' }}</dd>
+                <dt>{{ t('Kind') }}</dt><dd><span class="tp-kind" :style="{ 
color: kindColor(selectedSpan.kind) }">{{ selectedSpan.kind ?? '—' 
}}</span></dd>
+                <dt>{{ t('Peer') }}</dt><dd class="mono wba">{{ 
selectedSpan.remoteEndpoint?.serviceName || '—' }}</dd>
+                <dt v-if="selectedSpan.remoteEndpoint?.ipv4 || 
selectedSpan.remoteEndpoint?.ipv6">{{ t('Peer addr') }}</dt>
+                <dd v-if="selectedSpan.remoteEndpoint?.ipv4 || 
selectedSpan.remoteEndpoint?.ipv6" class="mono wba">{{ 
selectedSpan.remoteEndpoint?.ipv4 ?? selectedSpan.remoteEndpoint?.ipv6 
}}<template v-if="selectedSpan.remoteEndpoint?.port">:{{ 
selectedSpan.remoteEndpoint.port }}</template></dd>
+                <dt>{{ t('Span id') }}</dt><dd class="mono wba">{{ 
selectedSpan.id }}</dd>
+                <dt v-if="selectedSpan.parentId">{{ t('Parent id') }}</dt>
+                <dd v-if="selectedSpan.parentId" class="mono wba">{{ 
selectedSpan.parentId }}</dd>
+                <dt>{{ t('Start') }}</dt><dd class="mono">{{ 
fmtDateTime(selectedSpan.timestamp) }}</dd>
+                <dt>{{ t('Duration') }}</dt><dd class="mono">{{ 
fmtMs(selectedSpan.duration ?? 0) }}</dd>
+                <dt>{{ t('Error') }}</dt><dd><span class="status-flag" 
:class="selectedSpan.tags?.error != null ? 'flag-err' : 'flag-ok'"><span 
class="flag-dot" />{{ selectedSpan.tags?.error != null ? t('true') : t('false') 
}}</span></dd>
+              </dl>
+            </section>
+            <section v-if="selectedSpan.tags && 
Object.keys(selectedSpan.tags).length > 0" class="sd-section">
+              <h6>{{ t('Tags') }}</h6>
+              <dl class="kv">
+                <template v-for="(v, k) in selectedSpan.tags" :key="k">
+                  <dt class="mono">{{ k }}</dt>
+                  <dd class="mono wba" :class="{ err: k === 'error' }">{{ v 
}}</dd>
+                </template>
+              </dl>
+            </section>
+            <section v-if="selectedSpan.annotations && 
selectedSpan.annotations.length > 0" class="sd-section">
+              <h6>{{ t('Annotations') }}</h6>
+              <div v-for="(a, i) in selectedSpan.annotations" :key="i" 
class="span-event">
+                <div class="span-event-head">
+                  <span class="mono">{{ a.value }}<span 
v-if="annotationHint(a.value)" class="ann-hint" :title="annotationHint(a.value) 
?? ''"> ({{ annotationHint(a.value) }})</span></span>
+                  <span class="dim mono">{{ fmtDateTime(a.timestamp) }}</span>
+                </div>
+              </div>
+            </section>
+          </div>
+        </article>
       </div>
     </article>
   </div>
@@ -345,7 +402,7 @@ function copyTraceId(): void {
   display: flex;
   align-items: center;
   justify-content: center;
-  z-index: 100;
+  z-index: 999; /* match native TracePopout; the span-modal (1000) nests above 
*/
   padding: 24px;
 }
 .zk-popout {
@@ -373,78 +430,65 @@ function copyTraceId(): void {
   font-size: 12px;
 }
 
-.zk-split {
-  position: relative;
-  flex: 1;
-  min-height: 0;
-  overflow: hidden;
-}
-
 .zk-waterfall {
   height: 100%;
+  overflow-x: hidden;
   overflow-y: auto;
-  padding: 0;
+  padding: 4px 0;
 }
-.zk-time-axis {
+.tp-kpis {
+  display: flex;
+  gap: 24px;
+  padding: 8px 14px;
+  border-bottom: 1px solid var(--sw-line);
+  flex: 0 0 auto;
+}
+.kpi-label { font-size: 9.5px; text-transform: uppercase; letter-spacing: 
0.06em; color: var(--sw-fg-3); }
+.kpi-val { font-family: var(--sw-mono); font-size: 13px; color: 
var(--sw-fg-0); font-weight: 700; }
+.tp-svc-legend {
+  display: flex;
+  gap: 6px;
+  flex-wrap: wrap;
+  padding: 6px 14px;
+  border-bottom: 1px solid var(--sw-line);
+  background: var(--sw-bg-1);
+  flex: 0 0 auto;
+}
+.svc-chip {
+  display: inline-flex;
+  align-items: center;
+  gap: 4px;
+  font-size: 10.5px;
+  color: var(--sw-fg-2);
+  padding: 1px 6px 1px 4px;
+  border: 1px solid var(--sw-line-2);
+  border-radius: 10px;
+  background: var(--sw-bg-2);
+}
+.svc-swatch { width: 8px; height: 8px; border-radius: 2px; flex: 0 0 auto; }
+.svc-swatch.inline { display: inline-block; margin-right: 4px; vertical-align: 
middle; }
+.tp-time-axis {
   position: sticky;
   top: 0;
   z-index: 10;
   display: flex;
   justify-content: space-between;
   padding: 6px 12px;
-  /* Solid background + shadow so the axis cleanly overlays the
-   * scrolling rows underneath rather than letting the first row's
-   * bar bleed through (sibling rendering on scroll). */
   background-color: var(--sw-bg-1);
   border-bottom: 1px solid var(--sw-line);
   box-shadow: 0 2px 6px rgba(0, 0, 0, 0.35);
+  font-family: var(--sw-mono);
   font-size: 10px;
   color: var(--sw-fg-3);
-  font-family: var(--sw-mono);
-}
-.zk-row {
-  display: grid;
-  grid-template-columns: 280px 1fr;
-  align-items: center;
-  gap: 8px;
-  padding: 3px 12px;
-  border-bottom: 1px solid var(--sw-line);
-  cursor: pointer;
-}
-.zk-row:hover { background: var(--sw-bg-2); }
-.zk-row.on { background: var(--sw-bg-3); }
-.zk-row.err { box-shadow: inset 3px 0 0 var(--sw-err); }
-.zk-row-label {
-  display: inline-flex;
-  align-items: baseline;
-  gap: 6px;
-  font-size: 10.5px;
-  white-space: nowrap;
-  overflow: hidden;
-  text-overflow: ellipsis;
-  min-width: 0;
-}
-.zk-row-svc {
-  flex: 0 0 auto;
-  font-weight: 700;
-  max-width: 100px;
-  overflow: hidden;
-  text-overflow: ellipsis;
-}
-.zk-row-name {
-  flex: 1 1 auto;
-  min-width: 0;
-  overflow: hidden;
-  text-overflow: ellipsis;
-  color: var(--sw-fg-1);
-}
-.zk-track {
-  position: relative;
-  height: 18px;
-  background: var(--sw-bg-1);
-  border-radius: 2px;
-  overflow: visible;
 }
+.t-tick { white-space: nowrap; }
+.t-tick.first { text-align: left; }
+.t-tick.last { text-align: right; }
+.tp-row { padding: 3px 12px; cursor: pointer; }
+.tp-row:hover { background: var(--sw-bg-2); }
+.tp-row.err { background: rgba(239, 68, 68, 0.06); }
+.tp-row.on { background: var(--sw-accent-soft); }
+.tp-track { position: relative; height: 22px; background: transparent; }
 .t-grid {
   position: absolute;
   top: 0;
@@ -457,81 +501,180 @@ function copyTraceId(): void {
 .t-grid.q1 { left: 25%; }
 .t-grid.q2 { left: 50%; }
 .t-grid.q3 { left: 75%; }
-.zk-bar {
+.tp-bar {
   position: absolute;
   top: 2px;
   bottom: 2px;
-  border-radius: 2px;
+  border-radius: 3px;
   border: 1px solid transparent;
+  display: flex;
+  align-items: center;
+  overflow: hidden;
+  cursor: pointer;
+  transition: filter 0.12s ease;
+}
+.tp-bar:hover { filter: brightness(1.12); }
+.bar-inner {
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
+  padding: 0 6px;
+  height: 100%;
+  width: 100%;
+  min-width: 0;
+}
+.bar-text {
+  display: inline-flex;
+  align-items: baseline;
+  gap: 6px;
+  min-width: 0;
+  flex: 1 1 auto;
+  overflow: hidden;
+  color: var(--sw-bg-0);
+  text-shadow: 0 0 1px rgba(0, 0, 0, 0.25);
+}
+.bar-svc {
+  font-weight: 700;
+  font-size: 10.5px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  flex: 0 1 auto;
+  max-width: 40%;
+  opacity: 0.9;
 }
-.zk-dur {
+.bar-name {
+  flex: 1 1 auto;
+  min-width: 0;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  font-size: 11px;
+  font-weight: 500;
+}
+.comp-icon { width: 18px; height: 18px; flex: 0 0 auto; object-fit: contain; 
background: transparent; }
+.comp-icon-generic { color: var(--sw-fg-2); }
+.bar-inner .comp-icon-generic { color: rgba(0, 0, 0, 0.72); }
+.bar-inner .status-flag.sm { background: rgba(255, 255, 255, 0.35); color: 
var(--sw-bg-0); }
+.bar-inner .status-flag.flag-err { background: var(--sw-err); color: #fff; }
+.bar-dur-outside {
   position: absolute;
   top: 50%;
   transform: translateY(-50%);
   font-size: 10px;
   color: var(--sw-fg-2);
+  white-space: nowrap;
   pointer-events: none;
+}
+.bar-dur-inside {
+  flex: 0 0 auto;
+  margin-left: auto;
+  padding-left: 8px;
+  font-size: 10px;
+  color: var(--sw-bg-0);
+  opacity: 0.82;
   white-space: nowrap;
 }
-
-.zk-detail {
-  position: absolute;
-  top: 0;
-  right: 0;
-  bottom: 0;
-  width: min(640px, 60%);
+.status-flag {
+  display: inline-flex;
+  align-items: center;
+  gap: 4px;
+  height: 18px;
+  padding: 0 6px;
+  border-radius: 9px;
+  font-size: 9.5px;
+  font-weight: 700;
+  letter-spacing: 0.06em;
+  text-transform: uppercase;
+  flex: 0 0 auto;
+}
+.status-flag.sm { height: 12px; width: 12px; padding: 0; border-radius: 50%; 
justify-content: center; }
+.status-flag.sm .flag-dot { margin: 0; }
+.flag-dot { width: 6px; height: 6px; border-radius: 50%; background: 
currentColor; }
+.status-flag.flag-ok { background: rgba(34, 197, 94, 0.14); color: 
var(--sw-ok); }
+.status-flag.flag-err { background: rgba(239, 68, 68, 0.18); color: 
var(--sw-err); }
+.span-modal-backdrop {
+  position: fixed;
+  inset: 0;
+  background: rgba(0, 0, 0, 0.6);
+  z-index: 1000;
+  display: flex;
+  align-items: flex-start;
+  justify-content: center;
+  padding: 40px 20px;
   overflow-y: auto;
-  padding: 12px 14px;
-  background: var(--sw-bg-1);
-  border-left: 1px solid var(--sw-line);
-  box-shadow: -8px 0 24px rgba(0, 0, 0, 0.45);
-  z-index: 5;
 }
-.zk-detail-head {
+.span-modal {
+  width: 100%;
+  max-width: 920px;
+  max-height: calc(100vh - 80px);
+  display: flex;
+  flex-direction: column;
+}
+.span-modal-head {
   display: flex;
   align-items: center;
-  justify-content: space-between;
-  margin-bottom: 8px;
+  gap: 12px;
+  padding: 10px 14px;
+  border-bottom: 1px solid var(--sw-line);
+  flex: 0 0 auto;
 }
-.zk-detail-head h5 {
+.span-modal-head h4 {
   margin: 0;
   font-size: 12px;
-  color: var(--sw-fg-0);
+  font-weight: 600;
+  display: inline-flex;
+  gap: 10px;
+  align-items: baseline;
+  flex: 1;
+  min-width: 0;
 }
-.zk-tags, .zk-annotations { margin-top: 12px; }
-.zk-tags h6, .zk-annotations h6 {
+.span-modal-head h4 .dim { color: var(--sw-fg-3); font-weight: 500; }
+.span-modal-head h4 .mono {
+  font-family: var(--sw-mono);
+  color: var(--sw-fg-1);
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+.span-modal-body { padding: 12px 14px 16px; overflow-y: auto; }
+.sd-section { margin-bottom: 14px; }
+.sd-section h6 {
   margin: 0 0 6px;
-  font-size: 10px;
+  font-size: 9.5px;
   text-transform: uppercase;
   letter-spacing: 0.08em;
-  color: var(--sw-fg-3);
-  font-weight: 600;
+  color: var(--sw-accent);
+  font-weight: 700;
 }
-.zk-kv {
+.kv {
   display: grid;
-  grid-template-columns: max-content 1fr;
-  gap: 4px 12px;
+  grid-template-columns: 100px 1fr;
+  gap: 4px 10px;
+  margin: 0;
   font-size: 11px;
 }
-.zk-kv dt { color: var(--sw-fg-3); }
-.zk-kv dd { margin: 0; color: var(--sw-fg-1); }
-.zk-kv dd.wba { word-break: break-all; }
-.zk-annotations ul {
-  list-style: none;
-  margin: 0;
-  padding: 0;
-  display: flex;
-  flex-direction: column;
-  gap: 4px;
+.kv dt { color: var(--sw-fg-3); font-size: 10.5px; min-width: 0; 
overflow-wrap: anywhere; }
+.kv dd { margin: 0; color: var(--sw-fg-1); min-width: 0; }
+.kv dd.wba { word-break: break-all; }
+.kv dd.err { color: var(--sw-err); }
+.tp-kind { font-family: var(--sw-mono); font-size: 11px; font-weight: 600; }
+.span-event {
+  border: 1px solid var(--sw-line);
+  border-radius: 4px;
+  padding: 6px 10px;
+  margin-bottom: 6px;
+  background: var(--sw-bg-0);
 }
-.zk-annotations li {
-  display: grid;
-  grid-template-columns: 110px 1fr;
-  gap: 8px;
-  font-size: 10.5px;
+.span-event-head {
+  display: flex;
+  justify-content: space-between;
+  gap: 10px;
+  font-size: 11px;
 }
-.zk-annotations .dim { color: var(--sw-fg-3); }
-.zk-ann-val { color: var(--sw-fg-1); word-break: break-all; }
+.ann-hint { color: var(--sw-fg-3); }
+.dim { color: var(--sw-fg-3); }
+.wba { word-break: break-all; }
 .mono { font-family: var(--sw-mono); }
 .kicker {
   font-size: 10px;
@@ -540,17 +683,6 @@ function copyTraceId(): void {
   color: var(--sw-accent);
   font-weight: 600;
 }
-.sw-btn.primary {
-  background: var(--sw-accent);
-  color: var(--sw-bg-0);
-  border: none;
-  height: 26px;
-  padding: 0 14px;
-  border-radius: 4px;
-  font-size: 11px;
-  font-weight: 600;
-  cursor: pointer;
-}
 .sw-btn.small { height: 24px; padding: 0 10px; font-size: 11px; }
 .sw-btn.ghost { background: transparent; border: 1px solid var(--sw-line-2); 
color: var(--sw-fg-2); }
 </style>
diff --git a/apps/ui/src/layer/traces/useTracePopout.ts 
b/apps/ui/src/layer/traces/useTracePopout.ts
index bf80eac..3b44340 100644
--- a/apps/ui/src/layer/traces/useTracePopout.ts
+++ b/apps/ui/src/layer/traces/useTracePopout.ts
@@ -32,6 +32,7 @@
 
 import { computed } from 'vue';
 import { useRoute, useRouter } from 'vue-router';
+import { useTraceSourceIsZipkin } from './useZipkinTracePopout';
 
 /** Query-string keys. Any URL with TRACE_POPOUT_QUERY auto-opens the
  *  trace popout — shareable. TRACE_POPOUT_AT carries the trace's
@@ -48,9 +49,10 @@ export function useTracePopout() {
   const route = useRoute();
   const router = useRouter();
 
+  const sourceIsZipkin = useTraceSourceIsZipkin();
   const openTraceId = computed<string | null>(() => {
     const v = route.query[TRACE_POPOUT_QUERY];
-    return typeof v === 'string' && v.length > 0 ? v : null;
+    return typeof v === 'string' && v.length > 0 && !sourceIsZipkin.value ? v 
: null;
   });
   const openTraceAtMs = computed<number | null>(() => {
     const v = route.query[TRACE_POPOUT_AT];
diff --git a/apps/ui/src/layer/traces/useZipkinTracePopout.ts 
b/apps/ui/src/layer/traces/useZipkinTracePopout.ts
index a44244f..0e6f30e 100644
--- a/apps/ui/src/layer/traces/useZipkinTracePopout.ts
+++ b/apps/ui/src/layer/traces/useZipkinTracePopout.ts
@@ -15,34 +15,45 @@
  * limitations under the License.
  */
 
-/**
- * URL-backed popout state for Zipkin traces. Mirrors `useTracePopout`
- * (the native popout) but reads/writes `?openZipkinTraceId=<id>` so
- * the two trace kinds can be open simultaneously without colliding.
- */
+/** URL-backed popout state for Zipkin traces, sharing the native
+ *  popout's `?traceId=` param. */
 
 import { computed } from 'vue';
 import { useRoute, useRouter } from 'vue-router';
+import { useLayers } from '@/shell/useLayers';
+
+// Native vs Zipkin keys on the layer's trace source (route), not the
+// ID shape — native IDs can be bare hex, same as Zipkin.
+export function useTraceSourceIsZipkin() {
+  const route = useRoute();
+  const { layers } = useLayers();
+  return computed<boolean>(() => {
+    if (/\/zipkin-trace(\/|$|\?)/.test(route.path)) return true;
+    const key = String(route.params.layerKey ?? '');
+    return (layers.value.find((l) => l.key === key)?.traces?.source ?? 
'native') === 'zipkin';
+  });
+}
 
 export function useZipkinTracePopout() {
   const route = useRoute();
   const router = useRouter();
+  const sourceIsZipkin = useTraceSourceIsZipkin();
 
   const openTraceId = computed<string | null>(() => {
-    const v = route.query.openZipkinTraceId;
-    return typeof v === 'string' && v.length > 0 ? v : null;
+    const v = route.query.traceId;
+    return typeof v === 'string' && v.length > 0 && sourceIsZipkin.value ? v : 
null;
   });
 
   function openTrace(id: string): void {
     if (!id) return;
-    const next = { ...route.query, openZipkinTraceId: id };
-    void router.replace({ path: route.path, query: next });
+    void router.replace({ path: route.path, query: { ...route.query, traceId: 
id } });
   }
 
   function closeTrace(): void {
     if (!openTraceId.value) return;
     const next = { ...route.query };
-    delete next.openZipkinTraceId;
+    delete next.traceId;
+    delete next.traceAt;
     void router.replace({ path: route.path, query: next });
   }
 
diff --git a/apps/ui/src/shell/AppShell.vue b/apps/ui/src/shell/AppShell.vue
index c5bc335..4ab0abc 100644
--- a/apps/ui/src/shell/AppShell.vue
+++ b/apps/ui/src/shell/AppShell.vue
@@ -173,10 +173,8 @@ function startSidebarResize(e: PointerEvent): void {
     <!-- Global trace-id popout: any page can call 
useTracePopout().openTrace(id)
          and this modal renders the waterfall + span detail. -->
     <TracePopout />
-    <!-- Zipkin trace popout — separate URL key (`?openZipkinTraceId=`)
-         so the native + Zipkin popouts can be open in parallel without
-         collision (e.g. an operator drilling into a Zipkin trace from
-         a Logs row → trace link on a mesh layer). -->
+    <!-- Zipkin trace popout — shares `?traceId=`; native vs Zipkin
+         self-select by ID shape (see isZipkinTraceId). -->
     <ZipkinTracePopout />
     <!-- Per-session prompt: when local template edits diverge from OAP,
          ask once which version to render (local preview vs remote live). -->

Reply via email to