feat: Create Dockerfile.app (node→nginx multi-stage), nginx.conf (SPA +…

- "docker/Dockerfile.app"
- "docker/nginx.conf"
- "docker-compose.yml"

GSD-Task: S02/T01
This commit is contained in:
jlightner 2026-03-26 06:43:59 +00:00
parent c60dd59c01
commit 06b6045d8c
20 changed files with 5210 additions and 17 deletions

View file

@ -24,6 +24,8 @@ Agents read this before every unit. Add entries when you discover something wort
| P010 | Font Y-axis flip: canvas_y = ascender - font_y * scale | app/src/utils/fontService.ts | opentype.js uses font coordinate system (Y-up) while canvas uses screen coordinates (Y-down). Apply `ascender * scale - font_y` to all Y values in path data. Without this, text renders upside-down. | | P010 | Font Y-axis flip: canvas_y = ascender - font_y * scale | app/src/utils/fontService.ts | opentype.js uses font coordinate system (Y-up) while canvas uses screen coordinates (Y-down). Apply `ascender * scale - font_y` to all Y values in path data. Without this, text renders upside-down. |
| P011 | Letter spacing requires manual per-character glyph positioning | app/src/utils/fontService.ts | opentype.js getPath() doesn't support letter spacing. Must iterate characters, get individual glyph paths, accumulate x-advance + spacing per character, and compose the final SVG d-attribute manually. | | P011 | Letter spacing requires manual per-character glyph positioning | app/src/utils/fontService.ts | opentype.js getPath() doesn't support letter spacing. Must iterate characters, get individual glyph paths, accumulate x-advance + spacing per character, and compose the final SVG d-attribute manually. |
| P012 | Adding new CanvasObject types requires exhaustive switch updates in 6 files | app/src/types/canvas.ts, KonvaStage, CanvasToolbar, ObjectPanel, ShapeProperties, AlignmentBar | TypeScript noFallthroughCasesInSwitch enforces exhaustive handling. When adding a new type to the CanvasObject union, all switch statements across these 6 files must be updated or compilation fails. | | P012 | Adding new CanvasObject types requires exhaustive switch updates in 6 files | app/src/types/canvas.ts, KonvaStage, CanvasToolbar, ObjectPanel, ShapeProperties, AlignmentBar | TypeScript noFallthroughCasesInSwitch enforces exhaustive handling. When adding a new type to the CanvasObject union, all switch statements across these 6 files must be updated or compilation fails. |
| P013 | Export service: compose → validate → download is pure-function pipeline | app/src/utils/exportService.ts | composeCanvasSVG() builds SVG string, validateForExport() checks for errors/warnings, triggerDownload() creates blob + hidden anchor. All pure functions, no React deps. ExportView orchestrates them. |
| P014 | Canvas state lifted to App.tsx for cross-view sharing | app/src/App.tsx | useCanvasState() called in App.tsx, all 14 return values spread as props to DesignCanvas via UseCanvasStateReturn interface. ExportView receives objects + artboard. PNG captured via stageRef before view transition since Konva stage unmounts. |
## Lessons Learned ## Lessons Learned
@ -39,3 +41,6 @@ Agents read this before every unit. Add entries when you discover something wort
| L008 | Engine container has dual health endpoints | /health on root (main.py) and /engine/health on router (routes.py) both exist | Use /engine/health for Docker HEALTHCHECK and external probes for namespace consistency | engine API, Docker | | L008 | Engine container has dual health endpoints | /health on root (main.py) and /engine/health on router (routes.py) both exist | Use /engine/health for Docker HEALTHCHECK and external probes for namespace consistency | engine API, Docker |
| L009 | useCallback-based triggerTrace caused infinite re-render loops | Object params create new references each render, retriggering useEffect even when values are unchanged | Restructure hook to use single useEffect with JSON.stringify(params) in dependency array instead of useCallback-based approach | app hooks, React | | L009 | useCallback-based triggerTrace caused infinite re-render loops | Object params create new references each render, retriggering useEffect even when values are unchanged | Restructure hook to use single useEffect with JSON.stringify(params) in dependency array instead of useCallback-based approach | app hooks, React |
| L010 | jest-canvas-mock crashes in Vitest with "ReferenceError: jest is not defined" | jest-canvas-mock internally calls jest.fn() in createImageBitmap.js — Jest globals don't exist in Vitest | Use vitest-canvas-mock (a Vitest-compatible fork) instead. Import it in test-setup.ts. | app tests, canvas | | L010 | jest-canvas-mock crashes in Vitest with "ReferenceError: jest is not defined" | jest-canvas-mock internally calls jest.fn() in createImageBitmap.js — Jest globals don't exist in Vitest | Use vitest-canvas-mock (a Vitest-compatible fork) instead. Import it in test-setup.ts. | app tests, canvas |
| L011 | `npx tsc -b --noEmit` and `npx vitest run` fail from project root | Root has no node_modules with tsc/vitest, and vitest doesn't pick up app/vite.config.ts jsdom environment | Root package.json with `"workspaces": ["app"]` + `npm install` hoists deps. Root tsconfig.json references `./app`. Root vitest.config.ts uses `test.projects: ['app/vite.config.ts']` to delegate. | monorepo, testing |
| L012 | DXF scale_factor is caller-computed: 1/96 for inches, 25.4/96 for mm (assuming 96 PPI artboard) | Engine generate_dxf() applies scale_factor uniformly to all coordinates but doesn't know PPI — caller must compute | App exportService computes scaleFactor based on artboard unit. Engine API `/engine/simplify` accepts `units` and `scale_factor` as form params. | engine-app contract |
| L013 | nginx:alpine healthcheck with `wget -qO- http://localhost:80/` fails | `localhost` resolves to `::1` (IPv6) in Alpine containers, but nginx default listens on `0.0.0.0` (IPv4 only) | Use `127.0.0.1` explicitly: `wget -qO- http://127.0.0.1:80/` | Docker, healthcheck |

View file

@ -14,16 +14,25 @@ Built the complete React frontend:
- **View 2 (Design Canvas):** Konva.js-powered 2D environment with artboard shapes (rect, circle, ellipse, shield, pennant), basic shapes, text objects with opentype.js font loading and text-to-path conversion, layers panel, alignment tools, property editing, keyboard shortcuts, undo/redo - **View 2 (Design Canvas):** Konva.js-powered 2D environment with artboard shapes (rect, circle, ellipse, shield, pennant), basic shapes, text objects with opentype.js font loading and text-to-path conversion, layers panel, alignment tools, property editing, keyboard shortcuts, undo/redo
- **95 tests, zero TypeScript errors**, 54 source files, 10,721 lines of code - **95 tests, zero TypeScript errors**, 54 source files, 10,721 lines of code
## Queued Milestones ## In-Progress Milestones
### ⬜ M003: Export, Deployment & Embedding ### 🔄 M003: Export, Deployment & Embedding
Export pipeline (SVG/DXF download from canvas), production deployment, embeddable widget.
**S01: Export Flow (View 3) + DXF Generation — ✅ Complete**
- Engine: Extended generate_dxf() with units/scale_factor/layer_map, $INSUNITS/$MEASUREMENT headers, /engine/simplify API wiring
- App: Lifted useCanvasState to App.tsx for cross-view state sharing, created exportService.ts (composeCanvasSVG, validateForExport, triggerDownload), added exportAsDxf API client
- App: Built ExportView with DXF/SVG/PNG format selector, validation panel, unit selector, download wiring
- **120 app tests + 36 engine output tests, zero TypeScript errors**
**S02: Docker Packaging + README — ⬜ Queued**
**S03: Embed Mode — ⬜ Queued**
## Tech Stack ## Tech Stack
- **Engine:** Python 3.11, FastAPI, OpenCV, pypotrace, vtracer, ezdxf - **Engine:** Python 3.11, FastAPI, OpenCV, pypotrace, vtracer, ezdxf
- **App:** Vite, React 18, TypeScript (strict), Konva.js, opentype.js - **App:** Vite, React 19, TypeScript (strict), Konva.js, opentype.js
- **Testing:** pytest (engine), Vitest + testing-library (app) - **Testing:** pytest (engine), Vitest + testing-library (app)
- **Infrastructure:** Docker multi-stage build, GHCR - **Infrastructure:** Docker multi-stage build, GHCR, npm workspaces monorepo
## Key Architecture Decisions ## Key Architecture Decisions
- D001: Engine is standalone module, App consumes via HTTP API only - D001: Engine is standalone module, App consumes via HTTP API only

View file

@ -35,3 +35,6 @@
{"cmd":"complete-task","params":{"milestoneId":"M003","sliceId":"S01","taskId":"T02"},"ts":"2026-03-26T06:19:28.695Z","actor":"agent","hash":"20e62f4b5af835c3","session_id":"49f8e0fe-34a0-4608-b519-eca93850ed7c"} {"cmd":"complete-task","params":{"milestoneId":"M003","sliceId":"S01","taskId":"T02"},"ts":"2026-03-26T06:19:28.695Z","actor":"agent","hash":"20e62f4b5af835c3","session_id":"49f8e0fe-34a0-4608-b519-eca93850ed7c"}
{"cmd":"complete-task","params":{"milestoneId":"M003","sliceId":"S01","taskId":"T03"},"ts":"2026-03-26T06:26:04.608Z","actor":"agent","hash":"b3de5441cc811cf7","session_id":"49f8e0fe-34a0-4608-b519-eca93850ed7c"} {"cmd":"complete-task","params":{"milestoneId":"M003","sliceId":"S01","taskId":"T03"},"ts":"2026-03-26T06:26:04.608Z","actor":"agent","hash":"b3de5441cc811cf7","session_id":"49f8e0fe-34a0-4608-b519-eca93850ed7c"}
{"cmd":"complete-task","params":{"milestoneId":"M003","sliceId":"S01","taskId":"T04"},"ts":"2026-03-26T06:29:08.965Z","actor":"agent","hash":"c8adae40d118a764","session_id":"49f8e0fe-34a0-4608-b519-eca93850ed7c"} {"cmd":"complete-task","params":{"milestoneId":"M003","sliceId":"S01","taskId":"T04"},"ts":"2026-03-26T06:29:08.965Z","actor":"agent","hash":"c8adae40d118a764","session_id":"49f8e0fe-34a0-4608-b519-eca93850ed7c"}
{"cmd":"complete-slice","params":{"milestoneId":"M003","sliceId":"S01"},"ts":"2026-03-26T06:35:25.542Z","actor":"agent","hash":"165605976be6d69e","session_id":"49f8e0fe-34a0-4608-b519-eca93850ed7c"}
{"cmd":"plan-slice","params":{"milestoneId":"M003","sliceId":"S02"},"ts":"2026-03-26T06:39:00.941Z","actor":"agent","hash":"24790b4f3bd69e22","session_id":"49f8e0fe-34a0-4608-b519-eca93850ed7c"}
{"cmd":"complete-task","params":{"milestoneId":"M003","sliceId":"S02","taskId":"T01"},"ts":"2026-03-26T06:43:57.680Z","actor":"agent","hash":"bd4dc62bfd702053","session_id":"49f8e0fe-34a0-4608-b519-eca93850ed7c"}

View file

@ -6,6 +6,6 @@ Complete the Kerf App with the Export view (View 3), Docker Compose packaging fo
## Slice Overview ## Slice Overview
| ID | Slice | Risk | Depends | Done | After this | | ID | Slice | Risk | Depends | Done | After this |
|----|-------|------|---------|------|------------| |----|-------|------|---------|------|------------|
| S01 | Export Flow (View 3) + DXF Generation | high — dxf scale accuracy and geometry quality | — | | Design a sign with text + imported vector, export as DXF, open in Inkscape/LightBurn with correct geometry and scale | | S01 | Export Flow (View 3) + DXF Generation | high — dxf scale accuracy and geometry quality | — | | Design a sign with text + imported vector, export as DXF, open in Inkscape/LightBurn with correct geometry and scale |
| S02 | Docker Packaging + README | low — docker packaging is well-understood pattern | S01 | ⬜ | docker-compose up starts all services; Engine container runs independently; healthchecks pass | | S02 | Docker Packaging + README | low — docker packaging is well-understood pattern | S01 | ⬜ | docker-compose up starts all services; Engine container runs independently; healthchecks pass |
| S03 | Embed Mode | medium — shadow dom + konva.js + font loading interactions | S02 | ⬜ | Embed snippet in plain HTML page; component renders; styles don't bleed; download works from embedded context | | S03 | Embed Mode | medium — shadow dom + konva.js + font loading interactions | S02 | ⬜ | Embed snippet in plain HTML page; component renders; styles don't bleed; download works from embedded context |

View file

@ -0,0 +1,130 @@
---
id: S01
parent: M003
milestone: M003
provides:
- ExportView component with DXF/SVG/PNG download
- exportService.ts utility functions (composeCanvasSVG, validateForExport, triggerDownload)
- exportAsDxf() API client function
- Engine DXF generation with real-world units and scale
- Root monorepo config (package.json workspaces, tsconfig references, vitest projects)
requires:
[]
affects:
- S02
- S03
key_files:
- engine/output/dxf.py
- engine/api/routes.py
- engine/tests/test_output.py
- app/src/App.tsx
- app/src/views/DesignCanvas.tsx
- app/src/views/ExportView.tsx
- app/src/views/ExportView.module.css
- app/src/utils/exportService.ts
- app/src/utils/__tests__/exportService.test.ts
- app/src/api/engine.ts
- app/src/api/__tests__/engine.test.ts
- package.json
- tsconfig.json
- vitest.config.ts
key_decisions:
- DXF $INSUNITS codes: inches=1, mm=4; $MEASUREMENT: inches=0, mm=1 — caller computes scale_factor
- Canvas state lifted to App.tsx via UseCanvasStateReturn interface for cross-view sharing
- Export service uses pure-function pipeline: composeCanvasSVG → validateForExport → triggerDownload
- PNG captured before view transition (stageRef.toDataURL) since Konva stage unmounts in export view
- Root npm workspaces + vitest projects config enables monorepo-wide verification from project root
patterns_established:
- Pure-function export pipeline (compose → validate → download) with no React dependencies
- npm workspaces monorepo with root tsconfig references and vitest workspace projects
- Pre-transition state capture pattern for data that requires a mounted component
observability_surfaces:
- none
drill_down_paths:
- .gsd/milestones/M003/slices/S01/tasks/T01-SUMMARY.md
- .gsd/milestones/M003/slices/S01/tasks/T02-SUMMARY.md
- .gsd/milestones/M003/slices/S01/tasks/T03-SUMMARY.md
- .gsd/milestones/M003/slices/S01/tasks/T04-SUMMARY.md
duration: ""
verification_result: passed
completed_at: 2026-03-26T06:35:25.484Z
blocker_discovered: false
---
# S01: Export Flow (View 3) + DXF Generation
**Built the complete Export view (View 3) with DXF/SVG/PNG format selection, pre-export validation, unit-aware DXF generation with real-world scale, and browser download — wired end-to-end from canvas state through engine API to file download.**
## What Happened
This slice delivered the complete export pipeline across both engine and app:
**Engine (T01):** Extended `generate_dxf()` with `units`, `scale_factor`, and `layer_map` parameters. DXF files now include correct `$INSUNITS` headers (1=inches, 4=mm) and `$MEASUREMENT` headers (0=imperial, 1=metric). `scale_factor` is applied uniformly to all polyline coordinates, enabling pixel-to-real-world unit conversion. The `/engine/simplify` API endpoint was extended with optional `units` and `scale_factor` form params that pass through to the DXF generator via `_format_response()`. 11 new tests verify scale conversion accuracy (384×576 px artboard → 4×6 inches or 101.6×152.4 mm).
**App Architecture (T02):** Lifted `useCanvasState()` from DesignCanvas to App.tsx so canvas state is accessible across views. DesignCanvas now receives all 14 state/action properties as props via the `UseCanvasStateReturn` interface. A `stageRef` is created in App.tsx and passed to DesignCanvas for PNG capture. The `handleExport` callback captures a 2x PNG data URL from the Konva stage before navigating to the export view (since the stage unmounts when switching views).
**Export Service (T03):** Created `exportService.ts` with three pure utility functions: `composeCanvasSVG()` renders visible canvas objects into SVG with correct viewBox and real-world unit dimensions, `validateForExport()` checks for blocking errors (unconverted text, missing artboard) and warnings (raster images), `triggerDownload()` creates a hidden anchor + blob URL for browser download. Added `exportAsDxf()` to the engine API client that POSTs SVG as FormData to `/engine/simplify` with DXF params and returns a raw blob.
**Export View (T04):** Built `ExportView` component with format selector (DXF/SVG/PNG cards), unit selector (inches/mm, shown for DXF/SVG only), validation panel (red blocking errors, yellow warnings), canvas preview thumbnail, and download button. Each format has its own export flow: DXF composes SVG → calls engine API → downloads blob; SVG composes SVG → downloads string blob; PNG uses pre-captured data URL → converts to blob → downloads.
**Root Config Fix:** Created root `package.json` with npm workspaces, `tsconfig.json` with project references, and `vitest.config.ts` with workspace projects to enable `npx tsc -b --noEmit` and `npx vitest run` from the project root.
## Verification
All verification gates pass from project root:
- `npx tsc -b --noEmit` — exit 0, zero TypeScript errors
- `npx vitest run` — 120/120 tests pass across 8 test files (app)
- `cd engine && .venv/bin/python -m pytest tests/test_output.py -v -W ignore::DeprecationWarning` — 36/36 tests pass (engine output)
Test coverage includes: DXF scale conversion (px→inches, px→mm), DXF header correctness ($INSUNITS, $MEASUREMENT), layer mapping, SVG composition for all canvas object types, export validation (text blocking, raster warnings, missing artboard), API client DXF export, triggerDownload blob handling.
## Requirements Advanced
- R019 — ExportView provides DXF/SVG/PNG format selection, validateForExport() catches unconverted text (blocking) and raster images (warning), pre-export validation panel renders errors/warnings with download gating
- R020 — Engine generate_dxf() sets $INSUNITS and applies scale_factor to coordinates; exportService computes scale_factor from artboard PPI (1/96 for inches, 25.4/96 for mm); verified via tests that 384x576 px → 4x6 inch DXF
## Requirements Validated
- R019 — ExportView with format selector, validation panel, and download wiring. 120 tests pass including validateForExport() tests for text blocking and raster warnings.
- R020 — DXF scale conversion tests: 384×576 px artboard → 4×6 inch and 101.6×152.4 mm coordinates with correct $INSUNITS headers. 11 dedicated engine tests.
## New Requirements Surfaced
None.
## Requirements Invalidated or Re-scoped
None.
## Deviations
- T01: ezdxf R2000 defaults $INSUNITS to 6 (meters) not 0 (unitless) as originally assumed — test adjusted accordingly.
- T03: Fixed 8 pre-existing tsc errors in files outside T03 scope (useDebouncedTrace.test.ts, fontService.test.ts, fontService.ts, ImportConvert.tsx, vite.config.ts) that were blocking the verification gate.
- Added root package.json (npm workspaces), tsconfig.json, and vitest.config.ts to enable verification commands from project root — these were not in the slice plan but required for the verification gate.
## Known Limitations
- DXF layer assignment is supported in the engine but not exposed in the ExportView UI — all paths go to the default layer. Can be added in a future slice if needed.
- PNG export captures at 2x pixel ratio; there's no user-selectable resolution option.
- SVG images are detected by checking src for 'image/svg+xml' or .svg extension; edge cases with unusual MIME types could be missed.
## Follow-ups
None.
## Files Created/Modified
- `engine/output/dxf.py` — Added units, scale_factor, layer_map keyword args to generate_dxf(); sets $INSUNITS/$MEASUREMENT headers; applies scale_factor to polyline coords
- `engine/api/routes.py` — Added units and scale_factor Form params to /engine/simplify; passes through to _format_response() → generate_dxf()
- `engine/tests/test_output.py` — Added 11 tests: scale conversion (inches/mm), header correctness, layer mapping, combined features
- `app/src/App.tsx` — Lifted useCanvasState() here, creates stageRef, captures PNG data URL on export, routes to ExportView with all props
- `app/src/views/DesignCanvas.tsx` — Receives canvas state/actions as props instead of calling useCanvasState(); uses passed stageRef; adds Export button
- `app/src/views/ExportView.tsx` — New component: format selector, unit selector, validation panel, preview thumbnail, download button with per-format export flows
- `app/src/views/ExportView.module.css` — New CSS module for ExportView layout and styling
- `app/src/utils/exportService.ts` — New: composeCanvasSVG(), validateForExport(), triggerDownload() pure utility functions
- `app/src/utils/__tests__/exportService.test.ts` — New: 21 tests for SVG composition, validation, and download trigger
- `app/src/api/engine.ts` — Added exportAsDxf() function: POSTs SVG blob via FormData to /engine/simplify with DXF params
- `app/src/api/__tests__/engine.test.ts` — Added exportAsDxf tests for FormData construction and blob response
- `package.json` — New root package.json with npm workspaces pointing to app/
- `tsconfig.json` — New root tsconfig.json with project reference to ./app
- `vitest.config.ts` — New root vitest config delegating to app/vite.config.ts via test.projects

View file

@ -0,0 +1,152 @@
# S01: Export Flow (View 3) + DXF Generation — UAT
**Milestone:** M003
**Written:** 2026-03-26T06:35:25.485Z
## UAT: S01 — Export Flow (View 3) + DXF Generation
### Preconditions
- Engine running at localhost:8000 (`cd engine && .venv/bin/python -m uvicorn api.main:app --host 0.0.0.0 --port 8000`)
- App dev server running at localhost:5173 (`cd app && npm run dev`)
- A raster image available for tracing (e.g., a simple logo PNG)
- Inkscape or LightBurn installed for DXF verification (optional but recommended)
---
### Test 1: Navigate to Export View
**Steps:**
1. Open http://localhost:5173
2. Upload a raster image in View 1
3. Select a preset and click "Use This" to proceed to View 2 (Design Canvas)
4. Add at least one shape or imported vector to the canvas
5. Click the "Export" button in the toolbar
**Expected:**
- App transitions to the Export view (View 3)
- A canvas preview thumbnail is displayed showing the design
- Format selector shows three options: DXF, SVG, PNG
- DXF is selected by default (or first format card is highlighted)
- No validation errors appear if all objects are vector-based
---
### Test 2: Pre-Export Validation — Unconverted Text Blocking
**Steps:**
1. In View 2, add a text object (type some text, do NOT convert to paths)
2. Click "Export"
**Expected:**
- Export view shows a red blocking error: text indicating unconverted text objects
- Download button is disabled
- Error message identifies the text object that needs conversion
---
### Test 3: Pre-Export Validation — Raster Image Warning
**Steps:**
1. In View 2, import a raster image (PNG/JPG) onto the canvas
2. Click "Export"
**Expected:**
- Export view shows a yellow warning about raster images
- Download button remains enabled (warnings don't block export)
- Warning text indicates raster images will be skipped in vector export
---
### Test 4: DXF Export with Inches
**Steps:**
1. Set up a design with an artboard (e.g., 4×6 inch rectangle)
2. Add vector objects (shapes, imported SVGs)
3. Navigate to Export view
4. Select DXF format
5. Select "inches" as the unit
6. Click Download
**Expected:**
- Browser downloads a file named `export.dxf`
- Open the DXF in Inkscape or a text editor:
- `$INSUNITS` header value is `1` (inches)
- `$MEASUREMENT` header value is `0` (imperial)
- Polyline coordinates span approximately 04 × 06 (matching artboard dimensions in inches)
- Geometry renders correctly with no corrupt paths
---
### Test 5: DXF Export with Millimeters
**Steps:**
1. Same design as Test 4
2. Select DXF format, select "mm" as the unit
3. Click Download
**Expected:**
- Downloaded `export.dxf` has:
- `$INSUNITS` header value is `4` (mm)
- `$MEASUREMENT` header value is `1` (metric)
- Coordinates span approximately 0101.6 × 0152.4 (4×6 inches in mm)
---
### Test 6: SVG Export
**Steps:**
1. Design with vector objects on artboard
2. Navigate to Export view
3. Select SVG format
4. Select unit (inches or mm)
5. Click Download
**Expected:**
- Browser downloads `export.svg`
- SVG file opens correctly in a browser or Inkscape
- SVG has correct `viewBox` matching artboard pixel dimensions
- SVG `width`/`height` attributes use real-world units (e.g., `width="4in"`)
- All visible canvas objects are present as SVG elements (rect, circle, ellipse, polyline, etc.)
- Hidden objects are not included
---
### Test 7: PNG Export
**Steps:**
1. Design with any objects (vector or raster)
2. Navigate to Export view
3. Select PNG format
**Expected:**
- Unit selector is hidden (not applicable for PNG)
- Validation panel may not show vector-specific warnings
- Click Download → browser downloads `export.png`
- PNG is a 2x resolution capture of the canvas
---
### Test 8: Back to Design Navigation
**Steps:**
1. From Export view, click "← Back to Design" button
**Expected:**
- App navigates back to View 2 (Design Canvas)
- All canvas objects and state are preserved
- Can make edits and return to Export view again
---
### Test 9: Empty Canvas Export Attempt
**Steps:**
1. Navigate to Export view with no artboard set
**Expected:**
- Validation shows a blocking error about missing artboard
- Download button is disabled
---
### Test 10: Automated Test Verification
**Steps:**
1. From project root: `npx tsc -b --noEmit`
2. From project root: `npx vitest run`
3. From engine dir: `cd engine && .venv/bin/python -m pytest tests/test_output.py -v -W ignore::DeprecationWarning`
**Expected:**
- TypeScript compilation: zero errors
- Vitest: 120/120 tests pass across 8 test files
- Engine pytest: 36/36 tests pass including DXF scale/units tests

View file

@ -0,0 +1,30 @@
{
"schemaVersion": 1,
"taskId": "T04",
"unitId": "M003/S01/T04",
"timestamp": 1774506561926,
"passed": false,
"discoverySource": "task-plan",
"checks": [
{
"command": "cd app",
"exitCode": 0,
"durationMs": 4,
"verdict": "pass"
},
{
"command": "npx tsc -b --noEmit",
"exitCode": 1,
"durationMs": 729,
"verdict": "fail"
},
{
"command": "npx vitest run",
"exitCode": 1,
"durationMs": 1566,
"verdict": "fail"
}
],
"retryAttempt": 1,
"maxRetries": 2
}

View file

@ -1,6 +1,43 @@
# S02: Docker Packaging + README # S02: Docker Packaging + README
**Goal:** Create Docker Compose configuration with kerf-engine, kerf-app, kerf-server services, environment config, volumes, and healthchecks **Goal:** Package the full Kerf stack (engine + app) into Docker Compose with healthchecks, and provide comprehensive README documentation.
**Demo:** After this: docker-compose up starts all services; Engine container runs independently; healthchecks pass **Demo:** After this: docker-compose up starts all services; Engine container runs independently; healthchecks pass
## Tasks ## Tasks
- [x] **T01: Create Dockerfile.app (node→nginx multi-stage), nginx.conf (SPA + /engine proxy), and docker-compose.yml with healthchecks — both services start healthy** — Create the three Docker infrastructure files that package the Kerf stack: a multi-stage Dockerfile for the app (node build → nginx), an nginx config that serves static files and proxies /engine/* to the engine container, and a docker-compose.yml that wires both services with healthchecks. Verify by building images and running the stack with curl checks.
**Key constraints from research:**
- The app builds via `tsc -b && vite build` in `app/` — needs all tsconfig files (tsconfig.json, tsconfig.app.json, tsconfig.node.json)
- Root `package.json` declares workspaces — run `npm ci` inside `app/` directory to avoid workspace resolution issues, OR copy root package files and install from root
- nginx must handle SPA routing with `try_files $uri $uri/ /index.html`
- nginx must proxy `/engine/` to `http://kerf-engine:8000` (Docker Compose service name)
- Engine Dockerfile already exists at `docker/Dockerfile.engine` — no changes needed
- Engine HEALTHCHECK uses `curl -sf http://localhost:8000/engine/health`
- App healthcheck: `curl -sf http://localhost:80/` or use nginx built-in
- Font MIME types (.ttf/.otf) should be handled by nginx (included by default in nginx:alpine)
- App port: 80 internal (nginx default), mapped to 3000 on host
- Engine port: 8000 internal, also exposed on host for standalone use
- Estimate: 45m
- Files: docker/Dockerfile.app, docker/nginx.conf, docker-compose.yml
- Verify: docker compose build && docker compose up -d && sleep 12 && docker compose ps && curl -sf http://localhost:8000/engine/health && curl -sf http://localhost:3000/ | head -c 100 && curl -sf http://localhost:3000/engine/health && docker compose down
- [ ] **T02: Rewrite README.md with project overview, quick start, API reference, and usage docs** — Replace the placeholder README with comprehensive documentation covering: what Kerf is, quick start with Docker Compose, engine API reference (all 4 endpoints with request/response examples), font system explanation, preset system, engine standalone usage, repository structure, and known limitations.
**Content outline:**
1. Project title + one-paragraph description
2. Quick Start — `docker compose up`, then visit localhost:3000
3. Repository Structure — engine/, app/, docker/ directories
4. Engine API Reference — document all 4 endpoints:
- `GET /engine/health` — healthcheck
- `GET /engine/presets` — list available presets
- `POST /engine/trace` — raster-to-vector with preprocessing+vectorization+postprocessing
- `POST /engine/simplify` — SVG simplification with optional DXF export (units, scale_factor)
5. Font System — bundled fonts (Lato, OpenSans, Roboto), text-to-path conversion
6. Presets — 5 built-in presets (sign, patch, stencil, detailed, custom), how they work
7. Engine Standalone Usage — running engine independently, Docker image, API examples
8. Development — local setup for engine (Python venv) and app (npm), dev proxy setup
9. Known Limitations
**Sources for API details:** `engine/api/routes.py` for endpoint signatures, `engine/presets/*.json` for preset list, `app/src/utils/fontService.ts` for font system.
- Estimate: 30m
- Files: README.md
- Verify: grep -c '^## ' README.md | xargs test 6 -le && grep -q 'docker compose up' README.md && grep -q '/engine/trace' README.md && grep -q '/engine/simplify' README.md && grep -q '/engine/presets' README.md && grep -q '/engine/health' README.md && echo 'README OK'

View file

@ -0,0 +1,82 @@
# S02 — Docker Packaging + README — Research
**Date:** 2026-03-26
## Summary
This slice packages the full Kerf stack (engine + app) into Docker Compose and writes a comprehensive README. The work is straightforward — the engine Dockerfile already exists and works (D004, `docker/Dockerfile.engine`), the app is a standard Vite/React build that produces static files, and all app→engine communication uses relative `/engine/*` URLs that only need an nginx reverse proxy.
The GSD-INITIATE brief mentions a `kerf-server` service, but no server-side code exists and none is needed — the app makes all API calls directly to the engine via `/engine/*` endpoints. The `kerf-app` container should be nginx serving the Vite build output, with a reverse proxy block forwarding `/engine/*` to the `kerf-engine` container. This matches how the Vite dev proxy already works (see `app/vite.config.ts`).
R021 requires `docker-compose up` to start all services healthy. R022 requires the engine container to be independently deployable. Both are achievable with the existing Dockerfile.engine and a new Dockerfile.app + docker-compose.yml + nginx config.
## Recommendation
Build three artifacts: `docker/Dockerfile.app` (multi-stage: node build → nginx), `docker/nginx.conf` (serve static + proxy `/engine/*`), and `docker-compose.yml` (two services with healthchecks). Skip `kerf-server` — it doesn't exist and isn't needed for this milestone. The README should replace the current placeholder with full documentation per the GSD-INITIATE spec (quick start, engine API reference, font system, presets, engine standalone usage).
## Implementation Landscape
### Key Files
- `docker/Dockerfile.engine`**Exists, complete.** Multi-stage build with pypotrace compilation. HEALTHCHECK on `/engine/health`. CMD runs uvicorn on port 8000. No changes needed.
- `docker/Dockerfile.app`**New.** Multi-stage: Stage 1 `node:20-slim` runs `npm ci && npm run build` in `app/`. Stage 2 `nginx:alpine` copies the `app/dist/` output + custom nginx.conf. The app's `vite build` command (in `app/package.json`: `"build": "tsc -b && vite build"`) produces static files in `app/dist/`.
- `docker/nginx.conf`**New.** Serves static files from `/usr/share/nginx/html`, reverse-proxies `/engine/` to `http://kerf-engine:8000`. Must handle SPA routing (try_files $uri /index.html). Include font MIME types for .ttf/.otf.
- `docker-compose.yml`**New.** Two services: `kerf-engine` (build from `docker/Dockerfile.engine`, port 8000, healthcheck) and `kerf-app` (build from `docker/Dockerfile.app`, port 80 mapped to host, depends_on kerf-engine healthy). Fonts bundled in the app image via `app/public/fonts/` (3 .ttf files already there — no volume mount needed for V1).
- `README.md`**Rewrite.** Current file is a 20-line placeholder. Replace with comprehensive docs per GSD-INITIATE spec: what Kerf is, quick start (`docker compose up`), engine API reference (4 endpoints), font system, preset system, engine standalone usage, known limitations.
- `.dockerignore`**Exists, adequate.** Already excludes .git, node_modules, .venv, __pycache__, etc.
### Supporting Files (read-only context)
- `engine/main.py` — FastAPI app, mounts router, CORS middleware, `/health` endpoint
- `engine/api/routes.py` — Router with `/engine/health`, `/engine/presets` (GET), `/engine/trace` (POST), `/engine/simplify` (POST)
- `engine/pyproject.toml` — Python deps list (fastapi, uvicorn, opencv-headless, pypotrace, vtracer, multipart, Pillow, ezdxf)
- `engine/presets/*.json` — 5 preset files (custom, detailed, patch, sign, stencil)
- `app/package.json` — Build script: `tsc -b && vite build`, deps include react, konva, opentype.js
- `app/vite.config.ts` — Dev proxy: `/engine``http://localhost:8000`
- `app/src/api/engine.ts` — All API calls use relative `/engine/*` URLs (no hardcoded host)
- `app/public/fonts/` — 3 bundled .ttf files (Lato, OpenSans, Roboto)
### Build Order
1. **Dockerfile.app + nginx.conf** — New files, but the pattern is standard (Vite build → nginx). The nginx proxy config is the key piece that replaces the Vite dev proxy for production.
2. **docker-compose.yml** — Wires the two services together. Depends on both Dockerfiles existing.
3. **README.md** — Independent of Docker files. Can be built in parallel or after.
4. **Verification**`docker compose build` (both images build), `docker compose up -d` (both services start), healthchecks pass, app loads in browser at localhost, engine API responds independently.
### Verification Approach
```bash
# Build both images
docker compose build
# Start stack, wait for healthy
docker compose up -d
docker compose ps # both services "healthy"
# Engine responds independently
curl -sf http://localhost:8000/engine/health # {"status":"ok"}
curl -sf http://localhost:8000/engine/presets | head -c 200 # JSON presets
# App serves and proxies
curl -sf http://localhost:3000/ | head -c 200 # HTML content
curl -sf http://localhost:3000/engine/health # proxied to engine
# Teardown
docker compose down
```
Also verify: `docker compose up kerf-engine` starts the engine alone (R022).
## Constraints
- **No `kerf-server` service.** The GSD-INITIATE brief mentions it but no code exists. The app calls engine directly via `/engine/*`. Omit from this slice; add if/when a backend is built.
- **Fonts are bundled, not volume-mounted.** The 3 .ttf files live in `app/public/fonts/` and get baked into the nginx image via Vite build. The GSD-INITIATE mentions volume-mounting fonts, but the current font system uses a hardcoded `BUNDLED_FONTS` array in `fontService.ts`, not a `fonts.json` manifest. Volume mounting can be added later.
- **Root `package.json` has `workspaces: ["app"]`.** The `npm ci` in the Dockerfile must run inside `app/` (not root) to avoid workspace resolution issues, OR copy root `package.json` + `package-lock.json` and run from root. Simpler: just `WORKDIR /app/app` and install there.
- **Engine CORS is currently `allow_origins=["*"]`.** Fine for Docker networking where nginx proxies. No CORS issues expected.
- **Port mapping:** Engine internal 8000, App/nginx internal 80. Expose app on host port 3000 (or 80) via compose. Engine can optionally be exposed for standalone use.
## Common Pitfalls
- **SPA routing in nginx** — Without `try_files $uri $uri/ /index.html`, direct navigation to app routes returns 404. The Vite app is a SPA with client-side routing.
- **App Dockerfile npm ci location** — The root `package.json` declares workspaces. Running `npm ci` from root would try to install workspace deps. Simpler to copy just `app/package.json` + `app/package-lock.json` and install in isolation, ignoring the workspace root.
- **App build needs `app/tsconfig*.json`** — The build script runs `tsc -b` before `vite build`, so all tsconfig files must be copied into the build stage.

View file

@ -0,0 +1,43 @@
---
estimated_steps: 12
estimated_files: 3
skills_used: []
---
# T01: Create Dockerfile.app, nginx.conf, and docker-compose.yml for full-stack packaging
Create the three Docker infrastructure files that package the Kerf stack: a multi-stage Dockerfile for the app (node build → nginx), an nginx config that serves static files and proxies /engine/* to the engine container, and a docker-compose.yml that wires both services with healthchecks. Verify by building images and running the stack with curl checks.
**Key constraints from research:**
- The app builds via `tsc -b && vite build` in `app/` — needs all tsconfig files (tsconfig.json, tsconfig.app.json, tsconfig.node.json)
- Root `package.json` declares workspaces — run `npm ci` inside `app/` directory to avoid workspace resolution issues, OR copy root package files and install from root
- nginx must handle SPA routing with `try_files $uri $uri/ /index.html`
- nginx must proxy `/engine/` to `http://kerf-engine:8000` (Docker Compose service name)
- Engine Dockerfile already exists at `docker/Dockerfile.engine` — no changes needed
- Engine HEALTHCHECK uses `curl -sf http://localhost:8000/engine/health`
- App healthcheck: `curl -sf http://localhost:80/` or use nginx built-in
- Font MIME types (.ttf/.otf) should be handled by nginx (included by default in nginx:alpine)
- App port: 80 internal (nginx default), mapped to 3000 on host
- Engine port: 8000 internal, also exposed on host for standalone use
## Inputs
- `docker/Dockerfile.engine`
- `app/package.json`
- `app/tsconfig.json`
- `app/tsconfig.app.json`
- `app/tsconfig.node.json`
- `app/vite.config.ts`
- `engine/main.py`
- `engine/api/routes.py`
- `.dockerignore`
## Expected Output
- `docker/Dockerfile.app`
- `docker/nginx.conf`
- `docker-compose.yml`
## Verification
docker compose build && docker compose up -d && sleep 12 && docker compose ps && curl -sf http://localhost:8000/engine/health && curl -sf http://localhost:3000/ | head -c 100 && curl -sf http://localhost:3000/engine/health && docker compose down

View file

@ -0,0 +1,85 @@
---
id: T01
parent: S02
milestone: M003
provides: []
requires: []
affects: []
key_files: ["docker/Dockerfile.app", "docker/nginx.conf", "docker-compose.yml"]
key_decisions: ["Used npm workspace root install (npm ci --workspace=app) for proper lockfile resolution", "Used wget with 127.0.0.1 for nginx:alpine healthcheck (curl unavailable, localhost resolves to IPv6)", "Set proxy_read_timeout 120s and client_max_body_size 50m for large image processing through proxy"]
patterns_established: []
drill_down_paths: []
observability_surfaces: []
duration: ""
verification_result: "Built both images with docker compose build (success). Started stack with docker compose up -d. Both containers reported (healthy) in docker compose ps. Verified: engine health direct (curl :8000/engine/health → ok), app serves HTML (curl :3000/ → doctype html), engine health proxied (curl :3000/engine/health → ok), engine presets proxied (curl :3000/engine/presets → preset JSON). Clean teardown with docker compose down."
completed_at: 2026-03-26T06:43:57.639Z
blocker_discovered: false
---
# T01: Create Dockerfile.app (node→nginx multi-stage), nginx.conf (SPA + /engine proxy), and docker-compose.yml with healthchecks — both services start healthy
> Create Dockerfile.app (node→nginx multi-stage), nginx.conf (SPA + /engine proxy), and docker-compose.yml with healthchecks — both services start healthy
## What Happened
---
id: T01
parent: S02
milestone: M003
key_files:
- docker/Dockerfile.app
- docker/nginx.conf
- docker-compose.yml
key_decisions:
- Used npm workspace root install (npm ci --workspace=app) for proper lockfile resolution
- Used wget with 127.0.0.1 for nginx:alpine healthcheck (curl unavailable, localhost resolves to IPv6)
- Set proxy_read_timeout 120s and client_max_body_size 50m for large image processing through proxy
duration: ""
verification_result: passed
completed_at: 2026-03-26T06:43:57.650Z
blocker_discovered: false
---
# T01: Create Dockerfile.app (node→nginx multi-stage), nginx.conf (SPA + /engine proxy), and docker-compose.yml with healthchecks — both services start healthy
**Create Dockerfile.app (node→nginx multi-stage), nginx.conf (SPA + /engine proxy), and docker-compose.yml with healthchecks — both services start healthy**
## What Happened
Created three Docker infrastructure files to package the full Kerf stack:\n\n1. **docker/Dockerfile.app** — Multi-stage build using node:22-alpine for the Vite/React build (npm workspace-aware install and build) and nginx:1.27-alpine for runtime. Copies built dist/ into nginx html root.\n\n2. **docker/nginx.conf** — Serves SPA with try_files fallback to index.html, proxies /engine/* to kerf-engine:8000 with 120s timeouts for large image processing, 50m client_max_body_size for multipart uploads, gzip compression, and static asset caching.\n\n3. **docker-compose.yml** — Two services: kerf-engine (port 8000) and kerf-app (port 3000→80). App depends on engine being healthy. Both have Docker healthchecks.\n\nFixed an IPv6 resolution issue where nginx:alpine healthcheck using localhost failed because it resolved to ::1 while nginx listens on 0.0.0.0 only. Used 127.0.0.1 explicitly.
## Verification
Built both images with docker compose build (success). Started stack with docker compose up -d. Both containers reported (healthy) in docker compose ps. Verified: engine health direct (curl :8000/engine/health → ok), app serves HTML (curl :3000/ → doctype html), engine health proxied (curl :3000/engine/health → ok), engine presets proxied (curl :3000/engine/presets → preset JSON). Clean teardown with docker compose down.
## Verification Evidence
| # | Command | Exit Code | Verdict | Duration |
|---|---------|-----------|---------|----------|
| 1 | `docker compose build` | 0 | ✅ pass | 16300ms |
| 2 | `docker compose up -d && sleep 20 && docker compose ps` | 0 | ✅ pass (both healthy) | 26000ms |
| 3 | `curl -sf http://localhost:8000/engine/health` | 0 | ✅ pass | 200ms |
| 4 | `curl -sf http://localhost:3000/ | head -c 100` | 0 | ✅ pass | 200ms |
| 5 | `curl -sf http://localhost:3000/engine/health` | 0 | ✅ pass | 200ms |
| 6 | `docker compose down` | 0 | ✅ pass | 3000ms |
## Deviations
Changed healthcheck URL from localhost to 127.0.0.1 in Dockerfile.app and docker-compose.yml to fix IPv6 resolution failure in nginx:alpine containers.
## Known Issues
None.
## Files Created/Modified
- `docker/Dockerfile.app`
- `docker/nginx.conf`
- `docker-compose.yml`
## Deviations
Changed healthcheck URL from localhost to 127.0.0.1 in Dockerfile.app and docker-compose.yml to fix IPv6 resolution failure in nginx:alpine containers.
## Known Issues
None.

View file

@ -0,0 +1,47 @@
---
estimated_steps: 16
estimated_files: 1
skills_used: []
---
# T02: Rewrite README.md with project overview, quick start, API reference, and usage docs
Replace the placeholder README with comprehensive documentation covering: what Kerf is, quick start with Docker Compose, engine API reference (all 4 endpoints with request/response examples), font system explanation, preset system, engine standalone usage, repository structure, and known limitations.
**Content outline:**
1. Project title + one-paragraph description
2. Quick Start — `docker compose up`, then visit localhost:3000
3. Repository Structure — engine/, app/, docker/ directories
4. Engine API Reference — document all 4 endpoints:
- `GET /engine/health` — healthcheck
- `GET /engine/presets` — list available presets
- `POST /engine/trace` — raster-to-vector with preprocessing+vectorization+postprocessing
- `POST /engine/simplify` — SVG simplification with optional DXF export (units, scale_factor)
5. Font System — bundled fonts (Lato, OpenSans, Roboto), text-to-path conversion
6. Presets — 5 built-in presets (sign, patch, stencil, detailed, custom), how they work
7. Engine Standalone Usage — running engine independently, Docker image, API examples
8. Development — local setup for engine (Python venv) and app (npm), dev proxy setup
9. Known Limitations
**Sources for API details:** `engine/api/routes.py` for endpoint signatures, `engine/presets/*.json` for preset list, `app/src/utils/fontService.ts` for font system.
## Inputs
- `engine/api/routes.py`
- `engine/main.py`
- `engine/presets/sign.json`
- `engine/presets/patch.json`
- `engine/presets/stencil.json`
- `engine/presets/detailed.json`
- `engine/presets/custom.json`
- `app/package.json`
- `docker/Dockerfile.engine`
- `docker-compose.yml`
## Expected Output
- `README.md`
## Verification
grep -c '^## ' README.md | xargs test 6 -le && grep -q 'docker compose up' README.md && grep -q '/engine/trace' README.md && grep -q '/engine/simplify' README.md && grep -q '/engine/presets' README.md && grep -q '/engine/health' README.md && echo 'README OK'

File diff suppressed because one or more lines are too long

37
docker-compose.yml Normal file
View file

@ -0,0 +1,37 @@
# Kerf — full-stack Docker Compose
# Runs the vectorization engine + web app behind nginx reverse-proxy.
#
# Usage:
# docker compose up -d # start all services
# docker compose ps # check health
# docker compose down # tear down
services:
kerf-engine:
build:
context: .
dockerfile: docker/Dockerfile.engine
ports:
- "8000:8000"
healthcheck:
test: ["CMD", "curl", "-sf", "http://localhost:8000/engine/health"]
interval: 15s
timeout: 5s
start_period: 10s
retries: 3
kerf-app:
build:
context: .
dockerfile: docker/Dockerfile.app
ports:
- "3000:80"
depends_on:
kerf-engine:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:80/"]
interval: 15s
timeout: 5s
start_period: 5s
retries: 3

39
docker/Dockerfile.app Normal file
View file

@ -0,0 +1,39 @@
# ── Kerf App — multi-stage Docker build ──
# Stage 1: Build the Vite/React app
# Stage 2: Serve via nginx with reverse-proxy to engine
# ── Stage 1: Build ──
FROM node:22-alpine AS builder
WORKDIR /build
# Copy root workspace config first (npm ci needs it for workspace resolution)
COPY package.json package-lock.json ./
# Copy the app workspace
COPY app/ ./app/
# Install dependencies from the workspace root
RUN npm ci --workspace=app
# Build the app (tsc -b && vite build)
RUN npm run build --workspace=app
# ── Stage 2: Runtime — nginx serving static files ──
FROM nginx:1.27-alpine AS runtime
# Remove default nginx site
RUN rm /etc/nginx/conf.d/default.conf
# Copy custom nginx config
COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
# Copy built assets from builder
COPY --from=builder /build/app/dist /usr/share/nginx/html
EXPOSE 80
HEALTHCHECK --interval=15s --timeout=5s --start-period=5s --retries=3 \
CMD wget -qO- http://127.0.0.1:80/ || exit 1
CMD ["nginx", "-g", "daemon off;"]

43
docker/nginx.conf Normal file
View file

@ -0,0 +1,43 @@
# Kerf App nginx reverse-proxy + SPA static server
# Serves the Vite-built app and proxies /engine/* to the kerf-engine container.
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# ── Gzip ──
gzip on;
gzip_types text/plain text/css application/javascript application/json image/svg+xml;
gzip_min_length 256;
# ── Proxy /engine/* to the engine container ──
location /engine/ {
proxy_pass http://kerf-engine:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Engine trace/simplify can take a while on large images
proxy_read_timeout 120s;
proxy_send_timeout 120s;
# Allow large image uploads (engine accepts multipart)
client_max_body_size 50m;
}
# ── SPA fallback serve index.html for client-side routes ──
location / {
try_files $uri $uri/ /index.html;
}
# ── Static asset caching ──
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|otf|eot)$ {
expires 7d;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
}

4271
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

6
package.json Normal file
View file

@ -0,0 +1,6 @@
{
"name": "kerf-engine-root",
"private": true,
"version": "0.0.0",
"workspaces": ["app"]
}

6
tsconfig.json Normal file
View file

@ -0,0 +1,6 @@
{
"files": [],
"references": [
{ "path": "./app" }
]
}

7
vitest.config.ts Normal file
View file

@ -0,0 +1,7 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
projects: ['app/vite.config.ts'],
},
})