diff --git a/app/src/App.css b/app/src/App.css
index ebfac2e..a3db9a7 100644
--- a/app/src/App.css
+++ b/app/src/App.css
@@ -324,6 +324,77 @@
cursor: not-allowed;
}
+/* Output Info Bar */
+.output-info-bar {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ padding: 10px 12px;
+ background: var(--code-bg);
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ font-size: 13px;
+}
+
+.output-info-bar--empty {
+ opacity: 0.6;
+}
+
+.output-info-placeholder {
+ color: var(--text);
+ font-size: 13px;
+}
+
+.output-info-stats {
+ display: flex;
+ gap: 16px;
+ flex-wrap: wrap;
+}
+
+.output-stat {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 1px;
+}
+
+.output-stat-label {
+ font-size: 11px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ color: var(--text);
+}
+
+.output-stat-value {
+ font-size: 15px;
+ font-weight: 600;
+ font-variant-numeric: tabular-nums;
+}
+
+.output-stat--green .output-stat-value {
+ color: #27ae60;
+}
+
+.output-stat--yellow .output-stat-value {
+ color: #f39c12;
+}
+
+.output-stat--red .output-stat-value {
+ color: #e74c3c;
+}
+
+.output-info-warnings {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.output-info-warning {
+ font-size: 12px;
+ color: #e67e22;
+}
+
/* Placeholder views */
.placeholder-view {
display: flex;
diff --git a/app/src/components/OutputInfoBar.tsx b/app/src/components/OutputInfoBar.tsx
new file mode 100644
index 0000000..0af2c49
--- /dev/null
+++ b/app/src/components/OutputInfoBar.tsx
@@ -0,0 +1,68 @@
+import type { TraceMetadata } from '../types/engine';
+
+interface OutputInfoBarProps {
+ metadata: TraceMetadata | null;
+}
+
+type StatColor = 'green' | 'yellow' | 'red';
+
+function getNodeCountColor(count: number): StatColor {
+ if (count > 5000) return 'yellow';
+ return 'green';
+}
+
+function getOpenPathsColor(count: number): StatColor {
+ if (count > 0) return 'red';
+ return 'green';
+}
+
+export default function OutputInfoBar({ metadata }: OutputInfoBarProps) {
+ if (!metadata) {
+ return (
+
+
+ Trace an image to see output stats
+
+
+ );
+ }
+
+ const nodeColor = getNodeCountColor(metadata.node_count_total);
+ const openPathsColor = getOpenPathsColor(metadata.open_paths);
+
+ return (
+
+
+
+ Paths
+ {metadata.path_count}
+
+
+ Nodes
+ {metadata.node_count_total.toLocaleString()}
+
+
+ Open Paths
+ {metadata.open_paths}
+
+
+ Time
+ {metadata.processing_ms}ms
+
+
+ {metadata.warnings.length > 0 && (
+
+ {metadata.warnings.map((w, i) => (
+ ⚠ {w}
+ ))}
+
+ )}
+
+ );
+}
diff --git a/app/src/components/__tests__/OutputInfoBar.test.tsx b/app/src/components/__tests__/OutputInfoBar.test.tsx
new file mode 100644
index 0000000..bc549f3
--- /dev/null
+++ b/app/src/components/__tests__/OutputInfoBar.test.tsx
@@ -0,0 +1,88 @@
+import { describe, it, expect } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import OutputInfoBar from '../OutputInfoBar';
+import type { TraceMetadata } from '../../types/engine';
+
+function makeMetadata(overrides: Partial = {}): TraceMetadata {
+ return {
+ format: 'svg',
+ path_count: 42,
+ node_count_total: 1200,
+ open_paths: 0,
+ island_count: 3,
+ warnings: [],
+ processing_ms: 150,
+ ...overrides,
+ };
+}
+
+describe('OutputInfoBar', () => {
+ it('shows placeholder when metadata is null', () => {
+ const { container } = render();
+ expect(container.querySelector('.output-info-bar--empty')).toBeTruthy();
+ expect(screen.getByText(/trace an image/i)).toBeInTheDocument();
+ });
+
+ it('renders stats with green indicators for normal metadata', () => {
+ const meta = makeMetadata();
+ render();
+
+ const pathsStat = screen.getByTestId('stat-paths');
+ expect(pathsStat).toHaveClass('output-stat--green');
+ expect(pathsStat).toHaveTextContent('42');
+
+ const nodesStat = screen.getByTestId('stat-nodes');
+ expect(nodesStat).toHaveClass('output-stat--green');
+ expect(nodesStat).toHaveTextContent('1,200');
+
+ const openStat = screen.getByTestId('stat-open-paths');
+ expect(openStat).toHaveClass('output-stat--green');
+ expect(openStat).toHaveTextContent('0');
+
+ const timeStat = screen.getByTestId('stat-time');
+ expect(timeStat).toHaveClass('output-stat--green');
+ expect(timeStat).toHaveTextContent('150ms');
+ });
+
+ it('shows yellow indicator when node_count_total > 5000', () => {
+ const meta = makeMetadata({ node_count_total: 8500 });
+ render();
+
+ const nodesStat = screen.getByTestId('stat-nodes');
+ expect(nodesStat).toHaveClass('output-stat--yellow');
+ expect(nodesStat).toHaveTextContent('8,500');
+ });
+
+ it('shows red indicator when open_paths > 0', () => {
+ const meta = makeMetadata({ open_paths: 3 });
+ render();
+
+ const openStat = screen.getByTestId('stat-open-paths');
+ expect(openStat).toHaveClass('output-stat--red');
+ expect(openStat).toHaveTextContent('3');
+ });
+
+ it('shows both yellow and red indicators simultaneously', () => {
+ const meta = makeMetadata({ node_count_total: 6000, open_paths: 5 });
+ render();
+
+ expect(screen.getByTestId('stat-nodes')).toHaveClass('output-stat--yellow');
+ expect(screen.getByTestId('stat-open-paths')).toHaveClass('output-stat--red');
+ });
+
+ it('displays warnings when present', () => {
+ const meta = makeMetadata({ warnings: ['Too many paths', 'Complex geometry'] });
+ render();
+
+ const warnings = screen.getByTestId('warnings');
+ expect(warnings).toHaveTextContent('Too many paths');
+ expect(warnings).toHaveTextContent('Complex geometry');
+ });
+
+ it('does not render warnings container when no warnings', () => {
+ const meta = makeMetadata({ warnings: [] });
+ render();
+
+ expect(screen.queryByTestId('warnings')).not.toBeInTheDocument();
+ });
+});
diff --git a/app/src/views/ImportConvert.tsx b/app/src/views/ImportConvert.tsx
index c566b12..5d3c0d1 100644
--- a/app/src/views/ImportConvert.tsx
+++ b/app/src/views/ImportConvert.tsx
@@ -4,6 +4,7 @@ import FileUpload from '../components/FileUpload';
import PresetSelector from '../components/PresetSelector';
import ParameterSliders from '../components/ParameterSliders';
import SvgPreview from '../components/SvgPreview';
+import OutputInfoBar from '../components/OutputInfoBar';
import { useDebouncedTrace } from '../hooks/useDebouncedTrace';
import styles from './ImportConvert.module.css';
@@ -93,15 +94,14 @@ export default function ImportConvert({ onUseThis }: ImportConvertProps) {
params={currentParams}
onChange={handleParamsChange}
/>
- {svgOutput && (
-
- )}
+
+
);