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 && ( - - )} +
+
);