diff --git a/app/src/App.tsx b/app/src/App.tsx
index 8569544..55543a0 100644
--- a/app/src/App.tsx
+++ b/app/src/App.tsx
@@ -4,6 +4,7 @@ import type { TraceMetadata } from './types/engine';
import { useCanvasState } from './hooks/useCanvasState';
import ImportConvert from './views/ImportConvert';
import DesignCanvas from './views/DesignCanvas';
+import ExportView from './views/ExportView';
import './App.css';
type ViewState = 'import' | 'canvas' | 'export';
@@ -51,14 +52,12 @@ function App() {
/>
)}
{view === 'export' && (
-
-
View 3: Export (placeholder)
-
PNG preview captured: {pngDataUrl ? 'Yes' : 'No'}
-
Objects: {canvasState.state.objects.length}
-
-
+
)}
);
diff --git a/app/src/views/ExportView.module.css b/app/src/views/ExportView.module.css
new file mode 100644
index 0000000..7ef3b22
--- /dev/null
+++ b/app/src/views/ExportView.module.css
@@ -0,0 +1,284 @@
+/* Export View (View 3) layout */
+
+.container {
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+ height: 100svh;
+ overflow: hidden;
+}
+
+.header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+ padding: 12px 16px;
+ border-bottom: 1px solid var(--border);
+ background: var(--bg);
+ flex-shrink: 0;
+}
+
+.headerTitle {
+ font-size: 18px;
+ font-weight: 700;
+ color: var(--text-h);
+ margin: 0;
+}
+
+.backBtn {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 6px 12px;
+ border: 1px solid var(--border);
+ border-radius: 5px;
+ background: var(--bg);
+ color: var(--text-h);
+ font-size: 13px;
+ font-family: inherit;
+ cursor: pointer;
+ transition: border-color 0.12s, background-color 0.12s;
+}
+
+.backBtn:hover {
+ border-color: var(--accent-border);
+ background: var(--accent-bg);
+}
+
+.body {
+ display: flex;
+ flex: 1;
+ min-height: 0;
+ overflow-y: auto;
+}
+
+.mainPanel {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 24px;
+ padding: 32px 24px;
+ min-width: 0;
+}
+
+.sidePanel {
+ flex: 0 0 320px;
+ border-left: 1px solid var(--border);
+ background: var(--bg);
+ overflow-y: auto;
+ padding: 16px;
+ display: flex;
+ flex-direction: column;
+ gap: 20px;
+}
+
+/* Preview thumbnail */
+.previewSection {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 12px;
+ width: 100%;
+ max-width: 480px;
+}
+
+.previewLabel {
+ font-size: 12px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ color: var(--text);
+ align-self: flex-start;
+}
+
+.previewImage {
+ max-width: 100%;
+ max-height: 340px;
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ background: var(--code-bg);
+ object-fit: contain;
+}
+
+.previewPlaceholder {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 100%;
+ height: 200px;
+ border: 1px dashed var(--border);
+ border-radius: 8px;
+ color: var(--text);
+ font-size: 14px;
+}
+
+/* Format selector */
+.sectionLabel {
+ font-size: 12px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ color: var(--text);
+ margin: 0 0 8px;
+}
+
+.formatGrid {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.formatCard {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ padding: 10px 12px;
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ background: var(--bg);
+ cursor: pointer;
+ text-align: left;
+ transition: border-color 0.12s, background-color 0.12s;
+ font-family: inherit;
+}
+
+.formatCard:hover {
+ border-color: var(--accent-border);
+ background: var(--accent-bg);
+}
+
+.formatCardSelected {
+ border-color: var(--accent);
+ background: var(--accent-bg);
+ box-shadow: 0 0 0 1px var(--accent);
+}
+
+.formatName {
+ font-size: 14px;
+ font-weight: 600;
+ color: var(--text-h);
+}
+
+.formatDesc {
+ font-size: 12px;
+ color: var(--text);
+ line-height: 1.3;
+}
+
+/* Unit selector */
+.unitSelector {
+ display: flex;
+ gap: 16px;
+ align-items: center;
+}
+
+.unitLabel {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ font-size: 13px;
+ color: var(--text-h);
+ cursor: pointer;
+}
+
+.unitLabel input[type="radio"] {
+ accent-color: var(--accent);
+}
+
+/* Validation panel */
+.validationPanel {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.validationItem {
+ display: flex;
+ align-items: flex-start;
+ gap: 8px;
+ padding: 8px 10px;
+ border-radius: 6px;
+ font-size: 13px;
+ line-height: 1.4;
+}
+
+.validationError {
+ background: rgba(231, 76, 60, 0.1);
+ color: #e74c3c;
+ border: 1px solid rgba(231, 76, 60, 0.25);
+}
+
+.validationWarning {
+ background: rgba(243, 156, 18, 0.1);
+ color: #e67e22;
+ border: 1px solid rgba(243, 156, 18, 0.25);
+}
+
+.validationIcon {
+ flex-shrink: 0;
+ font-size: 14px;
+ line-height: 1.4;
+}
+
+.validationOk {
+ font-size: 13px;
+ color: #27ae60;
+ padding: 8px 10px;
+}
+
+/* Download button */
+.downloadBtn {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ width: 100%;
+ padding: 12px 24px;
+ background: var(--accent);
+ color: #fff;
+ border: none;
+ border-radius: 6px;
+ font-size: 15px;
+ font-weight: 600;
+ cursor: pointer;
+ transition: opacity 0.15s;
+ font-family: inherit;
+}
+
+.downloadBtn:hover {
+ opacity: 0.9;
+}
+
+.downloadBtn:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.downloadBtnExporting {
+ opacity: 0.7;
+ cursor: wait;
+}
+
+.exportError {
+ font-size: 13px;
+ color: #e74c3c;
+ padding: 8px 10px;
+ background: rgba(231, 76, 60, 0.08);
+ border-radius: 6px;
+}
+
+/* Responsive */
+@media (max-width: 768px) {
+ .body {
+ flex-direction: column;
+ }
+
+ .sidePanel {
+ flex: none;
+ border-left: none;
+ border-top: 1px solid var(--border);
+ }
+}
diff --git a/app/src/views/ExportView.tsx b/app/src/views/ExportView.tsx
new file mode 100644
index 0000000..70a24d6
--- /dev/null
+++ b/app/src/views/ExportView.tsx
@@ -0,0 +1,305 @@
+/**
+ * ExportView (View 3) — format selection, validation, and download.
+ *
+ * Receives canvas state from App.tsx (lifted in T02) and uses the export
+ * service (T03) to compose SVG, validate, call the engine API, and trigger
+ * downloads.
+ */
+
+import { useCallback, useEffect, useMemo, useState } from 'react';
+import type { ArtboardConfig, ArtboardUnit, CanvasObject } from '../types/canvas';
+import {
+ composeCanvasSVG,
+ validateForExport,
+ triggerDownload,
+ type ValidationResult,
+} from '../utils/exportService';
+import { exportAsDxf } from '../api/engine';
+import styles from './ExportView.module.css';
+
+// -- Types --------------------------------------------------------------------
+
+export type ExportFormat = 'dxf' | 'svg' | 'png';
+
+export interface ExportViewProps {
+ objects: CanvasObject[];
+ artboard: ArtboardConfig | null;
+ pngDataUrl: string | null;
+ onBack: () => void;
+}
+
+interface FormatOption {
+ id: ExportFormat;
+ name: string;
+ description: string;
+}
+
+const FORMAT_OPTIONS: FormatOption[] = [
+ {
+ id: 'dxf',
+ name: 'DXF',
+ description: 'AutoCAD / LightBurn — vector paths with real-world scale',
+ },
+ {
+ id: 'svg',
+ name: 'SVG',
+ description: 'Scalable vector — Inkscape, Illustrator, web',
+ },
+ {
+ id: 'png',
+ name: 'PNG',
+ description: 'Raster image — print preview, sharing',
+ },
+];
+
+// Scale factors: canvas uses 96 PPI internally
+const SCALE_FACTORS: Record = {
+ inches: 1 / 96,
+ mm: 25.4 / 96,
+};
+
+// -- Component ----------------------------------------------------------------
+
+export default function ExportView({
+ objects,
+ artboard,
+ pngDataUrl,
+ onBack,
+}: ExportViewProps) {
+ const [selectedFormat, setSelectedFormat] = useState('dxf');
+ const [selectedUnit, setSelectedUnit] = useState(
+ artboard?.unit ?? 'inches',
+ );
+ const [exporting, setExporting] = useState(false);
+ const [exportError, setExportError] = useState(null);
+
+ // Run validation on mount and when objects/artboard change
+ const validation: ValidationResult = useMemo(
+ () => validateForExport(objects, artboard),
+ [objects, artboard],
+ );
+
+ const hasBlockingErrors = validation.issues.some(
+ (i) => i.severity === 'error',
+ );
+
+ // Reset unit when artboard changes
+ useEffect(() => {
+ if (artboard) {
+ setSelectedUnit(artboard.unit);
+ }
+ }, [artboard]);
+
+ // Whether unit selector is relevant for the selected format
+ const showUnitSelector = selectedFormat === 'dxf' || selectedFormat === 'svg';
+
+ // -- Export handlers --------------------------------------------------------
+
+ const handleExport = useCallback(async () => {
+ if (!artboard && selectedFormat !== 'png') return;
+ setExporting(true);
+ setExportError(null);
+
+ try {
+ switch (selectedFormat) {
+ case 'dxf': {
+ const svgString = composeCanvasSVG(objects, artboard!);
+ const scaleFactor = SCALE_FACTORS[selectedUnit];
+ const dxfBlob = await exportAsDxf(
+ svgString,
+ selectedUnit,
+ scaleFactor,
+ );
+ triggerDownload(dxfBlob, 'export.dxf');
+ break;
+ }
+
+ case 'svg': {
+ const svgString = composeCanvasSVG(objects, artboard!);
+ const svgBlob = new Blob([svgString], { type: 'image/svg+xml' });
+ triggerDownload(svgBlob, 'export.svg');
+ break;
+ }
+
+ case 'png': {
+ if (!pngDataUrl) {
+ setExportError('No PNG preview available. Go back and try again.');
+ break;
+ }
+ // Convert data URL to Blob
+ const response = await fetch(pngDataUrl);
+ const pngBlob = await response.blob();
+ triggerDownload(pngBlob, 'export.png');
+ break;
+ }
+ }
+ } catch (err) {
+ const message =
+ err instanceof Error ? err.message : 'Export failed unexpectedly.';
+ setExportError(message);
+ console.error('[ExportView] export error:', err);
+ } finally {
+ setExporting(false);
+ }
+ }, [selectedFormat, selectedUnit, objects, artboard, pngDataUrl]);
+
+ // Disable download when there are blocking errors (except PNG which
+ // doesn't need artboard/vector validation)
+ const downloadDisabled =
+ exporting ||
+ (selectedFormat !== 'png' && hasBlockingErrors) ||
+ (selectedFormat === 'png' && !pngDataUrl);
+
+ // -- Render -----------------------------------------------------------------
+
+ return (
+
+ {/* Header */}
+
+ Export
+
+
+
+
+ {/* Main panel — preview */}
+
+
+
Preview
+ {pngDataUrl ? (
+

+ ) : (
+
+ No preview available
+
+ )}
+
+
+
+ {/* Side panel — format, units, validation, download */}
+
+
+
+ );
+}