ui: Add QuantizedSlices plugin

Port SwiPerf trace comparison tool as a Perfetto plugin. Supports
trace import, quantized slice visualization, clustering, cross-trace
comparison, and CSV export.

Change-Id: Iacd11a9f85bdae21f06362807799d0c16443d2bf
diff --git a/ui/src/core/default_plugins.ts b/ui/src/core/default_plugins.ts
index 9cef762..d31eba4 100644
--- a/ui/src/core/default_plugins.ts
+++ b/ui/src/core/default_plugins.ts
@@ -33,6 +33,7 @@
   'com.android.AndroidStartup',
   'com.android.Bluetooth',
   'com.android.ContainedTraces',
+  'com.google.QuantizedSlices',
   'com.android.CpuPerUid',
   'com.android.CujFrameDebugTrack',
   'com.android.DayExplorer',
diff --git a/ui/src/plugins/com.google.QuantizedSlices/components/cluster_tabs.ts b/ui/src/plugins/com.google.QuantizedSlices/components/cluster_tabs.ts
new file mode 100644
index 0000000..22ef9b1
--- /dev/null
+++ b/ui/src/plugins/com.google.QuantizedSlices/components/cluster_tabs.ts
@@ -0,0 +1,72 @@
+// Copyright (C) 2026 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import m from 'mithril';
+import {Tabs} from '../../../widgets/tabs';
+import type {TabsTab} from '../../../widgets/tabs';
+import {
+  S,
+  activeCluster,
+  addCluster,
+  removeCluster,
+  renameCluster,
+  switchCluster,
+} from '../state';
+import type {Cluster} from '../state';
+
+function renderTabTitle(cl: Cluster): string {
+  const count = cl.traces.length;
+  return count > 1 ? `${cl.name} (${count})` : cl.name;
+}
+
+export interface ClusterTabsAttrs {
+  // Content to render for the active cluster tab.
+  readonly contentForCluster?: (cl: Cluster) => m.Children;
+}
+
+export class ClusterTabs implements m.ClassComponent<ClusterTabsAttrs> {
+  view({attrs}: m.CVnode<ClusterTabsAttrs>): m.Children {
+    if (S.clusters.length === 0) return null;
+
+    const cl = activeCluster();
+    const contentFn = attrs.contentForCluster;
+
+    const tabs: TabsTab[] = S.clusters.map((c) => ({
+      key: c.id,
+      title: renderTabTitle(c),
+      content: contentFn && c.id === cl?.id ? contentFn(c) : null,
+      closeButton: true,
+    }));
+
+    return m(Tabs, {
+      className: 'qs-cluster-tabs',
+      tabs,
+      activeTabKey: S.activeClusterId ?? undefined,
+      onTabChange: (key: string) => {
+        switchCluster(key);
+      },
+      onTabClose: (key: string) => {
+        removeCluster(key);
+      },
+      onTabRename: (key: string, newTitle: string) => {
+        renameCluster(key, newTitle);
+      },
+    });
+  }
+}
+
+/** Helper: create a new empty cluster (for an "add tab" button). */
+export function addEmptyCluster(name?: string): void {
+  addCluster(name ?? 'New cluster', []);
+}
diff --git a/ui/src/plugins/com.google.QuantizedSlices/components/cross_compare_modal.ts b/ui/src/plugins/com.google.QuantizedSlices/components/cross_compare_modal.ts
new file mode 100644
index 0000000..adc618f
--- /dev/null
+++ b/ui/src/plugins/com.google.QuantizedSlices/components/cross_compare_modal.ts
@@ -0,0 +1,660 @@
+// Copyright (C) 2026 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import m from 'mithril';
+import {Button, ButtonVariant} from '../../../widgets/button';
+import {Intent} from '../../../widgets/common';
+import type {Cluster, TraceState} from '../state';
+import {
+  getCrossCompareState,
+  closeCrossCompare,
+  recordCrossComparison,
+  applyCrossCompareResults,
+  resetCrossCompare,
+  ensureCache,
+  updateSlider,
+  undoCrossComparison,
+  discardCrossCompareTrace,
+  skipCrossComparison,
+} from '../state';
+import {getProgress, getResults} from '../models/cross_compare';
+import type {CrossCompareState} from '../models/cross_compare';
+import {MiniTimeline} from './mini_timeline';
+import {SummaryTables} from './summary_tables';
+import {fmtDur} from '../utils/format';
+import {buildTraceLink} from '../utils/export';
+import type {SortState} from '../models/types';
+
+// -- Module-level state --
+
+let keyHandler: ((e: KeyboardEvent) => void) | null = null;
+let ccSliderPct = 100;
+let lastPairKey: string | null = null;
+
+// Anchor mode: when set, this trace stays on screen and others rotate against it
+let anchorKey: string | null = null;
+let anchorSide: 'left' | 'right' | null = null;
+
+// Review screen state
+let reviewPairIdx = 0;
+
+// Sort state for summary tables inside panels
+const panelSortState: Record<string, SortState> = {};
+
+// -- Slider helpers --
+
+function updateBothSliders(cl: Cluster, pct: number): void {
+  ccSliderPct = pct;
+  const state = getCrossCompareState();
+  if (!state?.currentPair) return;
+  const frac = pct / 100;
+  for (const key of state.currentPair) {
+    const ts = findTrace(cl, key);
+    if (!ts) continue;
+    ensureCache(ts);
+    const target = Math.max(2, Math.round(2 + (ts.origN - 2) * frac));
+    updateSlider(ts, target);
+  }
+}
+
+// -- Lookup maps (rebuilt once per render cycle) --
+
+let traceMap: Map<string, TraceState> | null = null;
+let indexMap: Map<string, number> | null = null;
+let mapClusterId: string | null = null;
+
+function ensureMaps(cl: Cluster): void {
+  if (mapClusterId === cl.id && traceMap) return;
+  traceMap = new Map();
+  indexMap = new Map();
+  cl.traces.forEach((ts, i) => {
+    traceMap!.set(ts._key, ts);
+    indexMap!.set(ts._key, i);
+  });
+  mapClusterId = cl.id;
+}
+
+function findTrace(cl: Cluster, key: string): TraceState | undefined {
+  ensureMaps(cl);
+  return traceMap!.get(key);
+}
+
+function traceIndex(cl: Cluster, key: string): number {
+  ensureMaps(cl);
+  return indexMap!.get(key) ?? -1;
+}
+
+// -- Anchor helpers --
+
+function toggleAnchor(key: string, side: 'left' | 'right'): void {
+  if (anchorKey === key && anchorSide === side) {
+    anchorKey = null;
+    anchorSide = null;
+  } else {
+    anchorKey = key;
+    anchorSide = side;
+  }
+  const state = getCrossCompareState();
+  if (state) state.selectedSide = null;
+  m.redraw();
+}
+
+function clearAnchor(): void {
+  anchorKey = null;
+  anchorSide = null;
+}
+
+/** After advancing, ensure anchor stays on the correct side of currentPair. */
+function ensureAnchorSide(): void {
+  const state = getCrossCompareState();
+  if (!anchorKey || !state?.currentPair) return;
+  // Clear anchor if anchor trace was discarded
+  if (state.discardedKeys.has(anchorKey)) {
+    clearAnchor();
+    return;
+  }
+  const [a, b] = state.currentPair;
+  // Only swap if anchor is actually in this pair
+  if (anchorSide === 'left' && b === anchorKey && a !== anchorKey) {
+    state.currentPair = [b, a];
+  } else if (anchorSide === 'right' && a === anchorKey && b !== anchorKey) {
+    state.currentPair = [b, a];
+  }
+}
+
+/** Whether the anchor trace is in the current pair. */
+function anchorActive(state: CrossCompareState | null): boolean {
+  return (
+    !!anchorKey && !!state?.currentPair && state.currentPair.includes(anchorKey)
+  );
+}
+
+function discardSideForAnchor(): 'left' | 'right' | null {
+  if (anchorSide === 'left') return 'right';
+  if (anchorSide === 'right') return 'left';
+  return null;
+}
+
+// -- Panel rendering --
+
+function renderPanel(
+  cl: Cluster,
+  key: string,
+  side: 'left' | 'right',
+): m.Children {
+  const ts = findTrace(cl, key);
+  if (!ts) return m('.qs-cc-panel', 'Trace not found');
+  ensureCache(ts);
+  const isAnchor = anchorKey === key;
+  const state = getCrossCompareState();
+  const aa = anchorActive(state);
+  const isSelected = !aa && state?.selectedSide === side;
+
+  const panelClass =
+    '.qs-cc-panel' +
+    (isAnchor ? '.qs-cc-anchored' : isSelected ? '.qs-cc-selected' : '');
+
+  return m(
+    panelClass,
+    {
+      onclick: (e: Event) => {
+        e.stopPropagation();
+        toggleAnchor(key, side);
+      },
+    },
+    [
+      m('.qs-cc-panel-header', [
+        m('span.qs-cc-panel-idx', `#${traceIndex(cl, key) + 1}`),
+        m('span.qs-cc-panel-pkg', ts.trace.package_name),
+        ts.trace.startup_dur
+          ? m('span.qs-cc-panel-dur', fmtDur(ts.trace.startup_dur))
+          : null,
+        (() => {
+          const href = buildTraceLink(
+            ts.trace.trace_uuid,
+            ts.trace.package_name,
+          );
+          return href
+            ? m(
+                'a.qs-cc-trace-link',
+                {
+                  href,
+                  target: '_blank',
+                  rel: 'noopener',
+                  onclick: (e: Event) => e.stopPropagation(),
+                  title: 'Open in trace viewer',
+                },
+                '\u2197',
+              )
+            : null;
+        })(),
+        isAnchor ? m('span.qs-cc-anchor-badge', 'anchor') : null,
+      ]),
+      m(MiniTimeline, {ts}),
+      m(
+        '.qs-cc-panel-detail',
+        m(SummaryTables, {
+          ts,
+          sortState: panelSortState,
+        }),
+      ),
+    ],
+  );
+}
+
+function renderReviewTraceRow(cl: Cluster, key: string): m.Children {
+  const ts = findTrace(cl, key);
+  if (!ts) return null;
+  ensureCache(ts);
+  const href = buildTraceLink(ts.trace.trace_uuid, ts.trace.package_name);
+
+  return m('.qs-cc-review-row', [
+    m('.qs-cc-review-row-header', [
+      m('span.qs-cc-panel-idx', `#${traceIndex(cl, key) + 1}`),
+      m('span.qs-cc-panel-pkg', ts.trace.package_name),
+      ts.trace.startup_dur
+        ? m('span.qs-cc-panel-dur', fmtDur(ts.trace.startup_dur))
+        : null,
+      href
+        ? m(
+            'a.qs-cc-trace-link',
+            {
+              href,
+              target: '_blank',
+              rel: 'noopener',
+              title: 'Open in trace viewer',
+            },
+            '\u2197',
+          )
+        : null,
+    ]),
+    m(MiniTimeline, {ts}),
+  ]);
+}
+
+// -- Review screen --
+
+function buildPairings(n: number): Array<[number, number]> {
+  if (n < 2) return [[0, -1]];
+  const pairs: Array<[number, number]> = [];
+  for (let p = 0; p < n; p++) {
+    for (let neg = 0; neg < n; neg++) {
+      if (neg !== p) pairs.push([p, neg]);
+    }
+  }
+  return pairs;
+}
+
+function cycleReview(delta: number, groupCount: number): void {
+  const pairings = buildPairings(groupCount);
+  if (pairings.length <= 1) return;
+  reviewPairIdx =
+    (((reviewPairIdx + delta) % pairings.length) + pairings.length) %
+    pairings.length;
+  m.redraw();
+}
+
+function renderReview(cl: Cluster): m.Children {
+  const state = getCrossCompareState();
+  if (!state) return null;
+  const {groups, discarded} = getResults(state);
+
+  // Pure anchor: anchor's group vs all others combined. Two groups, no cycling.
+  const pureAnchor =
+    anchorKey !== null && groups.some((g) => g.includes(anchorKey!));
+  let positiveGroup: string[];
+  let negativeGroup: string[];
+  let pairings: Array<[number, number]>;
+  let posIdx: number;
+  let negIdx: number;
+
+  if (pureAnchor) {
+    const ai = groups.findIndex((g) => g.includes(anchorKey!));
+    positiveGroup = groups[ai];
+    negativeGroup = groups.flatMap((g, i) => (i === ai ? [] : g));
+    posIdx = ai;
+    negIdx = -1;
+    pairings = [[posIdx, negIdx]];
+  } else {
+    pairings = buildPairings(groups.length);
+    if (reviewPairIdx >= pairings.length) reviewPairIdx = 0;
+    [posIdx, negIdx] = pairings[reviewPairIdx];
+    positiveGroup = groups[posIdx] ?? [];
+    negativeGroup = negIdx >= 0 ? groups[negIdx] ?? [] : [];
+  }
+
+  return m('.qs-cc-review', [
+    m('.qs-cc-review-split', [
+      m('.qs-cc-review-panel', [
+        m('.qs-cc-review-panel-header.qs-cc-negative', [
+          m('span', 'Negative'),
+          m('span.qs-cc-review-count', `${negativeGroup.length}`),
+        ]),
+        m(
+          '.qs-cc-review-panel-body',
+          negativeGroup.map((key) => renderReviewTraceRow(cl, key)),
+        ),
+      ]),
+      m('.qs-cc-review-panel', [
+        m('.qs-cc-review-panel-header.qs-cc-positive', [
+          m('span', 'Positive'),
+          m('span.qs-cc-review-count', `${positiveGroup.length}`),
+        ]),
+        m(
+          '.qs-cc-review-panel-body',
+          positiveGroup.map((key) => renderReviewTraceRow(cl, key)),
+        ),
+      ]),
+    ]),
+    m('.qs-cc-review-nav', [
+      pairings.length > 1
+        ? m(
+            'span.qs-cc-hint',
+            `Pairing ${reviewPairIdx + 1} / ${pairings.length}`,
+          )
+        : null,
+      discarded.length > 0
+        ? m('span.qs-cc-hint', `${discarded.length} discarded`)
+        : null,
+    ]),
+    pairings.length > 1
+      ? m('.qs-cc-hint', '\u2190 \u2192 to cycle pairings')
+      : null,
+    m('.qs-cc-footer', [
+      m(Button, {
+        label: 'Apply',
+        intent: Intent.Success,
+        variant: ButtonVariant.Filled,
+        onclick: () => applyCrossCompareResults(cl, posIdx, negIdx),
+      }),
+      m(Button, {
+        label: 'Undo',
+        variant: ButtonVariant.Outlined,
+        disabled: state.history.length === 0,
+        onclick: () => {
+          undoCrossComparison(anchorKey ?? undefined);
+          ensureAnchorSide();
+        },
+      }),
+      m(Button, {
+        label: 'Reset',
+        variant: ButtonVariant.Outlined,
+        onclick: () => {
+          resetCrossCompare(cl);
+          clearAnchor();
+        },
+      }),
+      m(Button, {
+        label: 'Close',
+        variant: ButtonVariant.Outlined,
+        onclick: closeCrossCompare,
+      }),
+    ]),
+  ]);
+}
+
+// -- Main component --
+
+export const CrossCompareModal: m.Component<{cl: Cluster}> = {
+  oncreate(vnode: m.VnodeDOM<{cl: Cluster}>) {
+    const getCl = () => vnode.attrs.cl;
+    keyHandler = (e: KeyboardEvent) => {
+      const target = e.target;
+      if (target instanceof HTMLElement) {
+        const tag = target.tagName;
+        if (tag === 'INPUT' || tag === 'TEXTAREA') return;
+      }
+      const state = getCrossCompareState();
+      if (!state) return;
+
+      if (e.key === 'Escape') {
+        closeCrossCompare();
+        return;
+      }
+      if (e.key === 'z' && (e.ctrlKey || e.metaKey)) {
+        e.preventDefault();
+        undoCrossComparison(anchorKey ?? undefined);
+        ensureAnchorSide();
+        return;
+      }
+
+      if (state.isComplete) {
+        const {groups} = getResults(state);
+        if (e.key === 'ArrowLeft') {
+          cycleReview(-1, groups.length);
+          return;
+        }
+        if (e.key === 'ArrowRight') {
+          cycleReview(1, groups.length);
+          return;
+        }
+        return;
+      }
+
+      // Comparison keys -- pass anchorKey so advancePair tries anchor-first
+      const ak = anchorKey ?? undefined;
+      const aa = anchorActive(state);
+
+      if (e.key === 'p' || e.key === 'P') {
+        recordCrossComparison('positive', ak);
+        ensureAnchorSide();
+        return;
+      }
+      if (e.key === 'n' || e.key === 'N') {
+        recordCrossComparison('negative', ak);
+        ensureAnchorSide();
+        return;
+      }
+      if (e.key === 's' || e.key === 'S') {
+        skipCrossComparison(ak);
+        ensureAnchorSide();
+        return;
+      }
+      if (e.key === 'd' || e.key === 'D') {
+        const side = aa ? discardSideForAnchor() : state.selectedSide;
+        if (side) {
+          discardCrossCompareTrace(getCl(), side, ak);
+          ensureAnchorSide();
+        }
+        return;
+      }
+
+      // Arrow keys: select side when anchor not active in current pair
+      if (!aa) {
+        if (e.key === 'ArrowLeft') {
+          state.selectedSide = 'left';
+          m.redraw();
+          return;
+        }
+        if (e.key === 'ArrowRight') {
+          state.selectedSide = 'right';
+          m.redraw();
+          return;
+        }
+      }
+    };
+    document.addEventListener('keydown', keyHandler);
+  },
+
+  onremove() {
+    if (keyHandler) {
+      document.removeEventListener('keydown', keyHandler);
+      keyHandler = null;
+    }
+  },
+
+  onupdate(vnode: m.VnodeDOM<{cl: Cluster}>) {
+    const {cl} = vnode.attrs;
+    const state = getCrossCompareState();
+    if (!state) return;
+
+    // Apply slider to new pairs when they change.
+    const pairKey = state.currentPair
+      ? state.currentPair[0] + '|' + state.currentPair[1]
+      : null;
+    if (pairKey && pairKey !== lastPairKey) {
+      lastPairKey = pairKey;
+      if (ccSliderPct < 100) updateBothSliders(cl, ccSliderPct);
+    }
+
+    // Clear anchor if discarded.
+    if (anchorKey && state.discardedKeys.has(anchorKey)) clearAnchor();
+  },
+
+  view(vnode: m.Vnode<{cl: Cluster}>) {
+    const {cl} = vnode.attrs;
+    const state = getCrossCompareState();
+    if (!state) return null;
+
+    const progress = getProgress(state);
+
+    const active = anchorActive(state);
+    const canDiscard = active || !!state.selectedSide;
+
+    return m(
+      '.qs-cc-overlay',
+      {
+        onclick: () => {
+          if (anchorKey) {
+            clearAnchor();
+            m.redraw();
+          } else if (state.selectedSide) {
+            state.selectedSide = null;
+            m.redraw();
+          } else {
+            closeCrossCompare();
+          }
+        },
+      },
+      [
+        m(
+          '.qs-cc-modal',
+          {
+            onclick: (e: Event) => {
+              e.stopPropagation();
+            },
+          },
+          [
+            // Header
+            m('.qs-cc-header', [
+              m('span.qs-cc-title', 'Compare'),
+              m(Button, {
+                label: '\u00d7',
+                variant: ButtonVariant.Minimal,
+                compact: true,
+                onclick: closeCrossCompare,
+                title: 'Close (Esc)',
+                className: 'qs-cc-close',
+              }),
+            ]),
+
+            // Progress bar
+            m('.qs-cc-progress', [
+              m(
+                '.qs-cc-progress-text',
+                `${progress.completed} / ${progress.total} pairs resolved (${progress.pct}%)`,
+              ),
+              m('.qs-cc-progress-bar', [
+                m('.qs-cc-progress-fill', {
+                  style: {width: progress.pct + '%'},
+                }),
+              ]),
+            ]),
+
+            // Body
+            state.isComplete
+              ? renderReview(cl)
+              : state.currentPair
+                ? m('.qs-cc-body', [
+                    m('.qs-cc-pair', [
+                      renderPanel(cl, state.currentPair[0], 'left'),
+                      m('.qs-cc-pair-divider', 'vs'),
+                      renderPanel(cl, state.currentPair[1], 'right'),
+                    ]),
+                    m('.qs-cc-slider', [
+                      m('span.qs-cc-slider-label', 'Detail'),
+                      m('span.qs-cc-slider-num', ccSliderPct + '%'),
+                      m('input[type=range]', {
+                        min: 1,
+                        max: 100,
+                        value: ccSliderPct,
+                        step: 1,
+                        oninput: (e: Event) => {
+                          const el = e.target as HTMLInputElement;
+                          updateBothSliders(cl, +el.value);
+                        },
+                        onchange: (e: Event) => {
+                          (e.target as HTMLElement).blur();
+                        },
+                      }),
+                    ]),
+                    m('.qs-cc-actions', [
+                      m(Button, {
+                        label: 'Positive (P)',
+                        intent: Intent.Success,
+                        variant: ButtonVariant.Filled,
+                        onclick: () => {
+                          recordCrossComparison(
+                            'positive',
+                            anchorKey ?? undefined,
+                          );
+                          ensureAnchorSide();
+                        },
+                      }),
+                      m(Button, {
+                        label: 'Negative (N)',
+                        intent: Intent.Danger,
+                        variant: ButtonVariant.Filled,
+                        onclick: () => {
+                          recordCrossComparison(
+                            'negative',
+                            anchorKey ?? undefined,
+                          );
+                          ensureAnchorSide();
+                        },
+                      }),
+                      m(Button, {
+                        label: 'Skip (S)',
+                        variant: ButtonVariant.Outlined,
+                        onclick: () => {
+                          skipCrossComparison(anchorKey ?? undefined);
+                          ensureAnchorSide();
+                        },
+                      }),
+                      m(Button, {
+                        label: 'Discard (D)',
+                        intent: Intent.Warning,
+                        variant: ButtonVariant.Outlined,
+                        disabled: !canDiscard,
+                        title: active
+                          ? 'Discard the non-anchor trace'
+                          : state.selectedSide
+                            ? 'Discard selected trace'
+                            : 'Click a panel to anchor, or \u2190\u2192 to select',
+                        onclick: () => {
+                          const side = active
+                            ? discardSideForAnchor()
+                            : state.selectedSide;
+                          if (side) {
+                            discardCrossCompareTrace(
+                              cl,
+                              side,
+                              anchorKey ?? undefined,
+                            );
+                            ensureAnchorSide();
+                          }
+                        },
+                      }),
+                      m(Button, {
+                        label: 'Undo (\u2318Z)',
+                        variant: ButtonVariant.Outlined,
+                        disabled: state.history.length === 0,
+                        title: 'Undo (Ctrl+Z)',
+                        onclick: () => {
+                          undoCrossComparison(anchorKey ?? undefined);
+                          ensureAnchorSide();
+                        },
+                      }),
+                    ]),
+                    m(
+                      '.qs-cc-hint',
+                      active
+                        ? 'Anchored \u00b7 P/N/S/D apply to other trace \u00b7 click anchor to deselect'
+                        : anchorKey
+                          ? 'Anchor set (not in this pair) \u00b7 \u2190\u2192 to select \u00b7 click panel to re-anchor'
+                          : 'Click a panel to anchor \u00b7 \u2190\u2192 to select for discard \u00b7 Esc close',
+                    ),
+                    m('.qs-cc-footer', [
+                      m(Button, {
+                        label: 'Apply Current Results',
+                        variant: ButtonVariant.Outlined,
+                        onclick: () => applyCrossCompareResults(cl),
+                      }),
+                      m(Button, {
+                        label: 'Reset',
+                        variant: ButtonVariant.Outlined,
+                        onclick: () => {
+                          resetCrossCompare(cl);
+                          clearAnchor();
+                        },
+                      }),
+                    ]),
+                  ])
+                : null,
+          ],
+        ),
+      ],
+    );
+  },
+};
diff --git a/ui/src/plugins/com.google.QuantizedSlices/components/import_panel.ts b/ui/src/plugins/com.google.QuantizedSlices/components/import_panel.ts
new file mode 100644
index 0000000..d30cbf9
--- /dev/null
+++ b/ui/src/plugins/com.google.QuantizedSlices/components/import_panel.ts
@@ -0,0 +1,313 @@
+// Copyright (C) 2026 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import m from 'mithril';
+import {Button, ButtonVariant} from '../../../widgets/button';
+import {Spinner} from '../../../widgets/spinner';
+import {parseText} from '../parse';
+import {
+  S,
+  activeCluster,
+  addCluster,
+  loadSingleJson,
+  loadMultipleTraces,
+  exportSession,
+  importSessionDataAsync,
+} from '../state';
+import type {TraceState} from '../state';
+import type {TraceEntry} from '../models/types';
+
+let debounceTimer: ReturnType<typeof setTimeout> | null = null;
+
+function applyParsedTraces(traces: TraceEntry[], clusterName: string): void {
+  if (traces.length === 0) {
+    S.importMsg = {text: 'No valid traces found', ok: false};
+    return;
+  }
+  if (
+    traces.length === 1 &&
+    traces[0].package_name === 'unknown' &&
+    traces[0].startup_dur === 0
+  ) {
+    loadSingleJson(traces[0].slices);
+    S.importMsg = {text: `Loaded ${traces[0].slices.length} slices`, ok: true};
+  } else {
+    loadMultipleTraces(clusterName, traces);
+    S.importMsg = {text: `Loaded ${traces.length} traces`, ok: true};
+  }
+}
+
+function handleTextInput(text: string, clusterName: string): void {
+  text = text.trim();
+  if (!text) return;
+  try {
+    applyParsedTraces(parseText(text), clusterName);
+  } catch (err: unknown) {
+    const message = err instanceof Error ? err.message : String(err);
+    S.importMsg = {text: message, ok: false};
+  }
+  m.redraw();
+}
+
+function readFileAsText(file: File): Promise<string> {
+  return new Promise((resolve, reject) => {
+    const reader = new FileReader();
+    reader.onload = () => resolve(reader.result as string);
+    reader.onerror = () => reject(new Error(`Failed to read ${file.name}`));
+    reader.readAsText(file);
+  });
+}
+
+async function loadFromFile(e: Event): Promise<void> {
+  const input = e.target as HTMLInputElement;
+  if (input.files === null || input.files.length === 0) return;
+  const fileList = Array.from(input.files).filter((f) =>
+    /\.(json|txt|tsv|csv)$/i.test(f.name),
+  );
+  input.value = '';
+  if (fileList.length === 0) {
+    S.importMsg = {text: 'No supported files', ok: false};
+    m.redraw();
+    return;
+  }
+
+  S.loadProgress = {
+    message: `Reading ${fileList.length} file${fileList.length > 1 ? 's' : ''}...`,
+  };
+  m.redraw();
+
+  try {
+    let totalTraces = 0;
+    let totalFiles = 0;
+    for (let i = 0; i < fileList.length; i++) {
+      S.loadProgress = {
+        message: `Reading file ${i + 1}/${fileList.length}...`,
+        pct: (i / fileList.length) * 100,
+      };
+      m.redraw();
+      const content = await readFileAsText(fileList[i]);
+      const name = fileList[i].name.replace(/\.\w+$/, '');
+      const traces = parseText(content);
+      if (traces.length > 0) {
+        addCluster(name, traces);
+        totalTraces += traces.length;
+        totalFiles++;
+      }
+    }
+
+    S.importMsg =
+      totalTraces > 0
+        ? {
+            text: `Loaded ${totalTraces} traces from ${totalFiles} file${totalFiles > 1 ? 's' : ''}`,
+            ok: true,
+          }
+        : {text: 'No valid traces found', ok: false};
+  } catch (err: unknown) {
+    const message = err instanceof Error ? err.message : String(err);
+    S.importMsg = {text: message, ok: false};
+  }
+
+  S.loadProgress = null;
+  m.redraw();
+}
+
+function saveSession(): void {
+  const json = exportSession();
+  const blob = new Blob([json], {type: 'application/json'});
+  const url = URL.createObjectURL(blob);
+  const a = document.createElement('a');
+  a.href = url;
+  const date = new Date().toISOString().slice(0, 10);
+  a.download = `qs-session-${date}.json`;
+  a.click();
+  // Delay revoke to allow the browser to start the download.
+  setTimeout(() => URL.revokeObjectURL(url), 60_000);
+  S.importMsg = {text: 'Session saved', ok: true};
+  m.redraw();
+}
+
+async function loadSession(e: Event): Promise<void> {
+  const input = e.target as HTMLInputElement;
+  const file = input.files?.[0];
+  if (!file) return;
+  input.value = '';
+
+  S.loadProgress = {message: 'Reading session file...'};
+  m.redraw();
+
+  try {
+    const json = await readFileAsText(file);
+
+    S.loadProgress = {message: 'Parsing session...'};
+    m.redraw();
+
+    const data = JSON.parse(json);
+    if (data.version !== 1) throw new Error('Unknown session version');
+
+    await importSessionDataAsync(data, (msg, pct) => {
+      S.loadProgress = {message: msg, pct};
+      m.redraw();
+    });
+    S.importMsg = {
+      text: `Session restored (${S.clusters.length} clusters)`,
+      ok: true,
+    };
+  } catch (err: unknown) {
+    const message = err instanceof Error ? err.message : String(err);
+    S.importMsg = {text: `Session load failed: ${message}`, ok: false};
+  }
+
+  S.loadProgress = null;
+  m.redraw();
+}
+
+// Unique IDs for hidden file inputs (avoids global getElementById collisions).
+const FILE_INPUT_ID = 'qs-file-input';
+const SESSION_INPUT_ID = 'qs-session-input';
+
+export class ImportPanel implements m.ClassComponent {
+  view(): m.Children {
+    const loading = S.loadProgress !== null;
+
+    return m('.qs-import', [
+      m('.qs-import-hint', [
+        'Paste or import data. Supports: ',
+        m('code', '[{ts, dur, state}]'),
+        ' slices, ',
+        m('code', '{trace_uuid, slices}'),
+        ' traces, or TSV/CSV with a ',
+        m('code', 'slices/json/data/base64'),
+        ' column.',
+      ]),
+
+      m('textarea.qs-json-area', {
+        placeholder:
+          'Paste JSON / TSV / CSV \u2014 creates a new cluster tab\u2026',
+        spellcheck: false,
+        disabled: loading,
+        rows: 4,
+        onpaste: (e: ClipboardEvent) => {
+          const text = e.clipboardData?.getData('text/plain');
+          if (!text?.trim()) return;
+          e.preventDefault();
+          if (debounceTimer) clearTimeout(debounceTimer);
+          debounceTimer = setTimeout(() => {
+            handleTextInput(text, 'Paste');
+          }, 50);
+        },
+        oninput: (e: Event) => {
+          if (debounceTimer) clearTimeout(debounceTimer);
+          debounceTimer = setTimeout(() => {
+            const el = e.target as HTMLTextAreaElement;
+            if (el.value.trim()) {
+              const text = el.value;
+              el.value = '';
+              handleTextInput(text, 'Paste');
+            }
+          }, 600);
+        },
+      }),
+
+      // Loading indicator
+      loading
+        ? m('.qs-load-progress', [
+            m(Spinner),
+            m('span.qs-progress-text', S.loadProgress!.message),
+          ])
+        : null,
+
+      m('.qs-import-actions', [
+        m(Button, {
+          label: 'Import files\u2026',
+          variant: ButtonVariant.Outlined,
+          disabled: loading,
+          onclick: () => {
+            const el = document.getElementById(FILE_INPUT_ID);
+            if (el) (el as HTMLInputElement).click();
+          },
+        }),
+        m('input', {
+          id: FILE_INPUT_ID,
+          type: 'file',
+          accept: '.json,.txt,.tsv,.csv',
+          multiple: true,
+          style: {display: 'none'},
+          onchange: loadFromFile,
+        }),
+
+        S.clusters.length > 0
+          ? m(Button, {
+              label: 'Save session',
+              variant: ButtonVariant.Outlined,
+              disabled: loading,
+              onclick: saveSession,
+            })
+          : null,
+
+        m(Button, {
+          label: 'Load session',
+          variant: ButtonVariant.Outlined,
+          disabled: loading,
+          onclick: () => {
+            const el = document.getElementById(SESSION_INPUT_ID);
+            if (el) (el as HTMLInputElement).click();
+          },
+        }),
+        m('input', {
+          id: SESSION_INPUT_ID,
+          type: 'file',
+          accept: '.json',
+          style: {display: 'none'},
+          onchange: loadSession,
+        }),
+
+        activeCluster()
+          ? m(Button, {
+              label: 'Copy compressed',
+              variant: ButtonVariant.Outlined,
+              onclick: () => {
+                const cl = activeCluster();
+                if (!cl) return;
+                const ts = cl.traces[0] as TraceState | undefined;
+                if (ts === undefined) return;
+                const clean = ts.currentSeq.map((s) => ({
+                  ts: s.ts,
+                  dur: s.dur,
+                  name: s.name,
+                  state: s.state,
+                  depth: s.depth,
+
+                  io_wait: s.io_wait,
+
+                  blocked_function: s.blocked_function,
+                  _merged: s._merged,
+                }));
+                navigator.clipboard.writeText(JSON.stringify(clean, null, 2));
+                S.importMsg = {text: 'Copied to clipboard', ok: true};
+                m.redraw();
+              },
+            })
+          : null,
+
+        // Status message
+        !loading && S.importMsg
+          ? m(
+              `span.${S.importMsg.ok ? 'qs-msg-ok' : 'qs-msg-err'}`,
+              S.importMsg.text,
+            )
+          : null,
+      ]),
+    ]);
+  }
+}
diff --git a/ui/src/plugins/com.google.QuantizedSlices/components/mini_timeline.ts b/ui/src/plugins/com.google.QuantizedSlices/components/mini_timeline.ts
new file mode 100644
index 0000000..a6da3e3
--- /dev/null
+++ b/ui/src/plugins/com.google.QuantizedSlices/components/mini_timeline.ts
@@ -0,0 +1,198 @@
+// Copyright (C) 2026 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import m from 'mithril';
+import {MergedSlice} from '../models/types';
+import {renderMiniCanvas, showTooltip, hideTooltip} from './timeline_canvas';
+import type {HitRect} from './timeline_canvas';
+
+/**
+ * Minimal interface for the trace-level state that MiniTimeline needs.
+ * Avoids a direct import of the plugin's global state module, preventing
+ * circular dependencies.
+ */
+export interface MiniTimelineTrace {
+  currentSeq: MergedSlice[];
+  totalDur: number;
+}
+
+interface MiniTimelineAttrs {
+  ts: MiniTimelineTrace;
+  /** Optional callback invoked before rendering to ensure caches are warm. */
+  ensureCache?: (ts: MiniTimelineTrace) => void;
+}
+
+// Store hit rects + trace state per canvas — survives across lifecycle hooks.
+const canvasHits = new WeakMap<
+  HTMLCanvasElement,
+  {hits: HitRect[]; totalDur: number; ts: MiniTimelineTrace}
+>();
+const canvasHover = new WeakMap<HTMLCanvasElement, number | undefined>();
+type CanvasListeners = {
+  move: (e: MouseEvent) => void;
+  leave: (e: MouseEvent) => void;
+};
+const canvasListeners = new WeakMap<HTMLCanvasElement, CanvasListeners>();
+
+// Viewport-gated rendering via IntersectionObserver.
+const isVisible = new WeakMap<Element, boolean>();
+const needsRender = new WeakSet<Element>();
+
+let observer: IntersectionObserver | null = null;
+let observerRefCount = 0;
+const hasIO = typeof IntersectionObserver !== 'undefined';
+
+function getObserver(): IntersectionObserver | null {
+  if (!hasIO) return null;
+  if (!observer) {
+    observer = new IntersectionObserver(
+      (entries) => {
+        let anyBecameVisible = false;
+        for (const entry of entries) {
+          const wasVisible = isVisible.get(entry.target) ?? false;
+          isVisible.set(entry.target, entry.isIntersecting);
+          if (
+            entry.isIntersecting &&
+            !wasVisible &&
+            needsRender.has(entry.target)
+          ) {
+            needsRender.delete(entry.target);
+            anyBecameVisible = true;
+          }
+        }
+        if (anyBecameVisible) m.redraw();
+      },
+      {rootMargin: '200px 0px'},
+    );
+  }
+  observerRefCount++;
+  return observer;
+}
+
+function releaseObserver(): void {
+  if (!hasIO) return;
+  observerRefCount--;
+  if (observerRefCount <= 0 && observer) {
+    observer.disconnect();
+    observer = null;
+    observerRefCount = 0;
+  }
+}
+
+function doRender(
+  dom: Element,
+  ts: MiniTimelineTrace,
+  ensureCache?: (ts: MiniTimelineTrace) => void,
+): void {
+  if (hasIO && !isVisible.get(dom)) {
+    needsRender.add(dom);
+    return;
+  }
+  needsRender.delete(dom);
+  const canvas = dom.querySelector('canvas');
+  if (!canvas) return;
+  if (ensureCache) ensureCache(ts);
+  const hits = renderMiniCanvas(canvas, {
+    seq: ts.currentSeq,
+    totalDur: ts.totalDur,
+    highlightIdx: canvasHover.get(canvas),
+  });
+  canvasHits.set(canvas, {hits, totalDur: ts.totalDur, ts});
+}
+
+export const MiniTimeline: m.Component<MiniTimelineAttrs> = {
+  oncreate(vnode: m.VnodeDOM<MiniTimelineAttrs>) {
+    const obs = getObserver();
+    if (obs) {
+      needsRender.add(vnode.dom);
+      obs.observe(vnode.dom);
+    } else {
+      doRender(vnode.dom, vnode.attrs.ts, vnode.attrs.ensureCache);
+    }
+
+    const canvas = vnode.dom.querySelector('canvas');
+    if (!canvas) return;
+
+    const onMove = (e: MouseEvent): void => {
+      const cvs = e.target as HTMLCanvasElement;
+      const data = canvasHits.get(cvs);
+      if (!data) return;
+      showTooltip(e, data.hits, data.totalDur);
+
+      // Find hovered segment index and re-render with highlight.
+      const rect = cvs.getBoundingClientRect();
+      const mx = e.clientX - rect.left;
+      const my = e.clientY - rect.top;
+      let hitIdx: number | undefined;
+      for (let i = data.hits.length - 1; i >= 0; i--) {
+        const r = data.hits[i];
+        if (mx >= r.x && mx <= r.x + r.w && my >= r.y && my <= r.y + r.h) {
+          hitIdx = i;
+          break;
+        }
+      }
+      if (hitIdx !== canvasHover.get(cvs)) {
+        canvasHover.set(cvs, hitIdx);
+        const hits = renderMiniCanvas(cvs, {
+          seq: data.ts.currentSeq,
+          totalDur: data.totalDur,
+          highlightIdx: hitIdx,
+        });
+        canvasHits.set(cvs, {hits, totalDur: data.totalDur, ts: data.ts});
+      }
+    };
+
+    const onLeave = (_e: MouseEvent): void => {
+      hideTooltip();
+      const cvs = _e.target as HTMLCanvasElement;
+      const data = canvasHits.get(cvs);
+      if (data && canvasHover.get(cvs) != null) {
+        canvasHover.delete(cvs);
+        const hits = renderMiniCanvas(cvs, {
+          seq: data.ts.currentSeq,
+          totalDur: data.totalDur,
+        });
+        canvasHits.set(cvs, {hits, totalDur: data.totalDur, ts: data.ts});
+      }
+    };
+
+    canvas.addEventListener('mousemove', onMove);
+    canvas.addEventListener('mouseleave', onLeave);
+    canvasListeners.set(canvas, {move: onMove, leave: onLeave});
+  },
+
+  onupdate(vnode: m.VnodeDOM<MiniTimelineAttrs>) {
+    doRender(vnode.dom, vnode.attrs.ts, vnode.attrs.ensureCache);
+  },
+
+  onremove(vnode: m.VnodeDOM<MiniTimelineAttrs>) {
+    const canvas = vnode.dom.querySelector('canvas');
+    if (canvas) {
+      const listeners = canvasListeners.get(canvas);
+      if (listeners) {
+        canvas.removeEventListener('mousemove', listeners.move);
+        canvas.removeEventListener('mouseleave', listeners.leave);
+        canvasListeners.delete(canvas);
+      }
+    }
+    if (observer) observer.unobserve(vnode.dom);
+    isVisible.delete(vnode.dom);
+    needsRender.delete(vnode.dom);
+    releaseObserver();
+  },
+
+  view() {
+    return m('.qs-mini-canvas', m('canvas'));
+  },
+};
diff --git a/ui/src/plugins/com.google.QuantizedSlices/components/summary_tables.ts b/ui/src/plugins/com.google.QuantizedSlices/components/summary_tables.ts
new file mode 100644
index 0000000..d90c740
--- /dev/null
+++ b/ui/src/plugins/com.google.QuantizedSlices/components/summary_tables.ts
@@ -0,0 +1,274 @@
+// Copyright (C) 2026 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import m from 'mithril';
+import {
+  MergedSlice,
+  SortState,
+  SummaryRow,
+  LONG_PKG_PREFIX,
+} from '../models/types';
+import {stateColor, stateLabel, nameColor} from '../utils/colors';
+import {fmtDur, fmtPct} from '../utils/format';
+
+function buildSummaryData(data: MergedSlice[]): {
+  stateMap: Record<string, {dur: number; count: number; color: string}>;
+  nameMap: Record<string, {dur: number; count: number}>;
+  bfMap: Record<string, {dur: number; count: number}>;
+} {
+  const stateMap: Record<string, {dur: number; count: number; color: string}> =
+    {};
+  const nameMap: Record<string, {dur: number; count: number}> = {};
+  const bfMap: Record<string, {dur: number; count: number}> = {};
+
+  for (const d of data) {
+    const sk = stateLabel(d);
+    if (stateMap[sk] === undefined) {
+      stateMap[sk] = {dur: 0, count: 0, color: stateColor(d)};
+    }
+    stateMap[sk].dur += d.dur;
+    stateMap[sk].count += 1;
+
+    if (d.name !== null) {
+      if (nameMap[d.name] === undefined) {
+        nameMap[d.name] = {dur: 0, count: 0};
+      }
+      nameMap[d.name].dur += d.dur;
+      nameMap[d.name].count += 1;
+    }
+
+    if (d.blocked_function !== null) {
+      if (bfMap[d.blocked_function] === undefined) {
+        bfMap[d.blocked_function] = {dur: 0, count: 0};
+      }
+      bfMap[d.blocked_function].dur += d.dur;
+      bfMap[d.blocked_function].count += 1;
+    }
+  }
+  return {stateMap, nameMap, bfMap};
+}
+
+type SortCol = 'label' | 'dur' | 'pct' | 'count';
+
+function sortRows(rows: SummaryRow[], sort: SortState): SummaryRow[] {
+  return [...rows].sort((a, b) => {
+    const col = sort.col as SortCol;
+    let va: string | number = a[col];
+    let vb: string | number = b[col];
+    if (col === 'label') {
+      va = (va as string).toLowerCase();
+      vb = (vb as string).toLowerCase();
+    }
+    if (va < vb) return -1 * sort.dir;
+    if (va > vb) return 1 * sort.dir;
+    return 0;
+  });
+}
+
+interface TableCardAttrs {
+  id: string;
+  title: string;
+  rows: SummaryRow[];
+  maxDur: number;
+  totalDur: number;
+  sortState: Record<string, SortState>;
+}
+
+const TableCard: m.Component<TableCardAttrs> = {
+  view(vnode: m.Vnode<TableCardAttrs>) {
+    const {id, title, rows, maxDur, totalDur, sortState} = vnode.attrs;
+    if (sortState[id] === undefined) sortState[id] = {col: 'dur', dir: -1};
+    const sort = sortState[id];
+    const sorted = sortRows(rows, sort);
+
+    const cols: Array<{col: string; label: string}> = [
+      {col: 'label', label: 'Name'},
+      {col: 'dur', label: 'Duration'},
+      {col: 'pct', label: '%'},
+      {col: 'count', label: 'Count'},
+    ];
+
+    function onHeaderClick(col: string): void {
+      if (sort.col === col) {
+        sort.dir = (sort.dir === -1 ? 1 : -1) as 1 | -1;
+      } else {
+        sort.col = col;
+        sort.dir = col === 'label' ? 1 : -1;
+      }
+    }
+
+    return m('.qs-card.qs-table-card', [
+      m('.qs-table-card-head', m('span', title)),
+      m(
+        '.qs-table-scroll',
+        m('table.qs-summary', [
+          m(
+            'thead',
+            m(
+              'tr',
+              cols.map((c) =>
+                m(
+                  'th',
+                  {
+                    class: sort.col === c.col ? 'sorted' : '',
+                    onclick: () => onHeaderClick(c.col),
+                  },
+                  [
+                    c.label,
+                    ' ',
+                    m(
+                      'span.qs-sort-arrow',
+                      sort.col === c.col
+                        ? sort.dir === -1
+                          ? '\u2193'
+                          : '\u2191'
+                        : '\u2195',
+                    ),
+                  ],
+                ),
+              ),
+            ),
+          ),
+          m(
+            'tbody',
+            sorted.map((row) =>
+              m('tr', [
+                m(
+                  'td',
+                  m('.qs-cell-label', [
+                    row.color
+                      ? m('span.qs-swatch', {
+                          style: {background: row.color},
+                        })
+                      : null,
+                    m('div', [
+                      m(
+                        'span.qs-name-text',
+                        {title: row.label},
+                        row.short || row.label,
+                      ),
+                      m(
+                        '.qs-bar-wrap',
+                        m('.qs-bar-fill', {
+                          style: {
+                            width: ((row.dur / maxDur) * 100).toFixed(1) + '%',
+                            background: row.color || 'var(--accent)',
+                          },
+                        }),
+                      ),
+                    ]),
+                  ]),
+                ),
+                m('td', fmtDur(row.dur)),
+                m('td', fmtPct(row.dur, totalDur)),
+                m('td', String(row.count)),
+              ]),
+            ),
+          ),
+        ]),
+      ),
+    ]);
+  },
+};
+
+/**
+ * Minimal interface for the trace-level state that SummaryTables needs.
+ */
+export interface SummaryTrace {
+  currentSeq: MergedSlice[];
+  totalDur: number;
+}
+
+interface SummaryAttrs {
+  ts: SummaryTrace;
+  /** Mutable sort state record, shared with the parent to persist across renders. */
+  sortState: Record<string, SortState>;
+}
+
+export const SummaryTables: m.Component<SummaryAttrs> = {
+  view(vnode: m.Vnode<SummaryAttrs>) {
+    const {ts, sortState} = vnode.attrs;
+    const data = ts.currentSeq;
+    const totalDur = ts.totalDur;
+    const {stateMap, nameMap, bfMap} = buildSummaryData(data);
+
+    const tables: m.Children[] = [];
+
+    const maxState = Math.max(...Object.values(stateMap).map((v) => v.dur), 1);
+    const stateRows: SummaryRow[] = Object.entries(stateMap).map(([k, v]) => ({
+      label: k,
+      short: k,
+      dur: v.dur,
+      count: v.count,
+      color: v.color,
+      pct: totalDur > 0 ? (v.dur / totalDur) * 100 : 0,
+    }));
+    tables.push(
+      m(TableCard, {
+        id: 'state',
+        title: 'States',
+        rows: stateRows,
+        maxDur: maxState,
+        totalDur,
+        sortState,
+      }),
+    );
+
+    if (Object.keys(nameMap).length) {
+      const maxName = Math.max(...Object.values(nameMap).map((v) => v.dur), 1);
+      const nameRows: SummaryRow[] = Object.entries(nameMap).map(([k, v]) => ({
+        label: k,
+        short: k.replace(LONG_PKG_PREFIX, ''),
+        dur: v.dur,
+        count: v.count,
+        color: nameColor(k),
+        pct: totalDur > 0 ? (v.dur / totalDur) * 100 : 0,
+      }));
+      tables.push(
+        m(TableCard, {
+          id: 'name',
+          title: 'Names',
+          rows: nameRows,
+          maxDur: maxName,
+          totalDur,
+          sortState,
+        }),
+      );
+    }
+
+    if (Object.keys(bfMap).length) {
+      const maxBf = Math.max(...Object.values(bfMap).map((v) => v.dur), 1);
+      const bfRows: SummaryRow[] = Object.entries(bfMap).map(([k, v]) => ({
+        label: k,
+        short: k,
+        dur: v.dur,
+        count: v.count,
+        color: '#c62828',
+        pct: totalDur > 0 ? (v.dur / totalDur) * 100 : 0,
+      }));
+      tables.push(
+        m(TableCard, {
+          id: 'bf',
+          title: 'Blocked functions',
+          rows: bfRows,
+          maxDur: maxBf,
+          totalDur,
+          sortState,
+        }),
+      );
+    }
+
+    return m('.qs-summary-grid', tables);
+  },
+};
diff --git a/ui/src/plugins/com.google.QuantizedSlices/components/timeline_canvas.ts b/ui/src/plugins/com.google.QuantizedSlices/components/timeline_canvas.ts
new file mode 100644
index 0000000..209fd9a
--- /dev/null
+++ b/ui/src/plugins/com.google.QuantizedSlices/components/timeline_canvas.ts
@@ -0,0 +1,156 @@
+// Copyright (C) 2026 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import {MergedSlice, LONG_PKG_PREFIX} from '../models/types';
+import {stateColor, stateLabel, nameColor, isDark} from '../utils/colors';
+import {fmtDur, fmtPct} from '../utils/format';
+
+export interface HitRect {
+  x: number;
+  y: number;
+  w: number;
+  h: number;
+  d: MergedSlice;
+}
+
+export interface RenderParams {
+  seq: MergedSlice[];
+  totalDur: number;
+  highlightIdx?: number;
+}
+
+export function renderMiniCanvas(
+  canvas: HTMLCanvasElement,
+  params: RenderParams,
+): HitRect[] {
+  const {seq, totalDur, highlightIdx} = params;
+  const dpr = window.devicePixelRatio || 1;
+  const parent = canvas.parentElement;
+  if (!parent) return [];
+  const cssW = parent.clientWidth - 16;
+  const cssH = 30;
+  canvas.style.width = cssW + 'px';
+  canvas.style.height = cssH + 'px';
+  canvas.width = Math.round(cssW * dpr);
+  canvas.height = Math.round(cssH * dpr);
+  const ctx = canvas.getContext('2d');
+  if (!ctx) return [];
+  ctx.scale(dpr, dpr);
+
+  const dark = isDark();
+  ctx.fillStyle = dark ? '#17171a' : '#ffffff';
+  ctx.fillRect(0, 0, cssW, cssH);
+
+  const dimmed = highlightIdx != null;
+  const hits: HitRect[] = [];
+  const scale = cssW / totalDur;
+  for (let i = 0; i < seq.length; i++) {
+    const d = seq[i];
+    const x = d.tsRel * scale;
+    const w = Math.max(d.dur * scale, 0.5);
+    ctx.globalAlpha = dimmed && i !== highlightIdx ? 0.25 : 1;
+    ctx.fillStyle = stateColor(d);
+    ctx.fillRect(x, 0, w, 12);
+    ctx.fillStyle = d.name ? nameColor(d.name) : dark ? '#1c1c26' : '#ede9e2';
+    ctx.fillRect(x, 14, w, 16);
+    hits.push({x, y: 0, w, h: cssH, d});
+  }
+  ctx.globalAlpha = 1;
+  return hits;
+}
+
+// DOM-based tooltip builder — avoids innerHTML and XSS risks.
+
+function createSpan(
+  className: string,
+  text: string,
+  color?: string,
+): HTMLSpanElement {
+  const span = document.createElement('span');
+  span.className = className;
+  span.textContent = text;
+  if (color) span.style.color = color;
+  return span;
+}
+
+export function showTooltip(
+  e: MouseEvent,
+  hits: HitRect[],
+  totalDur: number,
+): void {
+  const tip = document.querySelector<HTMLElement>('.qs-tooltip');
+  if (!tip) return;
+  const canvas = e.target as HTMLCanvasElement;
+  const rect = canvas.getBoundingClientRect();
+  const mx = e.clientX - rect.left;
+  const my = e.clientY - rect.top;
+
+  let hit: HitRect | undefined;
+  for (let i = hits.length - 1; i >= 0; i--) {
+    const r = hits[i];
+    if (mx >= r.x && mx <= r.x + r.w && my >= r.y && my <= r.y + r.h) {
+      hit = r;
+      break;
+    }
+  }
+
+  if (!hit) {
+    tip.style.display = 'none';
+    return;
+  }
+
+  const d = hit.d;
+  const rows: Array<[string, string, string | undefined]> = [
+    ['state', stateLabel(d), stateColor(d)],
+    ['io_wait', d.io_wait !== null ? String(d.io_wait) : '\u2014', undefined],
+    ['blocked', d.blocked_function ?? '\u2014', undefined],
+    ['dur', fmtDur(d.dur) + '  (' + fmtPct(d.dur, totalDur) + ')', undefined],
+    ['start', '+' + fmtDur(d.tsRel), undefined],
+    ['depth', d.depth !== null ? String(d.depth) : '\u2014', undefined],
+    ['\u00d7merged', String(d._merged), undefined],
+  ];
+
+  // Build tooltip DOM without innerHTML.
+  tip.replaceChildren();
+
+  const nameDiv = document.createElement('div');
+  nameDiv.className = 'qs-tooltip-name';
+  nameDiv.textContent = (d.name ?? 'null').replace(LONG_PKG_PREFIX, '');
+  tip.appendChild(nameDiv);
+
+  const grid = document.createElement('div');
+  grid.className = 'qs-tooltip-grid';
+  for (const [k, v, col] of rows) {
+    grid.appendChild(createSpan('qs-tooltip-key', k));
+    grid.appendChild(createSpan('qs-tooltip-val', v, col));
+  }
+  tip.appendChild(grid);
+
+  tip.style.display = 'block';
+  const TW = tip.offsetWidth || 300;
+  const TH = tip.offsetHeight || 160;
+  const VW = window.innerWidth;
+  const VH = window.innerHeight;
+  let tx = e.clientX + 16;
+  let ty = e.clientY - 8;
+  if (tx + TW > VW - 8) tx = e.clientX - TW - 12;
+  if (ty + TH > VH - 8) ty = VH - TH - 8;
+  tip.style.left = tx + 'px';
+  tip.style.top = ty + 'px';
+}
+
+export function hideTooltip(): void {
+  const tip = document.querySelector<HTMLElement>('.qs-tooltip');
+  if (tip) tip.style.display = 'none';
+}
diff --git a/ui/src/plugins/com.google.QuantizedSlices/components/trace_card.ts b/ui/src/plugins/com.google.QuantizedSlices/components/trace_card.ts
new file mode 100644
index 0000000..a2a6733
--- /dev/null
+++ b/ui/src/plugins/com.google.QuantizedSlices/components/trace_card.ts
@@ -0,0 +1,187 @@
+// Copyright (C) 2026 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import m from 'mithril';
+import {Button, ButtonVariant} from '../../../widgets/button';
+import {Intent} from '../../../widgets/common';
+import {Card} from '../../../widgets/card';
+import {MiniTimeline} from './mini_timeline';
+import {SummaryTables} from './summary_tables';
+import {setVerdict, ensureCache, updateSlider} from '../state';
+import type {TraceState, Cluster} from '../state';
+import {fmtDur} from '../utils/format';
+import {buildTraceLink} from '../utils/export';
+
+const expanded = new Set<string>();
+
+function toggleExpand(uuid: string): void {
+  if (expanded.has(uuid)) expanded.delete(uuid);
+  else expanded.add(uuid);
+}
+
+function renderSlider(ts: TraceState): m.Children {
+  ensureCache(ts);
+  return m('.qs-trace-slider', [
+    m('span.qs-slider-label', 'Slices'),
+    m('span.qs-slider-num', String(ts.currentSeq.length)),
+    m('input[type=range].qs-slider-input', {
+      min: 2,
+      max: ts.origN,
+      value: ts.sliderValue,
+      step: 1,
+      onclick: (e: Event) => e.stopPropagation(),
+      oninput: (e: Event) => {
+        e.stopPropagation();
+        updateSlider(ts, +(e.target as HTMLInputElement).value);
+      },
+    }),
+    m('span.qs-slider-of', `/ ${ts.origN}`),
+  ]);
+}
+
+export interface TraceCardAttrs {
+  cl: Cluster;
+  ts: TraceState;
+  idx: number;
+}
+
+export class TraceCard implements m.ClassComponent<TraceCardAttrs> {
+  view({attrs}: m.CVnode<TraceCardAttrs>): m.Children {
+    const {cl, ts, idx} = attrs;
+    const key = ts._key;
+    const isExpanded = expanded.has(key);
+    const verdict = cl.verdicts.get(key);
+
+    const verdictClass =
+      verdict === 'like'
+        ? 'qs-verdict-positive'
+        : verdict === 'dislike'
+          ? 'qs-verdict-negative'
+          : verdict === 'discard'
+            ? 'qs-verdict-discard'
+            : '';
+
+    const href = buildTraceLink(ts.trace.trace_uuid, ts.trace.package_name);
+
+    return m(
+      Card,
+      {className: `qs-trace-card ${verdictClass}`},
+      m(
+        '.qs-card-header',
+        {
+          onclick: () => toggleExpand(key),
+        },
+        [
+          m(
+            'span.qs-collapse-arrow',
+            {className: isExpanded ? 'qs-open' : ''},
+            '\u25b6',
+          ),
+          m('span.qs-trace-idx', `#${idx + 1}`),
+          m('span.qs-trace-pkg', ts.trace.package_name),
+          ts.trace.startup_dur
+            ? m('span.qs-trace-startup-dur', fmtDur(ts.trace.startup_dur))
+            : null,
+          href
+            ? m(
+                'a.qs-trace-link',
+                {
+                  href,
+                  target: '_blank',
+                  rel: 'noopener',
+                  onclick: (e: Event) => e.stopPropagation(),
+                  title: 'Open in trace viewer',
+                },
+                '\u2197',
+              )
+            : null,
+          m('span.qs-trace-actions', [
+            m(Button, {
+              label: '+',
+              variant: ButtonVariant.Minimal,
+              intent: verdict === 'like' ? Intent.Success : Intent.None,
+              active: verdict === 'like',
+              compact: true,
+              className: 'qs-verdict-btn',
+              tooltip: 'Positive',
+              onclick: (e: PointerEvent) => {
+                e.stopPropagation();
+                setVerdict(cl, key, 'like');
+              },
+            }),
+            m(Button, {
+              label: '\u2212',
+              variant: ButtonVariant.Minimal,
+              intent: verdict === 'dislike' ? Intent.Danger : Intent.None,
+              active: verdict === 'dislike',
+              compact: true,
+              className: 'qs-verdict-btn',
+              tooltip: 'Negative',
+              onclick: (e: PointerEvent) => {
+                e.stopPropagation();
+                setVerdict(cl, key, 'dislike');
+              },
+            }),
+            m(Button, {
+              label: '\u00d7',
+              variant: ButtonVariant.Minimal,
+              active: verdict === 'discard',
+              compact: true,
+              className: 'qs-verdict-btn',
+              tooltip: 'Discard',
+              onclick: (e: PointerEvent) => {
+                e.stopPropagation();
+                setVerdict(cl, key, 'discard');
+              },
+            }),
+          ]),
+        ],
+      ),
+      m('.qs-card-body', [m(MiniTimeline, {ts}), renderSlider(ts)]),
+      isExpanded
+        ? m('.qs-card-detail', [
+            m('.qs-detail-section', [
+              m('.qs-detail-label', 'Breakdown'),
+              m(SummaryTables, {ts, sortState: cl.tableSortState}),
+            ]),
+            m('.qs-detail-meta', [
+              m('.qs-tt-grid', [
+                m('span.qs-tt-k', 'UUID'),
+                m('span.qs-tt-v', ts.trace.trace_uuid),
+                m('span.qs-tt-k', 'Package'),
+                m('span.qs-tt-v', ts.trace.package_name),
+                m('span.qs-tt-k', 'Startup'),
+                m(
+                  'span.qs-tt-v',
+                  ts.trace.startup_dur
+                    ? fmtDur(ts.trace.startup_dur)
+                    : '\u2014',
+                ),
+                m('span.qs-tt-k', 'Slices'),
+                m('span.qs-tt-v', String(ts.origN)),
+                m('span.qs-tt-k', 'Total dur'),
+                m('span.qs-tt-v', fmtDur(ts.totalDur)),
+                ...(ts.trace.extra
+                  ? Object.entries(ts.trace.extra).flatMap(([k, v]) => [
+                      m('span.qs-tt-k', k),
+                      m('span.qs-tt-v', String(v)),
+                    ])
+                  : []),
+              ]),
+            ]),
+          ])
+        : null,
+    );
+  }
+}
diff --git a/ui/src/plugins/com.google.QuantizedSlices/components/trace_list.ts b/ui/src/plugins/com.google.QuantizedSlices/components/trace_list.ts
new file mode 100644
index 0000000..158b4dd
--- /dev/null
+++ b/ui/src/plugins/com.google.QuantizedSlices/components/trace_list.ts
@@ -0,0 +1,514 @@
+// Copyright (C) 2026 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import m from 'mithril';
+import {SegmentedButtons} from '../../../widgets/segmented_buttons';
+import {SplitPanel} from '../../../widgets/split_panel';
+import {Popup, PopupPosition} from '../../../widgets/popup';
+import {Menu, MenuItem, MenuDivider} from '../../../widgets/menu';
+import {Button, ButtonVariant} from '../../../widgets/button';
+import {
+  S,
+  activeCluster,
+  filteredTraces,
+  filterTraces,
+  updateGlobalSlider,
+  getFilterableFields,
+  getFieldValues,
+  togglePropFilter,
+  clearPropFilter,
+  copyFilteredToNewTab,
+  getCrossCompareState,
+  startCrossCompare,
+} from '../state';
+import type {TraceState, Cluster} from '../state';
+import type {OverviewFilter} from '../models/types';
+import {BRUSH_BASE_URL} from '../models/types';
+import {traceExportRow, rowsToTsv, rowsToJson} from '../utils/export';
+import type {ExportRow} from '../utils/export';
+import {TraceCard} from './trace_card';
+import {CrossCompareModal} from './cross_compare_modal';
+
+// -- Module-level state --
+
+let openFilterDropdown: string | null = null;
+const PAGE_SIZE = 100;
+const renderLimit = new Map<string, number>();
+
+const ALL_FILTERS: ReadonlyArray<{id: OverviewFilter; label: string}> = [
+  {id: 'all', label: 'All'},
+  {id: 'positive', label: 'Positive'},
+  {id: 'negative', label: 'Negative'},
+  {id: 'pending', label: 'Pending'},
+  {id: 'discarded', label: 'Discarded'},
+];
+
+// -- Export helpers --
+
+function downloadFile(content: string, filename: string, mime: string): void {
+  const blob = new Blob([content], {type: mime});
+  const url = URL.createObjectURL(blob);
+  const a = document.createElement('a');
+  a.href = url;
+  a.download = filename;
+  a.click();
+  setTimeout(() => URL.revokeObjectURL(url), 60_000);
+}
+
+function buildRows(clusters: Cluster[]): ExportRow[] {
+  const rows: ExportRow[] = [];
+  for (const cl of clusters) {
+    for (const ts of cl.traces) {
+      rows.push(traceExportRow(ts.trace, ts._key, cl.name, cl.verdicts));
+    }
+  }
+  return rows;
+}
+
+function doExport(scope: 'tab' | 'all', format: 'json' | 'tsv'): void {
+  const cl = activeCluster();
+  if (!cl) return;
+  const clusters = scope === 'tab' ? [cl] : S.clusters;
+  const rows = buildRows(clusters);
+  const date = new Date().toISOString().slice(0, 10);
+  const stem = scope === 'tab' ? `qs-${cl.name}` : 'qs-all';
+  const safeStem = stem.replace(/[^a-zA-Z0-9_-]/g, '_');
+  if (format === 'json') {
+    downloadFile(
+      rowsToJson(rows),
+      `${safeStem}-${date}.json`,
+      'application/json',
+    );
+  } else {
+    downloadFile(
+      rowsToTsv(rows),
+      `${safeStem}-${date}.tsv`,
+      'text/tab-separated-values',
+    );
+  }
+}
+
+function doCopy(scope: 'tab' | 'all'): void {
+  const cl = activeCluster();
+  if (!cl) return;
+  const clusters = scope === 'tab' ? [cl] : S.clusters;
+  const rows = buildRows(clusters);
+  navigator.clipboard.writeText(rowsToTsv(rows));
+}
+
+// -- Filter helpers --
+
+function filterCount(cl: Cluster, filter: OverviewFilter): number {
+  switch (filter) {
+    case 'positive':
+      return cl.counts.positive;
+    case 'negative':
+      return cl.counts.negative;
+    case 'pending':
+      return cl.counts.pending;
+    case 'discarded':
+      return cl.counts.discarded;
+    default:
+      return cl.traces.length;
+  }
+}
+
+function filterIndex(filter: OverviewFilter): number {
+  return ALL_FILTERS.findIndex((f) => f.id === filter);
+}
+
+// -- Sub-renders --
+
+function renderFilterBar(
+  cl: Cluster,
+  activeFilter: OverviewFilter,
+  onSelect: (f: OverviewFilter) => void,
+): m.Children {
+  return m(SegmentedButtons, {
+    className: 'qs-filter-bar',
+    options: ALL_FILTERS.map((f) => ({
+      label: `${f.label} (${filterCount(cl, f.id)})`,
+    })),
+    selectedOption: filterIndex(activeFilter),
+    onOptionSelected: (idx: number) => onSelect(ALL_FILTERS[idx].id),
+  });
+}
+
+function renderGlobalSlider(cl: Cluster): m.Children {
+  return m('.qs-trace-slider.qs-global-slider', [
+    m('span.qs-slider-label', 'All'),
+    m('span.qs-slider-num', String(cl.globalSlider) + '%'),
+    m('input[type=range].qs-slider-input', {
+      min: 1,
+      max: 100,
+      value: cl.globalSlider,
+      step: 1,
+      oninput: (e: Event) => {
+        updateGlobalSlider(cl, +(e.target as HTMLInputElement).value);
+      },
+    }),
+  ]);
+}
+
+function renderSortBtn(cl: Cluster): m.Children {
+  const active = cl.sortField === 'startup_dur';
+  return m(Button, {
+    label: active
+      ? `Startup ${cl.sortDir === 1 ? '\u2191' : '\u2193'}`
+      : 'Sort',
+    variant: ButtonVariant.Outlined,
+    active,
+    compact: true,
+    tooltip: active
+      ? 'Click to reverse, double-click index to reset'
+      : 'Sort by startup duration',
+    onclick: () => {
+      if (cl.sortField === 'startup_dur') {
+        cl.sortDir = cl.sortDir === 1 ? -1 : 1;
+      } else {
+        cl.sortField = 'startup_dur';
+        cl.sortDir = 1;
+      }
+    },
+  });
+}
+
+function renderFilterDropdown(cl: Cluster): m.Children {
+  const fields = getFilterableFields(cl);
+  if (fields.length === 0) return null;
+  const hasActive = cl.propFilters.size > 0;
+
+  return m('.qs-filter-dropdown-wrap', [
+    m(Button, {
+      label: hasActive ? `Filter (${cl.propFilters.size})` : 'Filter',
+      variant: ButtonVariant.Outlined,
+      active: hasActive,
+      compact: true,
+      onclick: (e: PointerEvent) => {
+        e.stopPropagation();
+        openFilterDropdown = openFilterDropdown ? null : cl.id;
+      },
+    }),
+    openFilterDropdown === cl.id
+      ? m(
+          '.qs-filter-dropdown',
+          {onclick: (e: Event) => e.stopPropagation()},
+          fields.map((field) => {
+            const values = getFieldValues(cl, field);
+            const active = cl.propFilters.get(field);
+            return m('.qs-filter-field', [
+              m('.qs-filter-field-header', [
+                m('span.qs-filter-field-name', field.replace(/_/g, ' ')),
+                active
+                  ? m(Button, {
+                      label: 'clear',
+                      variant: ButtonVariant.Minimal,
+                      compact: true,
+                      onclick: () => clearPropFilter(cl, field),
+                    })
+                  : null,
+              ]),
+              m(
+                '.qs-filter-field-values',
+                values.map((val) =>
+                  m('label.qs-filter-value-label', [
+                    m('input[type=checkbox]', {
+                      checked: !active || active.has(val),
+                      onchange: () => togglePropFilter(cl, field, val),
+                    }),
+                    m('span', val || '(empty)'),
+                  ]),
+                ),
+              ),
+            ]);
+          }),
+        )
+      : null,
+  ]);
+}
+
+function renderExportDropdown(): m.Children {
+  const hasMultipleTabs = S.clusters.length > 1;
+
+  const menuContent: m.Children = [
+    m(MenuItem, {
+      label: 'Copy (this tab)',
+      icon: 'content_copy',
+      onclick: () => doCopy('tab'),
+    }),
+    m(MenuItem, {
+      label: 'JSON (this tab)',
+      icon: 'download',
+      onclick: () => doExport('tab', 'json'),
+    }),
+    m(MenuItem, {
+      label: 'TSV (this tab)',
+      icon: 'download',
+      onclick: () => doExport('tab', 'tsv'),
+    }),
+  ];
+
+  if (hasMultipleTabs) {
+    menuContent.push(
+      m(MenuDivider),
+      m(MenuItem, {
+        label: 'Copy (all tabs)',
+        icon: 'content_copy',
+        onclick: () => doCopy('all'),
+      }),
+      m(MenuItem, {
+        label: 'JSON (all tabs)',
+        icon: 'download',
+        onclick: () => doExport('all', 'json'),
+      }),
+      m(MenuItem, {
+        label: 'TSV (all tabs)',
+        icon: 'download',
+        onclick: () => doExport('all', 'tsv'),
+      }),
+    );
+  }
+
+  return m(
+    Popup,
+    {
+      trigger: m(Button, {
+        label: 'Export',
+        icon: 'upload',
+        variant: ButtonVariant.Filled,
+        compact: true,
+      }),
+      position: PopupPosition.BottomEnd,
+    },
+    m(Menu, menuContent),
+  );
+}
+
+function renderCardList(cl: Cluster, traces: TraceState[]): m.Children {
+  // Build index map once instead of O(n) indexOf per card
+  const idxMap = new Map<TraceState, number>();
+  cl.traces.forEach((ts, i) => idxMap.set(ts, i));
+
+  const limit = renderLimit.get(cl.id) ?? PAGE_SIZE;
+  const visible = traces.slice(0, limit);
+  const remaining = traces.length - visible.length;
+
+  const cards = visible.map((ts) =>
+    m(TraceCard, {key: ts._key, cl, ts, idx: idxMap.get(ts) ?? 0}),
+  );
+  const showMore =
+    remaining > 0
+      ? m(
+          '.qs-show-more-wrap',
+          m(Button, {
+            label: `Show ${Math.min(remaining, PAGE_SIZE)} more (${remaining} remaining)`,
+            variant: ButtonVariant.Outlined,
+            className: 'qs-show-more',
+            onclick: () => {
+              renderLimit.set(cl.id, limit + PAGE_SIZE);
+            },
+          }),
+        )
+      : null;
+  return m('.qs-trace-list', [m('.qs-trace-cards', cards), showMore]);
+}
+
+function renderOpenInBrush(
+  cl: Cluster,
+  filtered: TraceState[] | null,
+): m.Children {
+  return m(Button, {
+    label: 'Open in Brush',
+    variant: ButtonVariant.Outlined,
+    compact: true,
+    disabled: cl.traces.length === 0,
+    tooltip: 'Open visible traces in Brush',
+    onclick: () => {
+      const visible = cl.splitView
+        ? [
+            ...filterTraces(cl, cl.splitFilters[0]),
+            ...filterTraces(cl, cl.splitFilters[1]),
+          ]
+        : filtered || [];
+      const uuids = visible.map((ts) => ts.trace.trace_uuid).filter(Boolean);
+      if (uuids.length === 0) return;
+      const filters = [
+        {column: 'trace_uuid', operator: 'in', value: JSON.stringify(uuids)},
+      ];
+      const encoded = encodeURIComponent(JSON.stringify(filters));
+      const url =
+        `${BRUSH_BASE_URL}?filters=${encoded}` +
+        '&metric_id=android_startup&charts=gallery' +
+        '&gallerySvgColumn=svg&galleryMetricColumn=dur_ms' +
+        '&galleryMetricNameColumn=process_name';
+      window.open(url, '_blank');
+    },
+  });
+}
+
+// -- Toolbar --
+
+function renderToolbar(cl: Cluster, filtered: TraceState[] | null): m.Children {
+  return m('.qs-list-toolbar', [
+    cl.splitView
+      ? m('.qs-list-filters-label', 'Split View')
+      : renderFilterBar(cl, cl.overviewFilter, (f) => {
+          cl.overviewFilter = f;
+        }),
+    renderGlobalSlider(cl),
+    m('.qs-list-actions', [
+      renderSortBtn(cl),
+      renderFilterDropdown(cl),
+      m(Button, {
+        label: cl.splitView ? 'Single' : 'Split',
+        variant: ButtonVariant.Outlined,
+        active: cl.splitView,
+        compact: true,
+        tooltip: 'Toggle split view',
+        onclick: () => {
+          cl.splitView = !cl.splitView;
+        },
+      }),
+      m(Button, {
+        label: 'Copy to tab',
+        variant: ButtonVariant.Outlined,
+        compact: true,
+        disabled: cl.splitView || !filtered || filtered.length === 0,
+        tooltip: cl.splitView
+          ? 'Switch to single view first'
+          : 'Copy visible traces to a new tab',
+        onclick: () => {
+          if (filtered) copyFilteredToNewTab(cl, filtered);
+        },
+      }),
+      m(Button, {
+        label: 'Compare',
+        variant: ButtonVariant.Outlined,
+        compact: true,
+        disabled: cl.traces.length < 2,
+        tooltip: 'Compare traces in pairs to find groups',
+        onclick: () => startCrossCompare(cl),
+      }),
+      renderOpenInBrush(cl, filtered),
+      renderExportDropdown(),
+    ]),
+  ]);
+}
+
+// -- Split view --
+
+function renderSplitView(cl: Cluster): m.Children {
+  const leftTraces = filterTraces(cl, cl.splitFilters[0]);
+  const rightTraces = filterTraces(cl, cl.splitFilters[1]);
+
+  const leftPanel = m('.qs-split-panel-inner', [
+    m('.qs-split-panel-header', [
+      renderFilterBar(cl, cl.splitFilters[0], (f) => {
+        cl.splitFilters[0] = f;
+      }),
+      m('span.qs-split-count', `${leftTraces.length}`),
+    ]),
+    m('.qs-split-panel-body', renderCardList(cl, leftTraces)),
+  ]);
+
+  const rightPanel = m('.qs-split-panel-inner', [
+    m('.qs-split-panel-header', [
+      renderFilterBar(cl, cl.splitFilters[1], (f) => {
+        cl.splitFilters[1] = f;
+      }),
+      m('span.qs-split-count', `${rightTraces.length}`),
+    ]),
+    m('.qs-split-panel-body', renderCardList(cl, rightTraces)),
+  ]);
+
+  return m(SplitPanel, {
+    className: 'qs-split-container',
+    direction: 'horizontal',
+    initialSplit: {percent: cl.splitRatio * 100},
+    firstPanel: leftPanel,
+    secondPanel: rightPanel,
+    onResize: (pct: number) => {
+      cl.splitRatio = pct / 100;
+    },
+  });
+}
+
+// -- Document click handler for closing dropdowns --
+
+let docClickHandler: (() => void) | null = null;
+
+// -- Main component --
+
+export class TraceList implements m.ClassComponent {
+  oncreate(): void {
+    docClickHandler = () => {
+      if (openFilterDropdown) {
+        openFilterDropdown = null;
+        m.redraw();
+      }
+    };
+    document.addEventListener('click', docClickHandler);
+  }
+
+  onremove(): void {
+    if (docClickHandler) {
+      document.removeEventListener('click', docClickHandler);
+      docClickHandler = null;
+    }
+  }
+
+  view(): m.Children {
+    const cl = activeCluster();
+    if (!cl || cl.traces.length === 0) {
+      return m('.qs-section', [
+        m('.qs-section-head', 'Traces'),
+        m(
+          'p',
+          {style: {color: 'var(--dim)', fontSize: '11px'}},
+          'No traces loaded.',
+        ),
+      ]);
+    }
+
+    const filtered = cl.splitView ? null : filteredTraces();
+    const toolbar = renderToolbar(cl, filtered);
+    const ccModal = getCrossCompareState() ? m(CrossCompareModal, {cl}) : null;
+
+    if (filtered) {
+      // Normal single-panel view
+      const countLabel =
+        filtered.length !== cl.traces.length
+          ? `${filtered.length}/${cl.traces.length}`
+          : `${filtered.length}`;
+      return [
+        m('.qs-section', [
+          m('.qs-section-head', `Traces (${countLabel})`),
+          toolbar,
+          renderCardList(cl, filtered),
+        ]),
+        ccModal,
+      ];
+    }
+
+    // Split view
+    return [
+      m('.qs-section', [
+        m('.qs-section-head', 'Traces (split view)'),
+        toolbar,
+        renderSplitView(cl),
+      ]),
+      ccModal,
+    ];
+  }
+}
diff --git a/ui/src/plugins/com.google.QuantizedSlices/index.ts b/ui/src/plugins/com.google.QuantizedSlices/index.ts
new file mode 100644
index 0000000..e1e6bf0
--- /dev/null
+++ b/ui/src/plugins/com.google.QuantizedSlices/index.ts
@@ -0,0 +1,36 @@
+// Copyright (C) 2026 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import m from 'mithril';
+import {App} from '../../public/app';
+import {PerfettoPlugin} from '../../public/plugin';
+import {QuantizedSlicesPage} from './page';
+
+export default class implements PerfettoPlugin {
+  static readonly id = 'com.google.QuantizedSlices';
+
+  static onActivate(app: App): void {
+    app.pages.registerPage({
+      route: '/quantized_slices',
+      render: (subpage) => m(QuantizedSlicesPage, {subpage}),
+    });
+    app.sidebar.addMenuItem({
+      section: 'trace_files',
+      text: 'Quantized Slices',
+      href: '#!/quantized_slices',
+      icon: 'analytics',
+      sortOrder: 5,
+    });
+  }
+}
diff --git a/ui/src/plugins/com.google.QuantizedSlices/models/compression.ts b/ui/src/plugins/com.google.QuantizedSlices/models/compression.ts
new file mode 100644
index 0000000..7110869
--- /dev/null
+++ b/ui/src/plugins/com.google.QuantizedSlices/models/compression.ts
@@ -0,0 +1,205 @@
+// Copyright (C) 2026 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import {MergedSlice, Slice} from './types';
+
+function tokenDistance(a: MergedSlice, b: MergedSlice): number {
+  let d = 0;
+  if (a.state !== b.state) {
+    d += 4;
+  } else if (a.state === 'Uninterruptible Sleep' && a.io_wait !== b.io_wait) {
+    d += 2;
+  }
+  if (a.name !== b.name) {
+    d += a.name === null || b.name === null ? 1 : 2;
+  }
+  if (a.blocked_function !== b.blocked_function) {
+    d += a.blocked_function === null || b.blocked_function === null ? 0.5 : 1;
+  }
+  return d;
+}
+
+function mergeCost(a: MergedSlice, b: MergedSlice): number {
+  const dist = tokenDistance(a, b);
+  if (dist === 0) {
+    return 0.01 * Math.log1p((a.dur + b.dur) / 1e6);
+  }
+  const loser = a.dur <= b.dur ? a : b;
+  const loserWeight =
+    Math.log1p(loser.dur / 1e6) *
+    (1 +
+      (loser.name !== null ? 1 : 0) +
+      (loser.blocked_function !== null ? 1 : 0));
+  return dist * loserWeight;
+}
+
+function mergeTwo(a: MergedSlice, b: MergedSlice): MergedSlice {
+  const winner = a.dur >= b.dur ? a : b;
+  return {
+    ts: a.ts,
+    tsRel: a.tsRel,
+    dur: a.dur + b.dur,
+    state: winner.state,
+    io_wait: winner.io_wait,
+    name: winner.name,
+    depth: winner.depth,
+    blocked_function: winner.blocked_function,
+    _merged: (a._merged || 1) + (b._merged || 1),
+  };
+}
+
+// Determine which lengths to cache — power-of-2 steps plus the original.
+function isCheckpoint(len: number, origN: number): boolean {
+  if (len === origN || len === 2) return true;
+  // Cache at powers of 2 and every 50 steps for fine-grained slider control.
+  return (len & (len - 1)) === 0 || len % 50 === 0;
+}
+
+// Linked-list node for in-place merging. Avoids O(n) array copies per step.
+interface LLNode {
+  data: MergedSlice;
+  prev: number; // index into nodes[], -1 if head
+  next: number; // index into nodes[], -1 if tail
+  alive: boolean;
+  cost: number; // merge cost with the next alive node
+}
+
+function snapshot(nodes: LLNode[], head: number): MergedSlice[] {
+  const result: MergedSlice[] = [];
+  let cur = head;
+  while (cur !== -1) {
+    result.push({...nodes[cur].data});
+    cur = nodes[cur].next;
+  }
+  return result;
+}
+
+export interface MergeCache {
+  cache: Map<number, MergedSlice[]>;
+  sortedKeys: number[]; // sorted ascending for binary search
+}
+
+export function buildMergeCache(rawData: Slice[]): MergeCache {
+  const cache = new Map<number, MergedSlice[]>();
+  if (rawData.length === 0) return {cache, sortedKeys: []};
+
+  const n = rawData.length;
+  const nodes: LLNode[] = rawData.map((d, i) => ({
+    data: {
+      ...d,
+      tsRel: d.ts - rawData[0].ts,
+      _merged: 1,
+    },
+    prev: i - 1,
+    next: i + 1 < n ? i + 1 : -1,
+    alive: true,
+    cost: 0,
+  }));
+
+  // Compute initial merge costs.
+  for (let i = 0; i < n; i++) {
+    if (nodes[i].next !== -1) {
+      nodes[i].cost = mergeCost(nodes[i].data, nodes[nodes[i].next].data);
+    } else {
+      nodes[i].cost = Infinity;
+    }
+  }
+
+  const head = 0;
+  let aliveCount = n;
+
+  // Cache original length.
+  cache.set(n, snapshot(nodes, head));
+
+  while (aliveCount > 2) {
+    // Find min-cost pair by scanning alive nodes.
+    let bestIdx = -1;
+    let bestCost = Infinity;
+    let cur = head;
+    while (cur !== -1) {
+      if (nodes[cur].next !== -1 && nodes[cur].cost < bestCost) {
+        bestCost = nodes[cur].cost;
+        bestIdx = cur;
+      }
+      cur = nodes[cur].next;
+    }
+    if (bestIdx === -1) break;
+
+    // Merge bestIdx with its next neighbor.
+    const nextIdx = nodes[bestIdx].next;
+    nodes[bestIdx].data = mergeTwo(nodes[bestIdx].data, nodes[nextIdx].data);
+
+    // Remove nextIdx from linked list.
+    nodes[nextIdx].alive = false;
+    nodes[bestIdx].next = nodes[nextIdx].next;
+    if (nodes[nextIdx].next !== -1) {
+      nodes[nodes[nextIdx].next].prev = bestIdx;
+    }
+    aliveCount--;
+
+    // Recompute costs for bestIdx and its predecessor.
+    if (nodes[bestIdx].next !== -1) {
+      nodes[bestIdx].cost = mergeCost(
+        nodes[bestIdx].data,
+        nodes[nodes[bestIdx].next].data,
+      );
+    } else {
+      nodes[bestIdx].cost = Infinity;
+    }
+    if (nodes[bestIdx].prev !== -1) {
+      const prevIdx = nodes[bestIdx].prev;
+      nodes[prevIdx].cost = mergeCost(nodes[prevIdx].data, nodes[bestIdx].data);
+    }
+
+    if (isCheckpoint(aliveCount, n)) {
+      cache.set(aliveCount, snapshot(nodes, head));
+    }
+  }
+
+  // Always cache the minimum (2 elements).
+  if (!cache.has(2) && aliveCount === 2) {
+    cache.set(aliveCount, snapshot(nodes, head));
+  }
+
+  const sortedKeys = [...cache.keys()].sort((a, b) => a - b);
+  return {cache, sortedKeys};
+}
+
+// Binary search for smallest key >= target in sorted array.
+function ceilSearch(arr: number[], target: number): number {
+  let lo = 0;
+  let hi = arr.length - 1;
+  let result = arr[hi]; // fallback to largest
+  while (lo <= hi) {
+    const mid = (lo + hi) >> 1;
+    if (arr[mid] >= target) {
+      result = arr[mid];
+      hi = mid - 1;
+    } else {
+      lo = mid + 1;
+    }
+  }
+  return result;
+}
+
+export function getCompressed(
+  mergeCache: MergeCache,
+  _origN: number,
+  target: number,
+): MergedSlice[] {
+  const t = Math.max(2, target);
+  if (mergeCache.sortedKeys.length === 0) return [];
+  const best = ceilSearch(mergeCache.sortedKeys, t);
+  return mergeCache.cache.get(best) ?? mergeCache.cache.get(2) ?? [];
+}
diff --git a/ui/src/plugins/com.google.QuantizedSlices/models/cross_compare.ts b/ui/src/plugins/com.google.QuantizedSlices/models/cross_compare.ts
new file mode 100644
index 0000000..9bf5e0c
--- /dev/null
+++ b/ui/src/plugins/com.google.QuantizedSlices/models/cross_compare.ts
@@ -0,0 +1,321 @@
+// Copyright (C) 2026 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Cross Compare — Union-Find based pairwise comparison algorithm.
+//
+// Traces are nodes in a graph. Positive comparisons merge nodes into
+// clusters (union-find). Negative comparisons mark inter-component
+// separation. Transitivity: if A+B and B+C then A+C is implied.
+// The pair selector prioritises the two largest unresolved components.
+
+export function edgeKey(a: string, b: string): string {
+  return a < b ? `${a}|${b}` : `${b}|${a}`;
+}
+
+// ── Union-Find ──
+
+export class UnionFind {
+  private parent = new Map<string, string>();
+  private rankMap = new Map<string, number>();
+
+  constructor(keys: string[]) {
+    for (const k of keys) {
+      this.parent.set(k, k);
+      this.rankMap.set(k, 0);
+    }
+  }
+
+  find(x: string): string {
+    let root = x;
+    while (this.parent.get(root) !== root) root = this.parent.get(root)!;
+    let cur = x;
+    while (cur !== root) {
+      const next = this.parent.get(cur)!;
+      this.parent.set(cur, root);
+      cur = next;
+    }
+    return root;
+  }
+
+  union(a: string, b: string): void {
+    const ra = this.find(a);
+    const rb = this.find(b);
+    if (ra === rb) return;
+    const rankA = this.rankMap.get(ra)!;
+    const rankB = this.rankMap.get(rb)!;
+    if (rankA < rankB) {
+      this.parent.set(ra, rb);
+    } else if (rankA > rankB) {
+      this.parent.set(rb, ra);
+    } else {
+      this.parent.set(rb, ra);
+      this.rankMap.set(ra, rankA + 1);
+    }
+  }
+
+  connected(a: string, b: string): boolean {
+    return this.find(a) === this.find(b);
+  }
+
+  components(): Map<string, string[]> {
+    const map = new Map<string, string[]>();
+    for (const k of this.parent.keys()) {
+      const root = this.find(k);
+      let arr = map.get(root);
+      if (!arr) {
+        arr = [];
+        map.set(root, arr);
+      }
+      arr.push(k);
+    }
+    return map;
+  }
+}
+
+// ── Cross Compare State ──
+
+export type ComparisonResult = 'positive' | 'negative';
+
+export type HistoryEntry =
+  | {type: 'compare'; keyA: string; keyB: string; result: ComparisonResult}
+  | {type: 'discard'; key: string};
+
+export interface CrossCompareState {
+  uf: UnionFind;
+  negativeEdges: Set<string>;
+  comparisons: Map<string, ComparisonResult>;
+  skippedPairs: Set<string>;
+  discardedKeys: Set<string>;
+  history: HistoryEntry[];
+  traceKeys: string[];
+  currentPair: [string, string] | null;
+  selectedSide: 'left' | 'right' | null;
+  isComplete: boolean;
+}
+
+export function createCrossCompareState(
+  traceKeys: string[],
+): CrossCompareState {
+  const state: CrossCompareState = {
+    uf: new UnionFind(traceKeys),
+    negativeEdges: new Set(),
+    comparisons: new Map(),
+    skippedPairs: new Set(),
+    discardedKeys: new Set(),
+    history: [],
+    traceKeys,
+    currentPair: null,
+    selectedSide: null,
+    isComplete: false,
+  };
+  state.currentPair = nextPair(state);
+  if (!state.currentPair) state.isComplete = true;
+  return state;
+}
+
+// ── Core Operations ──
+
+export function recordComparison(
+  state: CrossCompareState,
+  keyA: string,
+  keyB: string,
+  result: ComparisonResult,
+): void {
+  const ek = edgeKey(keyA, keyB);
+  state.comparisons.set(ek, result);
+  state.skippedPairs.delete(ek);
+  state.history.push({type: 'compare', keyA, keyB, result});
+
+  if (result === 'positive') {
+    const rootA = state.uf.find(keyA);
+    const rootB = state.uf.find(keyB);
+    const toRekey: [string, string][] = [];
+    for (const neg of state.negativeEdges) {
+      const [x, y] = neg.split('|');
+      if (x === rootA || x === rootB || y === rootA || y === rootB) {
+        toRekey.push([x, y]);
+      }
+    }
+    state.uf.union(keyA, keyB);
+    for (const [x, y] of toRekey) {
+      state.negativeEdges.delete(edgeKey(x, y));
+      const rx = state.uf.find(x);
+      const ry = state.uf.find(y);
+      if (rx !== ry) state.negativeEdges.add(edgeKey(rx, ry));
+    }
+  } else if (result === 'negative') {
+    const rootA = state.uf.find(keyA);
+    const rootB = state.uf.find(keyB);
+    if (rootA !== rootB) {
+      state.negativeEdges.add(edgeKey(rootA, rootB));
+    }
+  }
+}
+
+export function skipCurrentPair(state: CrossCompareState): void {
+  if (!state.currentPair) return;
+  state.skippedPairs.add(edgeKey(state.currentPair[0], state.currentPair[1]));
+}
+
+export function discardTrace(state: CrossCompareState, key: string): void {
+  state.discardedKeys.add(key);
+  state.history.push({type: 'discard', key});
+}
+
+export function undoComparison(state: CrossCompareState): void {
+  if (state.history.length === 0) return;
+  const prev = state.history.slice(0, -1);
+  state.uf = new UnionFind(state.traceKeys);
+  state.negativeEdges.clear();
+  state.comparisons.clear();
+  state.skippedPairs.clear();
+  state.discardedKeys.clear();
+  state.history = [];
+  for (const entry of prev) {
+    if (entry.type === 'compare') {
+      recordComparison(state, entry.keyA, entry.keyB, entry.result);
+    } else {
+      discardTrace(state, entry.key);
+    }
+  }
+  state.currentPair = nextPair(state);
+  state.isComplete = !state.currentPair;
+  state.selectedSide = null;
+}
+
+export function nextPair(state: CrossCompareState): [string, string] | null {
+  const comps = activeComponents(state);
+  const sorted = [...comps.entries()].sort((a, b) => b[1].length - a[1].length);
+
+  let fallback: [string, string] | null = null;
+  for (let i = 0; i < sorted.length; i++) {
+    for (let j = i + 1; j < sorted.length; j++) {
+      const rootI = sorted[i][0];
+      const rootJ = sorted[j][0];
+      if (state.negativeEdges.has(edgeKey(rootI, rootJ))) continue;
+      const pair = findUncomparedPair(
+        state,
+        sorted[i][1],
+        sorted[j][1],
+        state.skippedPairs,
+      );
+      if (pair) return pair;
+      if (!fallback) {
+        const skipped = findUncomparedPair(state, sorted[i][1], sorted[j][1]);
+        if (skipped) fallback = skipped;
+      }
+    }
+  }
+  return fallback;
+}
+
+export function nextPairForAnchor(
+  state: CrossCompareState,
+  anchorKey: string,
+): [string, string] | null {
+  if (state.discardedKeys.has(anchorKey)) return null;
+  const comps = activeComponents(state);
+  const anchorRoot = state.uf.find(anchorKey);
+
+  const sorted = [...comps.entries()]
+    .filter(([root]) => root !== anchorRoot)
+    .sort((a, b) => b[1].length - a[1].length);
+
+  let fallback: [string, string] | null = null;
+  for (const [root, members] of sorted) {
+    if (state.negativeEdges.has(edgeKey(anchorRoot, root))) continue;
+    for (const other of members) {
+      const ek = edgeKey(anchorKey, other);
+      if (!state.comparisons.has(ek) && !state.skippedPairs.has(ek)) {
+        return [anchorKey, other];
+      }
+      if (!fallback && !state.comparisons.has(ek)) {
+        fallback = [anchorKey, other];
+      }
+    }
+  }
+  return fallback;
+}
+
+function activeComponents(state: CrossCompareState): Map<string, string[]> {
+  if (state.discardedKeys.size === 0) return state.uf.components();
+  const comps = state.uf.components();
+  const filtered = new Map<string, string[]>();
+  for (const [root, members] of comps) {
+    const active = members.filter((k) => !state.discardedKeys.has(k));
+    if (active.length > 0) filtered.set(root, active);
+  }
+  return filtered;
+}
+
+function findUncomparedPair(
+  state: CrossCompareState,
+  membersA: string[],
+  membersB: string[],
+  exclude?: Set<string>,
+): [string, string] | null {
+  for (const a of membersA) {
+    for (const b of membersB) {
+      const ek = edgeKey(a, b);
+      if (!state.comparisons.has(ek) && (!exclude || !exclude.has(ek))) {
+        return [a, b];
+      }
+    }
+  }
+  return null;
+}
+
+// ── Progress ──
+
+export function getProgress(state: CrossCompareState): {
+  completed: number;
+  total: number;
+  pct: number;
+} {
+  const comps = activeComponents(state);
+  const roots = [...comps.keys()];
+  let total = 0;
+  let resolved = 0;
+  for (let i = 0; i < roots.length; i++) {
+    for (let j = i + 1; j < roots.length; j++) {
+      total++;
+      if (state.negativeEdges.has(edgeKey(roots[i], roots[j]))) {
+        resolved++;
+      }
+    }
+  }
+  const n = state.traceKeys.length - state.discardedKeys.size;
+  const maxPairs = (n * (n - 1)) / 2;
+  const mergedPairs = maxPairs - total;
+  const totalWork = maxPairs;
+  const completedWork = mergedPairs + resolved;
+  return {
+    completed: completedWork,
+    total: totalWork,
+    pct: totalWork > 0 ? Math.round((completedWork / totalWork) * 100) : 100,
+  };
+}
+
+// ── Results ──
+
+export interface CrossCompareResults {
+  groups: string[][];
+  discarded: string[];
+}
+
+export function getResults(state: CrossCompareState): CrossCompareResults {
+  const comps = activeComponents(state);
+  const groups = [...comps.values()].sort((a, b) => b.length - a.length);
+  return {groups, discarded: [...state.discardedKeys]};
+}
diff --git a/ui/src/plugins/com.google.QuantizedSlices/models/types.ts b/ui/src/plugins/com.google.QuantizedSlices/models/types.ts
new file mode 100644
index 0000000..87b2975
--- /dev/null
+++ b/ui/src/plugins/com.google.QuantizedSlices/models/types.ts
@@ -0,0 +1,143 @@
+// Copyright (C) 2026 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+export interface Slice {
+  ts: number;
+  dur: number;
+  name: string | null;
+  state: string | null;
+  depth: number | null;
+  io_wait: number | null;
+  blocked_function: string | null;
+}
+
+export interface MergedSlice extends Slice {
+  tsRel: number;
+  _merged: number;
+}
+
+export interface TraceEntry {
+  trace_uuid: string;
+  package_name: string;
+  startup_dur: number;
+  slices: Slice[];
+  extra?: Record<string, unknown>;
+}
+
+export type Verdict = 'like' | 'dislike' | 'discard';
+
+export type OverviewFilter =
+  | 'all'
+  | 'positive'
+  | 'negative'
+  | 'pending'
+  | 'discarded';
+
+export interface SortState {
+  col: string;
+  dir: 1 | -1;
+}
+
+export interface SummaryRow {
+  label: string;
+  short: string;
+  dur: number;
+  count: number;
+  color: string;
+  pct: number;
+}
+
+export interface ColumnConfig {
+  trace_uuid: {aliases: string[]; fallback: {factory: () => string}};
+  package_name: {aliases: string[]; fallback: {factory: () => string}};
+  startup_dur: {aliases: string[]; fallback: {factory: () => number}};
+  slices: {aliases: string[]; fallback: {factory: () => Slice[]}};
+}
+
+export const DEFAULT_COLUMN_CONFIG: ColumnConfig = {
+  trace_uuid: {
+    aliases: ['trace_uuid', 'uuid', 'id', 'trace_id', 'trace_address'],
+    fallback: {factory: () => crypto.randomUUID()},
+  },
+  package_name: {
+    aliases: [
+      'package_name',
+      'process_name',
+      'process',
+      'package',
+      'pkg',
+      'app',
+    ],
+    fallback: {factory: () => 'unknown'},
+  },
+  startup_dur: {
+    aliases: [
+      'startup_dur',
+      'startup_dur_ms',
+      'startup_duration',
+      'dur',
+      'duration',
+      'total_dur',
+      'startup_ms',
+    ],
+    fallback: {factory: () => 0},
+  },
+  slices: {
+    aliases: [
+      'slices',
+      'quantized_sequence',
+      'quantized_sequence_json',
+      'json',
+      'data',
+      'trace_data',
+      'base64',
+      'thread_slices',
+    ],
+    fallback: {factory: () => []},
+  },
+};
+
+export interface SliceFieldConfig {
+  ts: {aliases: string[]; fallback: number};
+  dur: {aliases: string[]; fallback: number};
+  name: {aliases: string[]; fallback: string | null};
+  state: {aliases: string[]; fallback: string | null};
+  depth: {aliases: string[]; fallback: number | null};
+  io_wait: {aliases: string[]; fallback: number | null};
+  blocked_function: {aliases: string[]; fallback: string | null};
+}
+
+export const DEFAULT_SLICE_FIELD_CONFIG: SliceFieldConfig = {
+  ts: {aliases: ['ts', 'timestamp', 'start', 'start_ts', 'begin'], fallback: 0},
+  dur: {aliases: ['dur', 'duration', 'length'], fallback: 0},
+  name: {aliases: ['name', 'slice_name', 'label', 'event'], fallback: null},
+  state: {aliases: ['state', 'thread_state', 'sched_state'], fallback: null},
+  depth: {aliases: ['depth', 'level', 'stack_depth'], fallback: null},
+  io_wait: {aliases: ['io_wait', 'iowait', 'io'], fallback: null},
+  blocked_function: {
+    aliases: ['blocked_function', 'blocked_fn', 'blocked', 'wchan'],
+    fallback: null,
+  },
+};
+
+// Shared constants used by multiple components.
+export const LONG_PKG_PREFIX =
+  'com.redfin.android.core.activity.launch.deeplink.';
+
+// Base URL for the trace viewer. Change this to point to a different viewer.
+export const TRACE_VIEWER_BASE_URL =
+  'https://apconsole.corp.google.com/link/perfetto/field_traces';
+
+// Base URL for the Brush tool.
+export const BRUSH_BASE_URL = 'https://brush.corp.google.com/';
diff --git a/ui/src/plugins/com.google.QuantizedSlices/page.ts b/ui/src/plugins/com.google.QuantizedSlices/page.ts
new file mode 100644
index 0000000..115086b
--- /dev/null
+++ b/ui/src/plugins/com.google.QuantizedSlices/page.ts
@@ -0,0 +1,60 @@
+// Copyright (C) 2026 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import m from 'mithril';
+import {S, activeCluster} from './state';
+import {ImportPanel} from './components/import_panel';
+import {ClusterTabs} from './components/cluster_tabs';
+import {TraceList} from './components/trace_list';
+
+export interface QuantizedSlicesPageAttrs {
+  readonly subpage?: string;
+}
+
+export class QuantizedSlicesPage
+  implements m.ClassComponent<QuantizedSlicesPageAttrs>
+{
+  view(_vnode: m.Vnode<QuantizedSlicesPageAttrs>): m.Children {
+    const cl = activeCluster();
+    const hasClusters = S.clusters.length > 0;
+
+    return m('.qs-page', [
+      // Tooltip element for canvas hover
+      m('.qs-tooltip'),
+
+      // Header
+      m('.qs-header', [
+        m('h2.qs-title', 'Quantized Slices'),
+        hasClusters
+          ? m(
+              'span.qs-header-stats',
+              `${S.clusters.reduce((n, c) => n + c.traces.length, 0)} traces in ${S.clusters.length} tab${S.clusters.length !== 1 ? 's' : ''}`,
+            )
+          : null,
+      ]),
+
+      // Import panel
+      m(ImportPanel),
+
+      // Cluster tabs + trace list
+      hasClusters
+        ? [
+            m(ClusterTabs, {
+              contentForCluster: () => (cl ? m(TraceList) : null),
+            }),
+          ]
+        : null,
+    ]);
+  }
+}
diff --git a/ui/src/plugins/com.google.QuantizedSlices/parse.ts b/ui/src/plugins/com.google.QuantizedSlices/parse.ts
new file mode 100644
index 0000000..f900c2f
--- /dev/null
+++ b/ui/src/plugins/com.google.QuantizedSlices/parse.ts
@@ -0,0 +1,539 @@
+// Copyright (C) 2026 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Pure parsing functions, no side effects.
+// Shared between main thread and Web Worker.
+//
+// Every function here takes data in and returns data out.
+// No mithril, no state, no DOM access.
+
+import type {Slice, TraceEntry} from './models/types';
+import {
+  DEFAULT_COLUMN_CONFIG,
+  DEFAULT_SLICE_FIELD_CONFIG,
+} from './models/types';
+
+// -- Field resolution --
+
+// Resolves a field value from an object by trying a list of aliases.
+// The fallback can be a static value or a factory function (for values like
+// crypto.randomUUID() that must be unique per call). Factory functions are
+// wrapped in {factory: () => T} to avoid ambiguity when T itself is a function.
+type FallbackValue<T> = T | {factory: () => T};
+
+export function resolveField<T>(
+  obj: Record<string, unknown>,
+  aliases: string[],
+  fallback: FallbackValue<T>,
+): T {
+  for (const alias of aliases) {
+    if (obj[alias] !== undefined) return obj[alias] as T;
+  }
+  if (
+    typeof fallback === 'object' &&
+    fallback !== null &&
+    'factory' in fallback
+  ) {
+    return (fallback as {factory: () => T}).factory();
+  }
+  return fallback as T;
+}
+
+// -- Slice normalization --
+
+export function normalizeSlice(raw: Record<string, unknown>): Slice {
+  const cfg = DEFAULT_SLICE_FIELD_CONFIG;
+  return {
+    ts: resolveField(raw, cfg.ts.aliases, cfg.ts.fallback),
+    dur: resolveField(raw, cfg.dur.aliases, cfg.dur.fallback),
+    name: resolveField(raw, cfg.name.aliases, cfg.name.fallback),
+    state: resolveField(raw, cfg.state.aliases, cfg.state.fallback),
+    depth: resolveField(raw, cfg.depth.aliases, cfg.depth.fallback),
+    io_wait: resolveField(raw, cfg.io_wait.aliases, cfg.io_wait.fallback),
+    blocked_function: resolveField(
+      raw,
+      cfg.blocked_function.aliases,
+      cfg.blocked_function.fallback,
+    ),
+  };
+}
+
+// -- Startup duration (ms -> ns conversion) --
+
+const MS_ALIASES = new Set(['startup_dur_ms', 'startup_ms']);
+
+function resolveStartupDur(obj: Record<string, unknown>): number {
+  const cfg = DEFAULT_COLUMN_CONFIG;
+  for (const alias of cfg.startup_dur.aliases) {
+    if (obj[alias] !== undefined) {
+      const val = parseFloat(String(obj[alias])) || 0;
+      return MS_ALIASES.has(alias) ? val * 1e6 : val;
+    }
+  }
+  return 0;
+}
+
+// -- Package name (handles JSON-encoded column) --
+
+export function resolvePackageName(raw: Record<string, unknown>): string {
+  const cfg = DEFAULT_COLUMN_CONFIG;
+  const val = resolveField(
+    raw,
+    cfg.package_name.aliases,
+    cfg.package_name.fallback,
+  );
+  if (typeof val === 'string' && val.startsWith('{')) {
+    try {
+      const parsed = JSON.parse(val) as Record<string, unknown>;
+      if (typeof parsed.package_name === 'string') return parsed.package_name;
+    } catch {
+      /* not JSON, use as-is */
+    }
+  }
+  return val;
+}
+
+// -- Array-of-arrays -> objects --
+
+export function arrayOfArraysToObjects(
+  arr: unknown[][],
+): Record<string, unknown>[] {
+  const headers = arr[0] as string[];
+  return arr.slice(1).map((row) => {
+    const obj: Record<string, unknown> = {};
+    headers.forEach((h, i) => {
+      if ((row as unknown[])[i] !== undefined) obj[h] = (row as unknown[])[i];
+    });
+    return obj;
+  });
+}
+
+// -- UUID extraction --
+// trace_address values are paths like "/path/to/uuid.pftrace.gz"
+// Extract the UUID portion (basename without extension) if the value looks
+// like a path. If it's already a bare UUID, return as-is.
+
+const UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i;
+
+function extractUuid(val: string): string {
+  if (!val || !val.includes('/')) return val;
+  const match = val.match(UUID_RE);
+  if (match) return match[0];
+  // Fallback: use basename without extension
+  const base = val.split('/').pop() || val;
+  return base.replace(/\.\w+(\.\w+)*$/, '');
+}
+
+// -- Shared slice-field parsing --
+// Handles string (JSON/base64), object-with-slices, or raw array inputs.
+
+export function parseSlicesField(rawSlices: unknown): Slice[] | null {
+  if (typeof rawSlices === 'string') {
+    let decoded = rawSlices;
+    if (!decoded.startsWith('[') && !decoded.startsWith('{')) {
+      try {
+        decoded = atob(decoded);
+      } catch {
+        return null;
+      }
+    }
+    try {
+      let parsed: unknown;
+      try {
+        parsed = JSON.parse(decoded);
+      } catch {
+        parsed = JSON.parse(repairJson(decoded));
+      }
+      const parsedObj = parsed as Record<string, unknown>;
+      const arr = Array.isArray(parsed)
+        ? (parsed as unknown[])
+        : ((parsedObj.slices ?? parsedObj.data) as unknown[] | undefined);
+      if (!Array.isArray(arr) || arr.length === 0) return null;
+      return arr.map((s: unknown) =>
+        normalizeSlice(s as Record<string, unknown>),
+      );
+    } catch {
+      return null;
+    }
+  }
+  if (Array.isArray(rawSlices)) {
+    const slices = rawSlices.map((s: unknown) =>
+      normalizeSlice(s as Record<string, unknown>),
+    );
+    return slices.length > 0 ? slices : null;
+  }
+  return null;
+}
+
+// -- Trace normalization --
+
+export function normalizeTrace(
+  raw: Record<string, unknown>,
+): TraceEntry | null {
+  const cfg = DEFAULT_COLUMN_CONFIG;
+  const rawSlices = resolveField<unknown>(
+    raw,
+    cfg.slices.aliases,
+    cfg.slices.fallback,
+  );
+
+  const slices = parseSlicesField(rawSlices);
+  if (!slices) return null;
+
+  // Collect extra fields (anything not a known column alias)
+  const knownKeys = new Set([
+    ...cfg.trace_uuid.aliases,
+    ...cfg.package_name.aliases,
+    ...cfg.startup_dur.aliases,
+    ...cfg.slices.aliases,
+  ]);
+  const extra: Record<string, unknown> = {};
+  for (const [k, v] of Object.entries(raw)) {
+    if (!knownKeys.has(k)) extra[k] = v;
+  }
+
+  return {
+    trace_uuid: extractUuid(
+      resolveField(raw, cfg.trace_uuid.aliases, cfg.trace_uuid.fallback),
+    ),
+    package_name: resolvePackageName(raw),
+    startup_dur: resolveStartupDur(raw),
+    slices,
+    extra: Object.keys(extra).length > 0 ? extra : undefined,
+  };
+}
+
+// -- JSON repair for truncated input --
+
+export function repairJson(text: string): string {
+  let result = text.trimEnd();
+  let inStr = false;
+  let escape = false;
+  const stack: string[] = [];
+  for (let i = 0; i < result.length; i++) {
+    const ch = result[i];
+    if (escape) {
+      escape = false;
+      continue;
+    }
+    if (ch === '\\' && inStr) {
+      escape = true;
+      continue;
+    }
+    if (ch === '"' && !escape) {
+      inStr = !inStr;
+      continue;
+    }
+    if (inStr) continue;
+    if (ch === '[') stack.push(']');
+    else if (ch === '{') stack.push('}');
+    else if (ch === ']' || ch === '}') {
+      if (stack.length > 0 && stack[stack.length - 1] === ch) stack.pop();
+    }
+  }
+  if (inStr) result += '"';
+  while (stack.length > 0) result += stack.pop();
+  return result;
+}
+
+// -- Delimited (TSV/CSV) row parsing -- RFC 4180 compliant --
+
+export function parseDelimitedRows(
+  text: string,
+  delimiter: string,
+): string[][] {
+  const rows: string[][] = [];
+  let fields: string[] = [];
+  let current = '';
+  let inQ = false;
+  let i = 0;
+
+  while (i < text.length) {
+    const ch = text[i];
+    if (inQ) {
+      if (ch === '"') {
+        if (i + 1 < text.length && text[i + 1] === '"') {
+          // Escaped quote ""
+          current += '"';
+          i += 2;
+          continue;
+        }
+        // End of quoted field
+        inQ = false;
+        i++;
+        continue;
+      }
+      current += ch;
+      i++;
+    } else {
+      if (ch === '"' && current === '') {
+        inQ = true;
+        i++;
+      } else if (ch === delimiter) {
+        fields.push(current);
+        current = '';
+        i++;
+      } else if (ch === '\n' || ch === '\r') {
+        fields.push(current);
+        current = '';
+        if (ch === '\r' && i + 1 < text.length && text[i + 1] === '\n') i++;
+        if (fields.some((f) => f.trim() !== '')) rows.push(fields);
+        fields = [];
+        i++;
+      } else {
+        current += ch;
+        i++;
+      }
+    }
+  }
+
+  // Final row
+  fields.push(current);
+  if (fields.some((f) => f.trim() !== '')) rows.push(fields);
+  return rows;
+}
+
+// -- Progress callback type --
+
+export interface ParseProgress {
+  message: string;
+  current?: number;
+  total?: number;
+}
+
+// -- High-level parse: JSON text -> TraceEntry[] --
+
+export function parseJsonToTraces(
+  text: string,
+  onProgress?: (p: ParseProgress) => void,
+): TraceEntry[] {
+  onProgress?.({message: 'Parsing JSON...'});
+
+  let parsed: unknown;
+  try {
+    parsed = JSON.parse(text);
+  } catch {
+    parsed = JSON.parse(repairJson(text));
+  }
+
+  if (Array.isArray(parsed) && parsed.length) {
+    let items: unknown[] = parsed;
+
+    // Array-of-arrays: [[headers], [row1], [row2], ...]
+    if (
+      Array.isArray(parsed[0]) &&
+      parsed.length >= 2 &&
+      (parsed[0] as unknown[]).every((h: unknown) => typeof h === 'string')
+    ) {
+      onProgress?.({message: 'Converting array-of-arrays...'});
+      items = arrayOfArraysToObjects(parsed as unknown[][]);
+    }
+
+    const first = items[0];
+    if (typeof first !== 'object' || first === null || Array.isArray(first)) {
+      throw new Error('Expected array of objects or [headers, ...rows]');
+    }
+
+    const firstObj = first as Record<string, unknown>;
+
+    // Detect: is this an array of slices or an array of traces?
+    const looksLikeSlice =
+      DEFAULT_SLICE_FIELD_CONFIG.ts.aliases.some(
+        (a) => firstObj[a] !== undefined,
+      ) &&
+      DEFAULT_SLICE_FIELD_CONFIG.dur.aliases.some(
+        (a) => firstObj[a] !== undefined,
+      );
+    const looksLikeTrace = DEFAULT_COLUMN_CONFIG.slices.aliases.some(
+      (a) => firstObj[a] !== undefined,
+    );
+
+    if (looksLikeSlice && !looksLikeTrace) {
+      onProgress?.({message: `Normalizing ${items.length} slices...`});
+      const slices = items.map((s) =>
+        normalizeSlice(s as Record<string, unknown>),
+      );
+      return [
+        {
+          trace_uuid: crypto.randomUUID(),
+          package_name: 'unknown',
+          startup_dur: 0,
+          slices,
+        },
+      ];
+    }
+
+    if (looksLikeTrace) {
+      const total = items.length;
+      const traces: TraceEntry[] = [];
+      for (let i = 0; i < total; i++) {
+        if (i % 10 === 0) {
+          onProgress?.({
+            message: `Processing trace ${i + 1}/${total}...`,
+            current: i,
+            total,
+          });
+        }
+        const t = normalizeTrace(items[i] as Record<string, unknown>);
+        if (t) traces.push(t);
+      }
+      if (traces.length === 0) throw new Error('No valid traces in array');
+      return traces;
+    }
+
+    throw new Error(
+      'Array items need ts+dur (slices) or a slices/json/data column (traces)',
+    );
+  }
+
+  if (typeof parsed === 'object' && parsed !== null) {
+    const trace = normalizeTrace(parsed as Record<string, unknown>);
+    if (trace) return [trace];
+    throw new Error('Object must have a slices/json/data field');
+  }
+
+  throw new Error('Expected array or object');
+}
+
+// -- High-level parse: delimited text -> TraceEntry[] --
+
+export function parseDelimitedToTraces(
+  text: string,
+  delimiter: string,
+  onProgress?: (p: ParseProgress) => void,
+): TraceEntry[] {
+  onProgress?.({message: 'Parsing rows...'});
+  const rows = parseDelimitedRows(text, delimiter);
+  if (rows.length < 2) throw new Error('Need header + data rows');
+
+  const headers = rows[0];
+  const cfg = DEFAULT_COLUMN_CONFIG;
+  const norm = (s: string): string =>
+    s.toLowerCase().trim().replace(/\s+/g, '_');
+  const findCol = (aliases: string[]): number => {
+    for (const a of aliases) {
+      const idx = headers.findIndex((h) => norm(h) === a.toLowerCase());
+      if (idx >= 0) return idx;
+    }
+    return -1;
+  };
+
+  const slicesIdx = findCol(cfg.slices.aliases);
+  const uuidIdx = findCol(cfg.trace_uuid.aliases);
+  const pkgIdx = findCol(cfg.package_name.aliases);
+  const durIdx = findCol(cfg.startup_dur.aliases);
+  const durIsMs = durIdx >= 0 && MS_ALIASES.has(norm(headers[durIdx]));
+
+  if (slicesIdx < 0) {
+    throw new Error(`Need a column matching: ${cfg.slices.aliases.join(', ')}`);
+  }
+
+  const traces: TraceEntry[] = [];
+  let parseErrors = 0;
+  const total = rows.length - 1;
+
+  for (let ri = 1; ri < rows.length; ri++) {
+    if ((ri - 1) % 10 === 0) {
+      onProgress?.({
+        message: `Processing row ${ri}/${total}...`,
+        current: ri - 1,
+        total,
+      });
+    }
+
+    const cols = rows[ri];
+    if (!cols[slicesIdx]?.trim()) continue;
+
+    try {
+      const slices = parseSlicesField(cols[slicesIdx].trim());
+      if (!slices) {
+        parseErrors++;
+        continue;
+      }
+
+      const extra: Record<string, unknown> = {};
+      headers.forEach((h, idx) => {
+        if (
+          idx !== slicesIdx &&
+          idx !== uuidIdx &&
+          idx !== pkgIdx &&
+          idx !== durIdx
+        ) {
+          if (cols[idx]?.trim()) extra[norm(h)] = cols[idx].trim();
+        }
+      });
+
+      let pkgName =
+        pkgIdx >= 0 && cols[pkgIdx]
+          ? cols[pkgIdx].trim()
+          : cfg.package_name.fallback.factory();
+      if (pkgName.startsWith('{')) {
+        try {
+          const p = JSON.parse(pkgName) as Record<string, unknown>;
+          if (typeof p.package_name === 'string') pkgName = p.package_name;
+        } catch {
+          /* not JSON */
+        }
+      }
+
+      traces.push({
+        trace_uuid: extractUuid(
+          uuidIdx >= 0 && cols[uuidIdx]
+            ? cols[uuidIdx].trim()
+            : cfg.trace_uuid.fallback.factory(),
+        ),
+        package_name: pkgName,
+        startup_dur:
+          durIdx >= 0 && cols[durIdx]
+            ? (parseFloat(cols[durIdx]) || 0) * (durIsMs ? 1e6 : 1)
+            : 0,
+        slices,
+        extra: Object.keys(extra).length > 0 ? extra : undefined,
+      });
+    } catch {
+      parseErrors++;
+    }
+  }
+
+  if (!traces.length) {
+    throw new Error(`No valid traces (${parseErrors} parse errors)`);
+  }
+  return traces;
+}
+
+// -- Unified entry point: auto-detect format --
+
+export function parseText(
+  text: string,
+  onProgress?: (p: ParseProgress) => void,
+): TraceEntry[] {
+  text = text.trim();
+  if (!text) return [];
+
+  if (text.startsWith('[') || text.startsWith('{')) {
+    return parseJsonToTraces(text, onProgress);
+  }
+
+  const firstNewline = text.indexOf('\n');
+  const firstLine = firstNewline >= 0 ? text.slice(0, firstNewline) : text;
+  if (firstLine.includes('\t')) {
+    return parseDelimitedToTraces(text, '\t', onProgress);
+  }
+  if (firstLine.includes(',')) {
+    return parseDelimitedToTraces(text, ',', onProgress);
+  }
+
+  // Fallback: try as JSON
+  return parseJsonToTraces(text, onProgress);
+}
diff --git a/ui/src/plugins/com.google.QuantizedSlices/state.ts b/ui/src/plugins/com.google.QuantizedSlices/state.ts
new file mode 100644
index 0000000..e800c53
--- /dev/null
+++ b/ui/src/plugins/com.google.QuantizedSlices/state.ts
@@ -0,0 +1,650 @@
+// Copyright (C) 2026 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import m from 'mithril';
+import type {
+  TraceEntry,
+  Slice,
+  MergedSlice,
+  Verdict,
+  OverviewFilter,
+  SortState,
+} from './models/types';
+import {buildMergeCache, getCompressed} from './models/compression';
+import type {MergeCache} from './models/compression';
+import type {CrossCompareState} from './models/cross_compare';
+import {
+  createCrossCompareState,
+  recordComparison as ccRecord,
+  nextPair,
+  nextPairForAnchor,
+  getResults as ccResults,
+  undoComparison as ccUndo,
+  discardTrace as ccDiscard,
+  skipCurrentPair as ccSkip,
+} from './models/cross_compare';
+
+export interface TraceState {
+  // Immutable data
+  trace: TraceEntry;
+  _key: string;
+  totalDur: number;
+  origN: number;
+
+  // Mutable compression cache (lazily initialized)
+  _mergeCache: MergeCache | null;
+  sliderValue: number;
+  currentSeq: MergedSlice[];
+}
+
+export function traceKey(t: TraceEntry): string {
+  const startupId = t.extra?.startup_id ?? '';
+  return `${t.trace_uuid}|${t.package_name}|${startupId}|${t.startup_dur}`;
+}
+
+interface VerdictCounts {
+  positive: number;
+  negative: number;
+  pending: number;
+  discarded: number;
+}
+
+export interface Cluster {
+  // -- Data --
+  id: string;
+  name: string;
+  traces: TraceState[];
+  verdicts: Map<string, Verdict>;
+  counts: VerdictCounts;
+
+  // -- View state --
+  overviewFilter: OverviewFilter;
+  splitView: boolean;
+  splitFilters: [OverviewFilter, OverviewFilter];
+  splitRatio: number;
+
+  // -- Sort / filter state --
+  tableSortState: Record<string, SortState>;
+  sortField: 'index' | 'startup_dur';
+  sortDir: 1 | -1;
+  propFilters: Map<string, Set<string>>;
+
+  // -- Compression state --
+  globalSlider: number; // 1-100 percentage
+}
+
+function makeCluster(name: string, traces: TraceState[]): Cluster {
+  return {
+    id: crypto.randomUUID(),
+    name,
+    traces,
+    verdicts: new Map(),
+    overviewFilter: 'all',
+    counts: {positive: 0, negative: 0, pending: traces.length, discarded: 0},
+    tableSortState: {},
+    splitView: false,
+    splitFilters: ['pending', 'positive'],
+    splitRatio: 0.5,
+    sortField: 'index',
+    sortDir: 1,
+    propFilters: new Map(),
+    globalSlider: 100,
+  };
+}
+
+interface AppState {
+  clusters: Cluster[];
+  activeClusterId: string | null;
+  importMsg: {text: string; ok: boolean} | null;
+  loadProgress: {message: string; pct?: number} | null;
+}
+
+export const S: AppState = {
+  clusters: [],
+  activeClusterId: null,
+  importMsg: null,
+  loadProgress: null,
+};
+
+export function activeCluster(): Cluster | null {
+  return S.clusters.find((c) => c.id === S.activeClusterId) ?? null;
+}
+
+export function recomputeCounts(cl: Cluster): void {
+  let positive = 0;
+  let negative = 0;
+  let discarded = 0;
+  for (const v of cl.verdicts.values()) {
+    if (v === 'like') positive++;
+    else if (v === 'dislike') negative++;
+    else if (v === 'discard') discarded++;
+  }
+  cl.counts = {
+    positive,
+    negative,
+    discarded,
+    pending: cl.traces.length - positive - negative - discarded,
+  };
+}
+
+export function initTraceLazy(trace: TraceEntry): TraceState {
+  // Sort slices by timestamp to ensure totalDur computation is correct.
+  const slices = trace.slices;
+  slices.sort((a, b) => a.ts - b.ts);
+
+  const totalDur =
+    slices.length > 0
+      ? slices.reduce((mx, d) => Math.max(mx, d.ts - slices[0].ts + d.dur), 0)
+      : 0;
+  return {
+    trace,
+    _key: traceKey(trace),
+    _mergeCache: null,
+    totalDur,
+    origN: slices.length,
+    sliderValue: slices.length,
+    currentSeq: [],
+  };
+}
+
+export function ensureCache(ts: TraceState): void {
+  if (ts._mergeCache !== null) return;
+  ts._mergeCache = buildMergeCache(ts.trace.slices);
+  ts.currentSeq = getCompressed(ts._mergeCache, ts.origN, ts.sliderValue);
+}
+
+export function updateSlider(ts: TraceState, value: number): void {
+  ensureCache(ts);
+  ts.sliderValue = value;
+  ts.currentSeq = getCompressed(ts._mergeCache!, ts.origN, value);
+}
+
+export function updateGlobalSlider(cl: Cluster, pct: number): void {
+  cl.globalSlider = pct;
+  const frac = pct / 100;
+  for (const ts of cl.traces) {
+    const target = Math.max(2, Math.round(2 + (ts.origN - 2) * frac));
+    updateSlider(ts, target);
+  }
+  m.redraw();
+}
+
+export function addCluster(name: string, entries: TraceEntry[]): void {
+  const allStates = entries.map(initTraceLazy);
+  // Deduplicate by composite key
+  const seen = new Set<string>();
+  const states = allStates.filter((ts) => {
+    if (seen.has(ts._key)) return false;
+    seen.add(ts._key);
+    return true;
+  });
+  const cl = makeCluster(name, states);
+  if (states.length > 0) ensureCache(states[0]);
+  S.clusters.push(cl);
+  S.activeClusterId = cl.id;
+  m.redraw();
+}
+
+export function loadSingleJson(
+  data: Slice[],
+  uuid?: string,
+  pkg?: string,
+  dur?: number,
+): void {
+  addCluster('Import', [
+    {
+      trace_uuid: uuid || crypto.randomUUID(),
+      package_name: pkg || 'unknown',
+      startup_dur: dur ?? 0,
+      slices: data,
+    },
+  ]);
+}
+
+export function loadMultipleTraces(name: string, traces: TraceEntry[]): void {
+  addCluster(name, traces);
+}
+
+/** Deep-copy filtered traces into a new independent tab, carrying over verdicts. */
+export function copyFilteredToNewTab(
+  sourceCl: Cluster,
+  filteredStates: TraceState[],
+): void {
+  if (filteredStates.length === 0) return;
+  const entries: TraceEntry[] = filteredStates.map((ts) => ({
+    trace_uuid: ts.trace.trace_uuid,
+    package_name: ts.trace.package_name,
+    startup_dur: ts.trace.startup_dur,
+    slices: ts.trace.slices,
+    extra: ts.trace.extra ? {...ts.trace.extra} : undefined,
+  }));
+  const newStates = entries.map(initTraceLazy);
+  const cl = makeCluster(sourceCl.name + ' (copy)', newStates);
+  // Carry over verdicts from source
+  for (const ts of newStates) {
+    const v = sourceCl.verdicts.get(ts._key);
+    if (v) cl.verdicts.set(ts._key, v);
+  }
+  recomputeCounts(cl);
+  if (newStates.length > 0) ensureCache(newStates[0]);
+  S.clusters.push(cl);
+  S.activeClusterId = cl.id;
+  m.redraw();
+}
+
+export function removeCluster(id: string): void {
+  S.clusters = S.clusters.filter((c) => c.id !== id);
+  if (S.activeClusterId === id) {
+    S.activeClusterId = S.clusters.length > 0 ? S.clusters[0].id : null;
+  }
+  // Clean up cross-compare state if it belonged to the removed cluster.
+  if (_ccState) {
+    const remaining = S.clusters.find((c) =>
+      c.traces.some((ts) => _ccState!.traceKeys.includes(ts._key)),
+    );
+    if (!remaining) _ccState = null;
+  }
+  m.redraw();
+}
+
+export function switchCluster(id: string): void {
+  S.activeClusterId = id;
+  m.redraw();
+}
+
+export function renameCluster(id: string, name: string): void {
+  const cl = S.clusters.find((c) => c.id === id);
+  if (cl && name.trim()) cl.name = name.trim();
+  m.redraw();
+}
+
+export function setVerdict(cl: Cluster, uuid: string, verdict: Verdict): void {
+  if (cl.verdicts.get(uuid) === verdict) {
+    cl.verdicts.delete(uuid);
+  } else {
+    cl.verdicts.set(uuid, verdict);
+  }
+  recomputeCounts(cl);
+  m.redraw();
+}
+
+function applyPropFilters(cl: Cluster, traces: TraceState[]): TraceState[] {
+  if (cl.propFilters.size === 0) return traces;
+  return traces.filter((ts) => {
+    for (const [field, allowed] of cl.propFilters) {
+      const val = traceFieldValue(ts, field);
+      if (!allowed.has(val)) return false;
+    }
+    return true;
+  });
+}
+
+function applySorting(cl: Cluster, traces: TraceState[]): TraceState[] {
+  if (cl.sortField === 'index') return traces;
+  const sorted = [...traces];
+  sorted.sort(
+    (a, b) => (a.trace.startup_dur - b.trace.startup_dur) * cl.sortDir,
+  );
+  return sorted;
+}
+
+export function filterTraces(
+  cl: Cluster,
+  filter: OverviewFilter,
+): TraceState[] {
+  let result: TraceState[];
+  switch (filter) {
+    case 'positive':
+      result = cl.traces.filter((ts) => cl.verdicts.get(ts._key) === 'like');
+      break;
+    case 'negative':
+      result = cl.traces.filter((ts) => cl.verdicts.get(ts._key) === 'dislike');
+      break;
+    case 'pending':
+      result = cl.traces.filter((ts) => {
+        const v = cl.verdicts.get(ts._key);
+        return !v;
+      });
+      break;
+    case 'discarded':
+      result = cl.traces.filter((ts) => cl.verdicts.get(ts._key) === 'discard');
+      break;
+    default:
+      result = cl.traces;
+  }
+  result = applyPropFilters(cl, result);
+  return applySorting(cl, result);
+}
+
+export function filteredTraces(): TraceState[] {
+  const cl = activeCluster();
+  if (!cl) return [];
+  return filterTraces(cl, cl.overviewFilter);
+}
+
+// Resolve a filterable field value -- checks top-level trace fields first, then extra
+function traceFieldValue(ts: TraceState, field: string): string {
+  const trace = ts.trace as unknown as Record<string, unknown>;
+  if (field in trace) return String(trace[field] ?? '');
+  return String(ts.trace.extra?.[field] ?? '');
+}
+
+// Collect unique values for a given field across all traces
+export function getFieldValues(cl: Cluster, field: string): string[] {
+  const vals = new Set<string>();
+  for (const ts of cl.traces) {
+    vals.add(traceFieldValue(ts, field));
+  }
+  return [...vals].sort();
+}
+
+// Only these fields appear in the filter dropdown
+const FILTERABLE_FIELDS = [
+  'startup_type',
+  'package_name',
+  'device_name',
+  'unique_session_name',
+];
+
+// Get list of filterable extra fields that have multiple distinct values
+export function getFilterableFields(cl: Cluster): string[] {
+  return FILTERABLE_FIELDS.filter((field) => {
+    const vals = new Set<string>();
+    for (const ts of cl.traces) {
+      vals.add(traceFieldValue(ts, field));
+      if (vals.size >= 2) return true;
+    }
+    return false;
+  });
+}
+
+export function togglePropFilter(
+  cl: Cluster,
+  field: string,
+  value: string,
+): void {
+  let allowed = cl.propFilters.get(field);
+  if (!allowed) {
+    // First click: select only this value (deselect all others)
+    allowed = new Set([value]);
+    cl.propFilters.set(field, allowed);
+  } else if (allowed.has(value)) {
+    allowed.delete(value);
+    if (allowed.size === 0) cl.propFilters.delete(field);
+  } else {
+    allowed.add(value);
+    // If all values selected, remove filter entirely
+    const all = getFieldValues(cl, field);
+    if (allowed.size === all.length) cl.propFilters.delete(field);
+  }
+  m.redraw();
+}
+
+export function clearPropFilter(cl: Cluster, field: string): void {
+  cl.propFilters.delete(field);
+  m.redraw();
+}
+
+// -- Cross Compare --
+
+let _ccState: CrossCompareState | null = null;
+
+export function getCrossCompareState(): CrossCompareState | null {
+  return _ccState;
+}
+
+export function startCrossCompare(cl: Cluster): void {
+  const keys = cl.traces.map((ts) => ts._key);
+  // Resume if trace set matches, otherwise start fresh
+  if (
+    _ccState &&
+    _ccState.traceKeys.length === keys.length &&
+    _ccState.traceKeys.every((k, i) => k === keys[i])
+  ) {
+    m.redraw();
+    return;
+  }
+  _ccState = createCrossCompareState(keys);
+  m.redraw();
+}
+
+export function closeCrossCompare(): void {
+  _ccState = null;
+  m.redraw();
+}
+
+function isPureAnchor(anchorKey: string): boolean {
+  if (!_ccState || _ccState.history.length === 0) return false;
+  return _ccState.history.every(
+    (e) => e.type === 'discard' || e.keyA === anchorKey || e.keyB === anchorKey,
+  );
+}
+
+function advancePair(anchorKey?: string): void {
+  if (!_ccState) return;
+  if (anchorKey && !_ccState.discardedKeys.has(anchorKey)) {
+    _ccState.currentPair = nextPairForAnchor(_ccState, anchorKey);
+    if (!_ccState.currentPair) {
+      // Pure anchor session: all comparisons involved the anchor -> done
+      if (!isPureAnchor(anchorKey)) {
+        _ccState.currentPair = nextPair(_ccState);
+      }
+    }
+  } else {
+    _ccState.currentPair = nextPair(_ccState);
+  }
+  if (!_ccState.currentPair) _ccState.isComplete = true;
+  _ccState.selectedSide = null;
+}
+
+export function recordCrossComparison(
+  result: 'positive' | 'negative',
+  anchorKey?: string,
+): void {
+  if (!_ccState || !_ccState.currentPair) return;
+  const [a, b] = _ccState.currentPair;
+  ccRecord(_ccState, a, b, result);
+  advancePair(anchorKey);
+  m.redraw();
+}
+
+export function skipCrossComparison(anchorKey?: string): void {
+  if (!_ccState || !_ccState.currentPair) return;
+  ccSkip(_ccState);
+  advancePair(anchorKey);
+  m.redraw();
+}
+
+export function undoCrossComparison(anchorKey?: string): void {
+  if (!_ccState || _ccState.history.length === 0) return;
+  ccUndo(_ccState);
+  // Re-advance with anchor if set
+  if (anchorKey && !_ccState.discardedKeys.has(anchorKey)) {
+    const pair = nextPairForAnchor(_ccState, anchorKey);
+    if (pair) {
+      _ccState.currentPair = pair;
+      _ccState.isComplete = false;
+    }
+  }
+  m.redraw();
+}
+
+export function discardCrossCompareTrace(
+  cl: Cluster,
+  side: 'left' | 'right',
+  anchorKey?: string,
+): void {
+  if (!_ccState || !_ccState.currentPair) return;
+  const key =
+    side === 'left' ? _ccState.currentPair[0] : _ccState.currentPair[1];
+  cl.verdicts.set(key, 'discard');
+  recomputeCounts(cl);
+  ccDiscard(_ccState, key);
+  advancePair(anchorKey);
+  m.redraw();
+}
+
+export function applyCrossCompareResults(
+  cl: Cluster,
+  positiveIdx = 0,
+  negativeIdx = 1,
+): void {
+  if (!_ccState) return;
+  const {groups} = ccResults(_ccState);
+  if (positiveIdx >= 0 && positiveIdx < groups.length) {
+    for (const key of groups[positiveIdx]) cl.verdicts.set(key, 'like');
+  }
+  if (negativeIdx === -1) {
+    // Pure anchor: all groups except positive -> negative
+    for (let i = 0; i < groups.length; i++) {
+      if (i === positiveIdx) continue;
+      for (const key of groups[i]) cl.verdicts.set(key, 'dislike');
+    }
+  } else if (negativeIdx >= 0 && negativeIdx < groups.length) {
+    for (const key of groups[negativeIdx]) {
+      cl.verdicts.set(key, 'dislike');
+    }
+  }
+  recomputeCounts(cl);
+  _ccState = null;
+  // Switch to split view: negative left, positive right
+  cl.splitView = true;
+  cl.splitFilters = ['negative', 'positive'];
+  m.redraw();
+}
+
+export function resetCrossCompare(cl: Cluster): void {
+  const keys = cl.traces.map((ts) => ts._key);
+  _ccState = createCrossCompareState(keys);
+  m.redraw();
+}
+
+// -- Session save / restore --
+
+interface SessionData {
+  version: 1;
+  activeClusterId: string | null;
+  clusters: {
+    id: string;
+    name: string;
+    traces: TraceEntry[];
+    verdicts: [string, Verdict][];
+    overviewFilter: OverviewFilter;
+    splitView: boolean;
+    splitFilters: [OverviewFilter, OverviewFilter];
+    splitRatio: number;
+    sortField?: 'index' | 'startup_dur';
+    sortDir?: 1 | -1;
+    propFilters?: [string, string[]][];
+    globalSlider?: number;
+  }[];
+}
+
+export function exportSession(): string {
+  const data: SessionData = {
+    version: 1,
+    activeClusterId: S.activeClusterId,
+    clusters: S.clusters.map((cl) => ({
+      id: cl.id,
+      name: cl.name,
+      traces: cl.traces.map((ts) => ts.trace),
+      verdicts: [...cl.verdicts.entries()],
+      overviewFilter: cl.overviewFilter,
+      splitView: cl.splitView,
+      splitFilters: cl.splitFilters,
+      splitRatio: cl.splitRatio,
+      sortField: cl.sortField,
+      sortDir: cl.sortDir,
+      propFilters: [...cl.propFilters.entries()].map(([k, v]) => [k, [...v]]),
+      globalSlider: cl.globalSlider,
+    })),
+  };
+  return JSON.stringify(data);
+}
+
+/** Hydrate pre-parsed session data into app state synchronously. */
+export function importSessionData(data: SessionData): void {
+  S.clusters = data.clusters.map((sc) => hydrateCluster(sc));
+  S.activeClusterId = data.activeClusterId;
+  m.redraw();
+}
+
+function hydrateCluster(sc: SessionData['clusters'][0]): Cluster {
+  const traces = sc.traces.map(initTraceLazy);
+  const cl: Cluster = {
+    id: sc.id,
+    name: sc.name,
+    traces,
+    verdicts: new Map(sc.verdicts),
+    overviewFilter: sc.overviewFilter,
+    counts: {positive: 0, negative: 0, pending: 0, discarded: 0},
+    tableSortState: {},
+    splitView: sc.splitView,
+    splitFilters: sc.splitFilters,
+    splitRatio: sc.splitRatio,
+    sortField: sc.sortField || 'index',
+    sortDir: sc.sortDir || 1,
+    propFilters: new Map(
+      (sc.propFilters || []).map(([k, v]) => [k, new Set(v)]),
+    ),
+    globalSlider: sc.globalSlider ?? 100,
+  };
+  recomputeCounts(cl);
+  if (traces.length > 0) ensureCache(traces[0]);
+  return cl;
+}
+
+/**
+ * Hydrate session data asynchronously, yielding to the event loop
+ * between clusters so the progress bar can update.
+ */
+export async function importSessionDataAsync(
+  data: SessionData,
+  onProgress?: (message: string, pct: number) => void,
+): Promise<void> {
+  const clusters: Cluster[] = [];
+  const total = data.clusters.length;
+  let processedTraces = 0;
+  const totalTraces = data.clusters.reduce(
+    (sum, sc) => sum + sc.traces.length,
+    0,
+  );
+
+  for (let i = 0; i < total; i++) {
+    const sc = data.clusters[i];
+    onProgress?.(
+      `Hydrating cluster ${i + 1}/${total}: ${sc.name} (${sc.traces.length} traces)`,
+      totalTraces > 0
+        ? (processedTraces / totalTraces) * 100
+        : (i / total) * 100,
+    );
+    // Yield so the UI can repaint the progress bar
+    await new Promise<void>((r) => setTimeout(r, 0));
+
+    clusters.push(hydrateCluster(sc));
+    processedTraces += sc.traces.length;
+  }
+
+  S.clusters = clusters;
+  S.activeClusterId = data.activeClusterId;
+  onProgress?.('Done', 100);
+  m.redraw();
+}
+
+/** Parse + hydrate a session JSON string. Sync -- use parseSessionAsync for large files. */
+export function importSession(json: string): void {
+  const data: SessionData = JSON.parse(json);
+  if (data.version !== 1) throw new Error('Unknown session version');
+  importSessionData(data);
+}
diff --git a/ui/src/plugins/com.google.QuantizedSlices/styles.scss b/ui/src/plugins/com.google.QuantizedSlices/styles.scss
new file mode 100644
index 0000000..ce6cd1f
--- /dev/null
+++ b/ui/src/plugins/com.google.QuantizedSlices/styles.scss
@@ -0,0 +1,1486 @@
+// Copyright (C) 2026 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+@use "../../assets/theme";
+
+// ============================================================================
+// QuantizedSlices plugin styles
+// All classes prefixed with .qs- to avoid collisions with Perfetto CSS.
+// Colors use Perfetto CSS variables — no light/dark media queries needed.
+// ============================================================================
+
+// ---------------------------------------------------------------------------
+// Page layout
+// ---------------------------------------------------------------------------
+.qs-page {
+  height: 100%;
+  padding: 20px 24px 56px;
+  font-size: 13px;
+  line-height: 1.5;
+  color: var(--pf-color-text);
+  overflow-y: auto;
+  overflow-x: hidden;
+  box-sizing: border-box;
+}
+
+.qs-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 16px;
+}
+
+.qs-title {
+  font-size: 15px;
+  font-weight: 600;
+  color: var(--pf-color-text);
+  letter-spacing: -0.2px;
+}
+
+.qs-header-stats {
+  font-size: 11px;
+  color: var(--pf-color-text-secondary);
+  font-family: var(--pf-font-family-monospace, monospace);
+  margin-top: 2px;
+}
+
+// ---------------------------------------------------------------------------
+// Tooltip
+// ---------------------------------------------------------------------------
+.qs-tooltip {
+  position: fixed;
+  background: var(--pf-color-background);
+  border: 1px solid var(--pf-color-border);
+  border-radius: theme.$border-radius-large;
+  padding: 11px 14px;
+  font-size: 11px;
+  line-height: 1.4;
+  pointer-events: none;
+  display: none;
+  z-index: 10001;
+  min-width: 280px;
+  max-width: 520px;
+  width: max-content;
+  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
+
+  .qs-tooltip-name {
+    font-weight: 600;
+    color: var(--pf-color-text);
+    font-size: 12px;
+    margin-bottom: 8px;
+    line-height: 1.4;
+    word-break: break-all;
+    white-space: normal;
+  }
+
+  .qs-tooltip-grid {
+    display: grid;
+    grid-template-columns: auto 1fr;
+    gap: 4px 10px;
+    align-items: baseline;
+  }
+
+  .qs-tooltip-key {
+    color: var(--pf-color-text-secondary);
+    font-size: 10px;
+    font-family: var(--pf-font-family-monospace, monospace);
+    white-space: nowrap;
+  }
+
+  .qs-tooltip-val {
+    color: var(--pf-color-text);
+    font-family: var(--pf-font-family-monospace, monospace);
+    font-size: 10px;
+    word-break: break-all;
+    white-space: normal;
+    line-height: 1.5;
+  }
+}
+
+// ---------------------------------------------------------------------------
+// Cards (shared)
+// ---------------------------------------------------------------------------
+.qs-card {
+  background: var(--pf-color-background);
+  border: 1px solid var(--pf-color-border);
+  border-radius: theme.$border-radius-large;
+}
+
+// ---------------------------------------------------------------------------
+// Buttons (shared)
+// ---------------------------------------------------------------------------
+.qs-btn {
+  font-size: 12px;
+  font-weight: 500;
+  padding: 6px 14px;
+  border-radius: theme.$border-radius-large;
+  cursor: pointer;
+  border: 1px solid var(--pf-color-border);
+  background: var(--pf-color-background);
+  color: var(--pf-color-text-secondary);
+  transition: all 0.15s;
+  white-space: nowrap;
+
+  &:hover {
+    background: var(--pf-color-border);
+    color: var(--pf-color-text);
+  }
+
+  &:disabled {
+    opacity: 0.4;
+    cursor: default;
+  }
+
+  &.primary {
+    background: color-mix(in srgb, var(--pf-color-accent) 10%, transparent);
+    color: var(--pf-color-accent);
+    border-color: color-mix(in srgb, var(--pf-color-accent) 30%, transparent);
+
+    &:hover {
+      background: color-mix(in srgb, var(--pf-color-accent) 20%, transparent);
+    }
+  }
+
+  &.active-split {
+    background: color-mix(in srgb, var(--pf-color-accent) 10%, transparent);
+    color: var(--pf-color-accent);
+    border-color: color-mix(in srgb, var(--pf-color-accent) 30%, transparent);
+  }
+}
+
+// ---------------------------------------------------------------------------
+// Import panel
+// ---------------------------------------------------------------------------
+.qs-import {
+  padding: 14px;
+
+  .qs-import-hint {
+    font-size: 11px;
+    color: var(--pf-color-text-secondary);
+    margin-bottom: 10px;
+
+    code {
+      font-family: var(--pf-font-family-monospace, monospace);
+      font-size: 10px;
+      color: var(--pf-color-text-secondary);
+      background: var(--pf-color-background);
+      border: 1px solid var(--pf-color-border);
+      padding: 1px 5px;
+      border-radius: theme.$border-radius;
+    }
+  }
+}
+
+.qs-json-area {
+  width: 100%;
+  min-height: 90px;
+  max-height: 180px;
+  background: var(--pf-color-background);
+  border: 1px solid var(--pf-color-border);
+  border-radius: theme.$border-radius-large;
+  color: var(--pf-color-text-secondary);
+  font-family: var(--pf-font-family-monospace, monospace);
+  font-size: 10px;
+  line-height: 1.6;
+  padding: 10px 12px;
+  resize: vertical;
+  outline: none;
+  transition: border-color 0.2s;
+
+  &:focus {
+    border-color: var(--pf-color-accent);
+  }
+}
+
+.qs-import-actions {
+  display: flex;
+  gap: 8px;
+  margin-top: 9px;
+  align-items: center;
+  flex-wrap: wrap;
+}
+
+.qs-msg-ok {
+  font-size: 10px;
+  color: var(--pf-color-success);
+  font-family: var(--pf-font-family-monospace, monospace);
+}
+
+.qs-msg-err {
+  font-size: 10px;
+  color: var(--pf-color-danger);
+  font-family: var(--pf-font-family-monospace, monospace);
+}
+
+// ---------------------------------------------------------------------------
+// Progress bar
+// ---------------------------------------------------------------------------
+.qs-progress {
+  margin-top: 10px;
+
+  .qs-progress-text {
+    font-size: 10px;
+    color: var(--pf-color-text-secondary);
+    font-family: var(--pf-font-family-monospace, monospace);
+    margin-bottom: 4px;
+  }
+
+  .qs-progress-bar {
+    height: 3px;
+    border-radius: theme.$border-radius;
+    background: var(--pf-color-border);
+    overflow: hidden;
+  }
+
+  .qs-progress-fill {
+    height: 100%;
+    background: var(--pf-color-accent);
+    border-radius: theme.$border-radius;
+    transition: width 0.2s ease-out;
+  }
+}
+
+@keyframes qs-pulse {
+  0%,
+  100% {
+    opacity: 0.4;
+  }
+  50% {
+    opacity: 1;
+  }
+}
+
+// ---------------------------------------------------------------------------
+// Trace list toolbar
+// ---------------------------------------------------------------------------
+.qs-list-toolbar {
+  display: flex;
+  align-items: center;
+  padding: 8px 16px;
+  margin-bottom: 14px;
+  gap: 10px;
+}
+
+.qs-list-filters {
+  display: flex;
+  gap: 4px;
+  flex-shrink: 0;
+}
+
+.qs-filter-btn {
+  font-size: 11px;
+  font-weight: 500;
+  padding: 4px 10px;
+  border-radius: theme.$border-radius-large;
+  cursor: pointer;
+  border: 1px solid var(--pf-color-border);
+  background: var(--pf-color-background);
+  color: var(--pf-color-text-secondary);
+  transition: all 0.15s;
+  display: flex;
+  align-items: center;
+  gap: 5px;
+  white-space: nowrap;
+
+  &:hover {
+    background: var(--pf-color-border);
+    color: var(--pf-color-text);
+  }
+
+  &.active {
+    background: color-mix(in srgb, var(--pf-color-accent) 10%, transparent);
+    color: var(--pf-color-accent);
+    border-color: color-mix(in srgb, var(--pf-color-accent) 30%, transparent);
+  }
+}
+
+.qs-filter-count {
+  font-family: var(--pf-font-family-monospace, monospace);
+  font-size: 9px;
+  background: var(--pf-color-background);
+  border-radius: theme.$border-radius-large;
+  padding: 0 5px;
+  min-width: 18px;
+  text-align: center;
+}
+
+.qs-fc-positive {
+  color: var(--pf-color-success);
+}
+
+.qs-fc-negative {
+  color: var(--pf-color-danger);
+}
+
+.qs-fc-pending {
+  color: var(--pf-color-text-secondary);
+}
+
+.qs-fc-discarded {
+  color: var(--pf-color-text-secondary);
+  opacity: 0.6;
+}
+
+.qs-fc-all {
+  color: var(--pf-color-text-secondary);
+}
+
+.qs-list-actions {
+  display: flex;
+  gap: 6px;
+  flex-shrink: 0;
+}
+
+// ---------------------------------------------------------------------------
+// Filter dropdown
+// ---------------------------------------------------------------------------
+.qs-filter-dropdown-wrap {
+  position: relative;
+}
+
+.qs-filter-dropdown {
+  position: absolute;
+  top: 100%;
+  right: 0;
+  margin-top: 4px;
+  z-index: 100;
+  background: var(--pf-color-background);
+  border: 1px solid var(--pf-color-border);
+  border-radius: theme.$border-radius-large;
+  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
+  padding: 8px 0;
+  min-width: 200px;
+  max-height: 360px;
+  overflow-y: auto;
+
+  .qs-filter-field {
+    padding: 4px 12px;
+
+    & + .qs-filter-field {
+      border-top: 1px solid var(--pf-color-border);
+      padding-top: 8px;
+      margin-top: 4px;
+    }
+  }
+
+  .qs-filter-field-header {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    gap: 8px;
+    margin-bottom: 4px;
+  }
+
+  .qs-filter-field-name {
+    font-size: 10px;
+    font-weight: 600;
+    letter-spacing: 0.06em;
+    text-transform: uppercase;
+    color: var(--pf-color-text-secondary);
+  }
+
+  .qs-filter-clear {
+    font-size: 9px;
+    color: var(--pf-color-accent);
+    background: none;
+    border: none;
+    cursor: pointer;
+    padding: 0;
+
+    &:hover {
+      text-decoration: underline;
+    }
+  }
+
+  .qs-filter-field-values {
+    display: flex;
+    flex-direction: column;
+    gap: 2px;
+  }
+
+  .qs-filter-value-label {
+    display: flex;
+    align-items: center;
+    gap: 6px;
+    font-size: 11px;
+    color: var(--pf-color-text-secondary);
+    cursor: pointer;
+    padding: 2px 0;
+
+    &:hover {
+      color: var(--pf-color-text);
+    }
+
+    input[type="checkbox"] {
+      accent-color: var(--pf-color-accent);
+    }
+  }
+}
+
+// ---------------------------------------------------------------------------
+// Export dropdown
+// ---------------------------------------------------------------------------
+.qs-export-dropdown {
+  position: absolute;
+  top: 100%;
+  right: 0;
+  margin-top: 4px;
+  z-index: 100;
+  background: var(--pf-color-background);
+  border: 1px solid var(--pf-color-border);
+  border-radius: theme.$border-radius-large;
+  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
+  padding: 6px 0;
+  min-width: 120px;
+
+  .qs-export-section-label {
+    font-size: 9px;
+    font-weight: 600;
+    letter-spacing: 0.06em;
+    text-transform: uppercase;
+    color: var(--pf-color-text-secondary);
+    padding: 4px 12px 2px;
+  }
+
+  .qs-export-item {
+    display: block;
+    width: 100%;
+    text-align: left;
+    padding: 5px 12px;
+    font-size: 11px;
+    color: var(--pf-color-text-secondary);
+    background: none;
+    border: none;
+    cursor: pointer;
+    transition: all 0.1s;
+
+    &:hover {
+      background: color-mix(in srgb, var(--pf-color-accent) 10%, transparent);
+      color: var(--pf-color-accent);
+    }
+  }
+}
+
+// ---------------------------------------------------------------------------
+// Trace cards
+// ---------------------------------------------------------------------------
+.qs-trace-list {
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+}
+
+.qs-show-more {
+  width: 100%;
+  padding: 8px;
+  font-size: 11px;
+  text-align: center;
+}
+
+.qs-trace-card {
+  overflow: hidden;
+  transition: border-color 0.2s;
+
+  &.verdict-positive {
+    border-color: var(--pf-color-success);
+  }
+
+  &.verdict-negative {
+    border-color: var(--pf-color-danger);
+  }
+
+  &.verdict-discard {
+    border-color: var(--pf-color-text-secondary);
+    opacity: 0.5;
+  }
+}
+
+.qs-card-header {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  cursor: pointer;
+  padding: 8px 14px;
+  user-select: none;
+
+  &:hover {
+    background: color-mix(in srgb, var(--pf-color-text) 3%, transparent);
+  }
+
+  .qs-collapse-arrow {
+    font-size: 10px;
+    color: var(--pf-color-text-secondary);
+    transition: transform 0.15s;
+
+    &.open {
+      transform: rotate(90deg);
+    }
+  }
+
+  .qs-trace-idx {
+    font-family: var(--pf-font-family-monospace, monospace);
+    font-size: 10px;
+    color: var(--pf-color-text-secondary);
+    min-width: 28px;
+  }
+
+  .qs-trace-pkg {
+    font-size: 11px;
+    font-weight: 500;
+    color: var(--pf-color-text);
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+  }
+
+  .qs-trace-startup-dur {
+    font-family: var(--pf-font-family-monospace, monospace);
+    font-size: 12px;
+    font-weight: 700;
+    color: var(--pf-color-accent);
+    white-space: nowrap;
+  }
+
+  .qs-trace-actions {
+    margin-left: auto;
+    display: flex;
+    gap: 4px;
+    flex-shrink: 0;
+  }
+}
+
+.qs-trace-link {
+  font-size: 12px;
+  color: var(--pf-color-accent);
+  text-decoration: none;
+  opacity: 0.6;
+  transition: opacity 0.15s;
+
+  &:hover {
+    opacity: 1;
+  }
+}
+
+.qs-verdict-btn-sm {
+  font-family: var(--pf-font-family-monospace, monospace);
+  font-size: 14px;
+  font-weight: 700;
+  width: 32px;
+  height: 32px;
+  border-radius: theme.$border-radius-large;
+  cursor: pointer;
+  border: 1px solid var(--pf-color-border);
+  background: var(--pf-color-background);
+  color: var(--pf-color-text-secondary);
+  transition: all 0.12s;
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  line-height: 1;
+
+  &:hover {
+    transform: translateY(-1px);
+    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+  }
+}
+
+.qs-active-positive {
+  background: var(--pf-color-success);
+  color: #fff;
+  border-color: var(--pf-color-success);
+}
+
+.qs-active-negative {
+  background: var(--pf-color-danger);
+  color: #fff;
+  border-color: var(--pf-color-danger);
+}
+
+.qs-active-discard {
+  background: var(--pf-color-text-secondary);
+  color: #fff;
+  border-color: var(--pf-color-text-secondary);
+}
+
+// ---------------------------------------------------------------------------
+// Trace card body & detail
+// ---------------------------------------------------------------------------
+.qs-trace-card-body {
+  padding: 6px 14px 10px;
+}
+
+.qs-trace-slider {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  padding: 4px 0;
+
+  .qs-slider-label {
+    font-size: 10px;
+    color: var(--pf-color-text-secondary);
+    white-space: nowrap;
+  }
+
+  .qs-slider-num {
+    font-family: var(--pf-font-family-monospace, monospace);
+    font-size: 12px;
+    font-weight: 500;
+    color: var(--pf-color-text);
+    min-width: 24px;
+  }
+
+  .qs-slider-of {
+    font-family: var(--pf-font-family-monospace, monospace);
+    font-size: 10px;
+    color: var(--pf-color-text-secondary);
+  }
+}
+
+.qs-global-slider {
+  padding: 0;
+  flex: 1;
+  min-width: 0;
+
+  input[type="range"] {
+    flex: 1;
+    min-width: 60px;
+  }
+}
+
+.qs-card-body {
+  padding: 6px 14px 10px;
+}
+
+.qs-card-detail {
+  padding: 10px 14px;
+  display: flex;
+  flex-direction: column;
+  gap: 16px;
+  overflow: auto;
+  max-height: 600px;
+}
+
+.qs-detail-label {
+  font-size: 10px;
+  font-weight: 600;
+  letter-spacing: 0.08em;
+  text-transform: uppercase;
+  color: var(--pf-color-text-secondary);
+  margin-bottom: 6px;
+}
+
+.qs-detail-meta {
+  padding: 10px 0 0;
+}
+
+// Key-value grid for trace metadata (UUID, package, startup, extras)
+.qs-tt-grid {
+  display: grid;
+  grid-template-columns: auto 1fr;
+  gap: 5px 14px;
+  align-items: baseline;
+}
+
+.qs-tt-k {
+  font-size: 10px;
+  font-weight: 600;
+  letter-spacing: 0.04em;
+  text-transform: uppercase;
+  color: var(--pf-color-text-secondary);
+  font-family: var(--pf-font-family-monospace, monospace);
+  white-space: nowrap;
+}
+
+.qs-tt-v {
+  font-size: 11px;
+  color: var(--pf-color-text);
+  font-family: var(--pf-font-family-monospace, monospace);
+  word-break: break-all;
+  line-height: 1.5;
+}
+
+// ---------------------------------------------------------------------------
+// Range input styling
+// ---------------------------------------------------------------------------
+.qs-page input[type="range"] {
+  -webkit-appearance: none;
+  appearance: none;
+  width: 200px;
+  height: 2px;
+  border-radius: 1px;
+  background: var(--pf-color-border);
+  outline: none;
+  cursor: pointer;
+  transition: background 0.2s;
+
+  &::-webkit-slider-thumb {
+    -webkit-appearance: none;
+    width: 11px;
+    height: 11px;
+    border-radius: 50%;
+    background: var(--pf-color-accent);
+    cursor: pointer;
+    box-shadow: 0 0 0 3px
+      color-mix(in srgb, var(--pf-color-accent) 15%, transparent);
+  }
+}
+
+// ---------------------------------------------------------------------------
+// Mini timeline canvas
+// ---------------------------------------------------------------------------
+.qs-mini-canvas {
+  padding: 4px 0;
+
+  canvas {
+    display: block;
+    width: 100%;
+    height: 30px;
+    cursor: crosshair;
+  }
+}
+
+// ---------------------------------------------------------------------------
+// Summary tables
+// ---------------------------------------------------------------------------
+.qs-summary-grid {
+  display: grid;
+  grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
+  gap: 14px;
+}
+
+.qs-table-card {
+  overflow: hidden;
+
+  .qs-table-card-head {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    padding: 9px 14px;
+    border-bottom: 1px solid var(--pf-color-border);
+    background: var(--pf-color-background);
+    font-size: 10px;
+    font-weight: 600;
+    letter-spacing: 0.08em;
+    text-transform: uppercase;
+    color: var(--pf-color-text-secondary);
+  }
+
+  .qs-table-scroll {
+    max-height: 260px;
+    overflow-y: auto;
+
+    &::-webkit-scrollbar {
+      width: 4px;
+    }
+
+    &::-webkit-scrollbar-track {
+      background: transparent;
+    }
+
+    &::-webkit-scrollbar-thumb {
+      background: var(--pf-color-border);
+      border-radius: theme.$border-radius;
+    }
+  }
+}
+
+table.qs-summary {
+  width: 100%;
+  border-collapse: collapse;
+
+  thead th {
+    position: sticky;
+    top: 0;
+    background: var(--pf-color-background);
+    padding: 6px 14px;
+    font-size: 10px;
+    font-weight: 600;
+    color: var(--pf-color-text-secondary);
+    text-align: left;
+    letter-spacing: 0.06em;
+    text-transform: uppercase;
+    border-bottom: 1px solid var(--pf-color-border);
+    cursor: pointer;
+    user-select: none;
+    white-space: nowrap;
+
+    &:hover {
+      color: var(--pf-color-text);
+    }
+
+    .sort-arrow {
+      margin-left: 4px;
+      opacity: 0.4;
+    }
+
+    &.sorted .sort-arrow {
+      opacity: 1;
+      color: var(--pf-color-accent);
+    }
+
+    &:not(:first-child) {
+      text-align: right;
+    }
+  }
+
+  tbody {
+    tr {
+      transition: background 0.1s;
+
+      &:hover {
+        background: color-mix(in srgb, var(--pf-color-text) 3%, transparent);
+      }
+    }
+
+    td {
+      padding: 5px 14px;
+      font-size: 11px;
+      color: var(--pf-color-text-secondary);
+      vertical-align: middle;
+
+      &:first-child {
+        color: var(--pf-color-text);
+        max-width: 220px;
+        font-size: 11px;
+      }
+
+      &:not(:first-child) {
+        font-family: var(--pf-font-family-monospace, monospace);
+        font-size: 10px;
+        color: var(--pf-color-text-secondary);
+        text-align: right;
+        white-space: nowrap;
+      }
+    }
+  }
+}
+
+.qs-cell-label {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+}
+
+.qs-swatch {
+  width: 7px;
+  height: 7px;
+  border-radius: 1px;
+  flex-shrink: 0;
+  display: inline-block;
+}
+
+.qs-name-text {
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  max-width: 180px;
+  display: block;
+}
+
+.qs-bar-wrap {
+  margin-top: 3px;
+  height: 2px;
+  background: var(--pf-color-border);
+  border-radius: 1px;
+  overflow: hidden;
+}
+
+.qs-bar-fill {
+  height: 2px;
+  border-radius: 1px;
+}
+
+// ---------------------------------------------------------------------------
+// Cluster tabs
+// ---------------------------------------------------------------------------
+.qs-cluster-tabs {
+  display: flex;
+  gap: 2px;
+  margin-bottom: 16px;
+  flex-wrap: wrap;
+  border-bottom: 1px solid var(--pf-color-border);
+  padding-bottom: 0;
+}
+
+.qs-cluster-tab {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  padding: 7px 14px;
+  font-size: 11px;
+  font-weight: 500;
+  color: var(--pf-color-text-secondary);
+  cursor: pointer;
+  border: 1px solid var(--pf-color-border);
+  border-bottom: none;
+  border-radius: theme.$border-radius-large theme.$border-radius-large 0 0;
+  background: var(--pf-color-background);
+  transition: all 0.15s;
+  position: relative;
+  top: 1px;
+
+  &:hover {
+    color: var(--pf-color-text);
+  }
+
+  &.active {
+    color: var(--pf-color-accent);
+    border-bottom: 1px solid var(--pf-color-background);
+    font-weight: 600;
+  }
+
+  .qs-cluster-count {
+    font-family: var(--pf-font-family-monospace, monospace);
+    font-size: 9px;
+    color: var(--pf-color-text-secondary);
+  }
+
+  .qs-cluster-close {
+    font-size: 14px;
+    line-height: 1;
+    color: var(--pf-color-text-secondary);
+    background: none;
+    border: none;
+    cursor: pointer;
+    padding: 0 2px;
+    border-radius: theme.$border-radius;
+    transition: all 0.1s;
+
+    &:hover {
+      color: var(--pf-color-danger);
+      background: color-mix(in srgb, var(--pf-color-danger) 10%, transparent);
+    }
+  }
+}
+
+// ---------------------------------------------------------------------------
+// Split view
+// ---------------------------------------------------------------------------
+.qs-split-container {
+  height: calc(100vh - 280px);
+  min-height: 300px;
+}
+
+.qs-split-panel-inner {
+  display: flex;
+  flex-direction: column;
+  height: 100%;
+  min-width: 0;
+  overflow: hidden;
+}
+
+.qs-split-panel {
+  display: flex;
+  flex-direction: column;
+  min-width: 200px;
+  overflow: hidden;
+}
+
+.qs-split-panel-header {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  padding: 6px 8px;
+  background: var(--pf-color-background);
+  flex-wrap: wrap;
+
+  .qs-list-filters {
+    gap: 2px;
+  }
+
+  .qs-filter-btn {
+    padding: 2px 8px;
+    font-size: 10px;
+  }
+
+  .qs-filter-count {
+    font-size: 8px;
+    padding: 0 3px;
+    min-width: 14px;
+  }
+}
+
+.qs-split-count {
+  font-family: var(--pf-font-family-monospace, monospace);
+  font-size: 9px;
+  color: var(--pf-color-text-secondary);
+  margin-left: auto;
+  white-space: nowrap;
+}
+
+.qs-split-panel-body {
+  flex: 1;
+  overflow-y: auto;
+  padding: 8px;
+  background: var(--pf-color-background);
+
+  &::-webkit-scrollbar {
+    width: 4px;
+  }
+
+  &::-webkit-scrollbar-track {
+    background: transparent;
+  }
+
+  &::-webkit-scrollbar-thumb {
+    background: var(--pf-color-border);
+    border-radius: theme.$border-radius;
+  }
+}
+
+.qs-split-divider {
+  width: 6px;
+  cursor: col-resize;
+  background: var(--pf-color-border);
+  flex-shrink: 0;
+  position: relative;
+  margin: 0 1px;
+  transition: background 0.15s;
+
+  &:hover {
+    background: var(--pf-color-accent);
+  }
+
+  &::after {
+    content: "";
+    position: absolute;
+    top: 50%;
+    left: 50%;
+    transform: translate(-50%, -50%);
+    width: 2px;
+    height: 32px;
+    background: var(--pf-color-text-secondary);
+    border-radius: 1px;
+  }
+}
+
+// ---------------------------------------------------------------------------
+// Cross Compare modal
+// ---------------------------------------------------------------------------
+.qs-cc-overlay {
+  position: fixed;
+  inset: 0;
+  z-index: 10000;
+  background: rgba(0, 0, 0, 0.6);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.qs-cc-modal {
+  background: var(--pf-color-background);
+  border: 1px solid var(--pf-color-border);
+  border-radius: theme.$border-radius-large;
+  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
+  max-width: 1200px;
+  width: 95vw;
+  max-height: 90vh;
+  display: flex;
+  flex-direction: column;
+  overflow: hidden;
+}
+
+.qs-cc-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 12px 20px;
+  border-bottom: 1px solid var(--pf-color-border);
+
+  .qs-cc-title {
+    font-size: 13px;
+    font-weight: 600;
+    color: var(--pf-color-text);
+  }
+}
+
+.qs-cc-close {
+  background: none;
+  border: none;
+  font-size: 18px;
+  cursor: pointer;
+  color: var(--pf-color-text-secondary);
+  padding: 0 4px;
+  line-height: 1;
+
+  &:hover {
+    color: var(--pf-color-text);
+  }
+}
+
+.qs-cc-progress {
+  padding: 10px 20px;
+
+  .qs-cc-progress-text {
+    font-size: 10px;
+    color: var(--pf-color-text-secondary);
+    font-family: var(--pf-font-family-monospace, monospace);
+    margin-bottom: 4px;
+  }
+}
+
+.qs-cc-progress-bar {
+  height: 4px;
+  background: var(--pf-color-border);
+  border-radius: theme.$border-radius;
+  overflow: hidden;
+}
+
+.qs-cc-progress-fill {
+  height: 100%;
+  background: var(--pf-color-accent);
+  border-radius: theme.$border-radius;
+  transition: width 0.2s;
+}
+
+.qs-cc-body {
+  flex: 1;
+  overflow-y: auto;
+  padding: 16px 20px;
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+}
+
+.qs-cc-pair {
+  display: flex;
+  gap: 12px;
+  align-items: stretch;
+}
+
+.qs-cc-pair-divider {
+  display: flex;
+  align-items: center;
+  font-size: 11px;
+  font-weight: 600;
+  color: var(--pf-color-text-secondary);
+  padding: 0 4px;
+}
+
+.qs-cc-panel {
+  flex: 1;
+  border: 2px solid var(--pf-color-border);
+  border-radius: theme.$border-radius-large;
+  padding: 10px;
+  display: flex;
+  flex-direction: column;
+  gap: 6px;
+  transition: border-color 0.15s;
+  overflow: hidden;
+  cursor: pointer;
+
+  &.selected {
+    border-color: var(--pf-color-accent);
+  }
+
+  &.anchored {
+    border-color: var(--pf-color-accent);
+    background: color-mix(in srgb, var(--pf-color-accent) 5%, transparent);
+  }
+
+  .qs-cc-panel-header {
+    display: flex;
+    align-items: center;
+    gap: 8px;
+  }
+
+  .qs-cc-panel-idx {
+    font-family: var(--pf-font-family-monospace, monospace);
+    font-size: 10px;
+    color: var(--pf-color-text-secondary);
+  }
+
+  .qs-cc-panel-pkg {
+    font-size: 11px;
+    font-weight: 500;
+    color: var(--pf-color-text);
+  }
+
+  .qs-cc-panel-dur {
+    font-family: var(--pf-font-family-monospace, monospace);
+    font-size: 12px;
+    font-weight: 700;
+    color: var(--pf-color-accent);
+    margin-left: auto;
+  }
+
+  .qs-cc-panel-detail {
+    max-height: 200px;
+    overflow-y: auto;
+  }
+}
+
+.qs-cc-anchor-badge {
+  font-size: 9px;
+  font-weight: 600;
+  text-transform: uppercase;
+  letter-spacing: 0.05em;
+  color: var(--pf-color-accent);
+  background: color-mix(in srgb, var(--pf-color-accent) 10%, transparent);
+  padding: 1px 5px;
+  border-radius: theme.$border-radius-large;
+  margin-left: auto;
+}
+
+.qs-cc-actions {
+  display: flex;
+  gap: 10px;
+  justify-content: center;
+  padding: 4px 0;
+
+  .qs-cc-action-btn {
+    padding: 7px 16px;
+    border-radius: theme.$border-radius-large;
+    font-size: 11px;
+    font-weight: 600;
+    cursor: pointer;
+    border: 1px solid var(--pf-color-border);
+    background: var(--pf-color-background);
+    color: var(--pf-color-text-secondary);
+    display: flex;
+    align-items: center;
+    gap: 6px;
+    transition: all 0.1s;
+
+    &:hover {
+      background: var(--pf-color-border);
+      color: var(--pf-color-text);
+    }
+
+    &.positive {
+      background: color-mix(in srgb, var(--pf-color-success) 15%, transparent);
+      color: var(--pf-color-success);
+      border-color: var(--pf-color-success);
+
+      &:hover {
+        background: color-mix(
+          in srgb,
+          var(--pf-color-success) 25%,
+          transparent
+        );
+      }
+    }
+
+    &.negative {
+      background: color-mix(in srgb, var(--pf-color-danger) 15%, transparent);
+      color: var(--pf-color-danger);
+      border-color: var(--pf-color-danger);
+
+      &:hover {
+        background: color-mix(in srgb, var(--pf-color-danger) 25%, transparent);
+      }
+    }
+
+    &.discard {
+      background: var(--pf-color-background);
+      color: var(--pf-color-text-secondary);
+      border-color: var(--pf-color-border);
+
+      &:hover {
+        background: var(--pf-color-border);
+        color: var(--pf-color-text-secondary);
+      }
+    }
+
+    kbd {
+      font-family: var(--pf-font-family-monospace, monospace);
+      font-size: 9px;
+      padding: 1px 4px;
+      border: 1px solid currentColor;
+      border-radius: theme.$border-radius;
+      opacity: 0.7;
+      color: inherit;
+    }
+  }
+}
+
+.qs-cc-slider {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  justify-content: center;
+  padding: 2px 0;
+
+  input[type="range"] {
+    width: 200px;
+  }
+}
+
+.qs-cc-hint {
+  font-size: 10px;
+  color: var(--pf-color-text-secondary);
+  text-align: center;
+}
+
+.qs-cc-footer {
+  display: flex;
+  gap: 8px;
+  justify-content: center;
+  padding: 8px 20px;
+  border-top: 1px solid var(--pf-color-border);
+}
+
+// ---------------------------------------------------------------------------
+// Cross Compare review screen
+// ---------------------------------------------------------------------------
+.qs-cc-review {
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+  flex: 1;
+  overflow: hidden;
+}
+
+.qs-cc-review-split {
+  display: flex;
+  gap: 12px;
+  flex: 1;
+  overflow: hidden;
+  padding: 12px 20px 0;
+}
+
+.qs-cc-review-panel {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  border: 1px solid var(--pf-color-border);
+  border-radius: theme.$border-radius-large;
+  overflow: hidden;
+  min-width: 0;
+}
+
+.qs-cc-review-panel-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 8px 12px;
+  font-size: 11px;
+  font-weight: 600;
+  border-bottom: 1px solid var(--pf-color-border);
+
+  &.positive {
+    background: color-mix(in srgb, var(--pf-color-success) 10%, transparent);
+    color: var(--pf-color-success);
+  }
+
+  &.negative {
+    background: color-mix(in srgb, var(--pf-color-danger) 10%, transparent);
+    color: var(--pf-color-danger);
+  }
+}
+
+.qs-cc-review-count {
+  font-family: var(--pf-font-family-monospace, monospace);
+  font-size: 10px;
+  padding: 1px 6px;
+  border-radius: theme.$border-radius-large;
+  background: var(--pf-color-background);
+}
+
+.qs-cc-review-panel-body {
+  flex: 1;
+  overflow-y: auto;
+  padding: 6px;
+  display: flex;
+  flex-direction: column;
+  gap: 4px;
+}
+
+.qs-cc-review-row {
+  padding: 6px 8px;
+  border-radius: theme.$border-radius-large;
+  border: 1px solid var(--pf-color-border);
+  background: var(--pf-color-background);
+
+  .qs-cc-review-row-header {
+    display: flex;
+    align-items: center;
+    gap: 6px;
+    margin-bottom: 2px;
+  }
+}
+
+.qs-cc-review-nav {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  gap: 12px;
+  padding: 2px 0;
+}
+
+// ---------------------------------------------------------------------------
+// Legend
+// ---------------------------------------------------------------------------
+.qs-legend {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 14px;
+  align-items: center;
+
+  .qs-legend-item {
+    display: flex;
+    align-items: center;
+    gap: 5px;
+    font-size: 11px;
+    color: var(--pf-color-text-secondary);
+  }
+
+  .qs-legend-swatch {
+    width: 8px;
+    height: 8px;
+    border-radius: 1px;
+    flex-shrink: 0;
+  }
+}
+
+// ---------------------------------------------------------------------------
+// Section headings
+// ---------------------------------------------------------------------------
+.qs-section {
+  margin-bottom: 20px;
+}
+
+.qs-section-head {
+  font-size: 10px;
+  font-weight: 600;
+  letter-spacing: 0.1em;
+  text-transform: uppercase;
+  color: var(--pf-color-text-secondary);
+  margin-top: 16px;
+  margin-bottom: 10px;
+  display: flex;
+  align-items: center;
+  gap: 10px;
+
+  &::after {
+    content: "";
+    flex: 1;
+    height: 1px;
+    background: var(--pf-color-border);
+  }
+}
+
+// ---------------------------------------------------------------------------
+// Canvas (full timeline)
+// ---------------------------------------------------------------------------
+.qs-canvas-wrap {
+  padding: 14px 14px 10px;
+  overflow: hidden;
+
+  canvas {
+    display: block;
+    width: 100%;
+    cursor: crosshair;
+  }
+}
diff --git a/ui/src/plugins/com.google.QuantizedSlices/utils/colors.ts b/ui/src/plugins/com.google.QuantizedSlices/utils/colors.ts
new file mode 100644
index 0000000..b8286fe
--- /dev/null
+++ b/ui/src/plugins/com.google.QuantizedSlices/utils/colors.ts
@@ -0,0 +1,76 @@
+// Copyright (C) 2026 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import {HSLColor, HSLuvColor} from '../../../base/color';
+import {hash} from '../../../base/hash';
+
+const nameColorCache = new Map<string, string>();
+
+export function nameColor(name: string): string {
+  const seed = name.replace(/( )?\d+/g, '');
+  if (nameColorCache.has(seed)) return nameColorCache.get(seed)!;
+  const hue = hash(seed, 360);
+  const lightness = hash(seed + 'x', 40) + 40;
+  const color = new HSLuvColor([hue, 80, lightness]);
+  nameColorCache.set(seed, color.cssString);
+  return color.cssString;
+}
+
+export function isDark(): boolean {
+  return !!document.querySelector('.pf-theme-provider--dark');
+}
+
+// Thread state colors — using Perfetto's HSLColor.
+const STATE_RUNNING = new HSLColor([120, 44, 34]).cssString;
+const STATE_RUNNABLE = new HSLColor([75, 55, 47]).cssString;
+const STATE_IO_WAIT = new HSLColor([36, 100, 50]).cssString;
+const STATE_NONIO = new HSLColor([3, 30, 49]).cssString;
+const STATE_CREATED = new HSLColor([0, 0, 70]).cssString;
+const STATE_UNKNOWN = new HSLColor([44, 63, 91]).cssString;
+const STATE_DEAD = new HSLColor([0, 0, 62]).cssString;
+const STATE_INDIGO = new HSLColor([231, 48, 48]).cssString;
+
+export function stateColor(d: {
+  state: string | null;
+  io_wait: number | null;
+}): string {
+  const s = d.state;
+  if (!s) return isDark() ? '#44444e' : STATE_UNKNOWN;
+  if (s === 'Created') return STATE_CREATED;
+  if (s === 'Running') return STATE_RUNNING;
+  if (s.startsWith('Runnable')) return STATE_RUNNABLE;
+  if (s.includes('Uninterruptible Sleep')) {
+    if (s.includes('non-IO') || d.io_wait === 0) return STATE_NONIO;
+    return STATE_IO_WAIT;
+  }
+  if (s.includes('Dead')) return STATE_DEAD;
+  if (s.includes('Sleeping') || s.includes('Idle')) {
+    return isDark() ? '#2a2a3a' : '#ffffff';
+  }
+  if (s.includes('Unknown')) return STATE_UNKNOWN;
+  return STATE_INDIGO;
+}
+
+export function stateLabel(d: {
+  state: string | null;
+  io_wait: number | null;
+}): string {
+  if (!d.state) return 'Unknown';
+  if (d.state === 'Uninterruptible Sleep') {
+    if (d.io_wait === 1) return 'Unint. Sleep (IO)';
+    if (d.io_wait === 0) return 'Unint. Sleep (non-IO)';
+    return 'Unint. Sleep';
+  }
+  return d.state;
+}
diff --git a/ui/src/plugins/com.google.QuantizedSlices/utils/export.ts b/ui/src/plugins/com.google.QuantizedSlices/utils/export.ts
new file mode 100644
index 0000000..c922459
--- /dev/null
+++ b/ui/src/plugins/com.google.QuantizedSlices/utils/export.ts
@@ -0,0 +1,114 @@
+// Copyright (C) 2026 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import {Verdict, TRACE_VIEWER_BASE_URL} from '../models/types';
+
+export interface ExportRow {
+  trace_uuid: string;
+  package_name: string;
+  startup_dur: number;
+  tab_name: string;
+  verdict: string;
+  link: string;
+  [key: string]: unknown;
+}
+
+export interface ExportableTrace {
+  trace_uuid: string;
+  package_name: string;
+  startup_dur: number;
+  extra?: Record<string, unknown>;
+}
+
+const EXCLUDED_EXTRA = new Set([
+  'slices',
+  'quantized_sequence',
+  'quantized_sequence_json',
+  'quantized_sequence_base64',
+]);
+
+export function buildTraceLink(uuid: string, packageName?: string): string {
+  if (!uuid) return '';
+  let url = `${TRACE_VIEWER_BASE_URL}?uuid=${uuid}`;
+  if (packageName) {
+    url += `&query=${encodeURIComponent(`com.android.AndroidStartup.packageName=${packageName}`)}`;
+  }
+  return url;
+}
+
+function verdictLabel(v: Verdict | undefined): string {
+  if (v === 'like') return 'positive';
+  if (v === 'dislike') return 'negative';
+  if (v === 'discard') return 'discarded';
+  return 'pending';
+}
+
+export function traceExportRow(
+  trace: ExportableTrace,
+  traceKey: string,
+  tabName: string,
+  verdicts: Map<string, Verdict>,
+): ExportRow {
+  const row: ExportRow = {
+    trace_uuid: trace.trace_uuid,
+    package_name: trace.package_name,
+    startup_dur: trace.startup_dur,
+    tab_name: tabName,
+    verdict: verdictLabel(verdicts.get(traceKey)),
+    link: buildTraceLink(trace.trace_uuid, trace.package_name),
+  };
+  if (trace.extra) {
+    for (const [k, v] of Object.entries(trace.extra)) {
+      if (!EXCLUDED_EXTRA.has(k)) row[k] = v;
+    }
+  }
+  return row;
+}
+
+const FIXED_COLS = [
+  'trace_uuid',
+  'package_name',
+  'startup_dur',
+  'tab_name',
+  'verdict',
+  'link',
+];
+
+function tsvEscape(v: unknown): string {
+  if (v == null) return '';
+  if (typeof v === 'object') {
+    return JSON.stringify(v).replace(/[\t\n\r]/g, ' ');
+  }
+  return String(v).replace(/[\t\n\r]/g, ' ');
+}
+
+export function rowsToTsv(rows: ExportRow[]): string {
+  if (rows.length === 0) return '';
+  const extraCols = new Set<string>();
+  for (const row of rows) {
+    for (const k of Object.keys(row)) {
+      if (!FIXED_COLS.includes(k)) extraCols.add(k);
+    }
+  }
+  const cols = [...FIXED_COLS, ...[...extraCols].sort()];
+  const header = cols.join('\t');
+  const lines = rows.map((row) =>
+    cols.map((c) => tsvEscape(row[c])).join('\t'),
+  );
+  return header + '\n' + lines.join('\n');
+}
+
+export function rowsToJson(rows: ExportRow[]): string {
+  return JSON.stringify(rows, null, 2);
+}
diff --git a/ui/src/plugins/com.google.QuantizedSlices/utils/format.ts b/ui/src/plugins/com.google.QuantizedSlices/utils/format.ts
new file mode 100644
index 0000000..68c97eb
--- /dev/null
+++ b/ui/src/plugins/com.google.QuantizedSlices/utils/format.ts
@@ -0,0 +1,24 @@
+// Copyright (C) 2026 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+export function fmtDur(ns: number): string {
+  if (ns >= 1e9) return (ns / 1e9).toFixed(3) + ' s';
+  if (ns >= 1e6) return (ns / 1e6).toFixed(1) + ' ms';
+  return (ns / 1e3).toFixed(0) + ' \u00b5s';
+}
+
+export function fmtPct(ns: number, total: number): string {
+  if (total === 0) return '0.0%';
+  return ((ns / total) * 100).toFixed(1) + '%';
+}